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("need a hoe") || e.contains("need a dibber") {
1235        return Some("give them the tool or withdraw it on the route");
1236    }
1237    None
1238}
1239
1240/// True when a worker `last_error` is transient noise (soft-skip chatter).
1241pub fn worker_error_is_transient(err: &str) -> bool {
1242    let e = err.to_ascii_lowercase();
1243    e.contains("continuing route")
1244        || e.contains("storage full")
1245        || e.starts_with("nothing to withdraw")
1246}
1247
1248/// True for routine worker recoveries that should not paint the status bar.
1249/// Still logged when they change (see [`GameState::apply_hired_workers`]).
1250pub fn worker_error_is_hud_noise(err: &str) -> bool {
1251    let e = err.to_ascii_lowercase();
1252    // Durable complete-or-idle park must stay visible on HUD and attention lines.
1253    if e.contains("idling") && (e.contains("cannot reach") || e.contains("unreachable")) {
1254        return false;
1255    }
1256    e.contains("returned to lodging after path")
1257        || e.contains("path failure")
1258        || e.contains("no path to")
1259        || e.contains("pathfinding")
1260        // Soft stuck recovery — worker keeps working / replans; not a player action item.
1261        || e.contains("repathing")
1262        || e.contains("nudged clear")
1263        // Auto space rescue — informative in the log, not a red alert.
1264        || e.contains("auto-recovery")
1265        || e.contains("stranded at interior map coords")
1266}
1267
1268/// In-flight `SetWorkerJob` waiting for IntentAck (or a reject Interaction).
1269#[derive(Debug, Clone)]
1270pub struct PendingWorkerJobAck {
1271    pub seq: u32,
1272    pub worker_instance_id: String,
1273    pub worker_label: String,
1274    pub idle: bool,
1275    pub stop_count: usize,
1276    pub prev_route: Option<flatland_protocol::WorkerRouteView>,
1277    pub prev_mode: flatland_protocol::WorkerModeView,
1278    pub prev_step_label: String,
1279    pub prev_last_error: Option<String>,
1280}
1281
1282fn push_inventory_rows(
1283    rows: &mut Vec<InventoryRow>,
1284    depth: usize,
1285    stack: &flatland_protocol::ItemStack,
1286    from: &flatland_protocol::InventoryLocation,
1287    from_parent_instance_id: Option<uuid::Uuid>,
1288    section: InventorySection,
1289) {
1290    push_inventory_rows_filtered(
1291        rows,
1292        depth,
1293        stack,
1294        from,
1295        from_parent_instance_id,
1296        section,
1297        "",
1298    );
1299}
1300
1301fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
1302    if filter.is_empty() {
1303        return true;
1304    }
1305    let f = filter.to_ascii_lowercase();
1306    let name = stack
1307        .display_name
1308        .as_deref()
1309        .unwrap_or("")
1310        .to_ascii_lowercase();
1311    let tid = stack.template_id.to_ascii_lowercase();
1312    name.contains(&f)
1313        || tid.contains(&f)
1314        || stack
1315            .contents
1316            .iter()
1317            .any(|c| stack_matches_filter(c, filter))
1318}
1319
1320fn push_inventory_rows_filtered(
1321    rows: &mut Vec<InventoryRow>,
1322    depth: usize,
1323    stack: &flatland_protocol::ItemStack,
1324    from: &flatland_protocol::InventoryLocation,
1325    from_parent_instance_id: Option<uuid::Uuid>,
1326    section: InventorySection,
1327    filter: &str,
1328) {
1329    if !filter.is_empty() && !stack_matches_filter(stack, filter) {
1330        return;
1331    }
1332    let self_hit = filter.is_empty() || {
1333        let f = filter.to_ascii_lowercase();
1334        let name = stack
1335            .display_name
1336            .as_deref()
1337            .unwrap_or("")
1338            .to_ascii_lowercase();
1339        let tid = stack.template_id.to_ascii_lowercase();
1340        name.contains(&f) || tid.contains(&f)
1341    };
1342    rows.push(InventoryRow {
1343        depth,
1344        stack: stack.clone(),
1345        from: from.clone(),
1346        from_parent_instance_id,
1347        is_equip_shell: false,
1348        is_chest_shell: false,
1349        section,
1350    });
1351    for child in &stack.contents {
1352        if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
1353            push_inventory_rows_filtered(
1354                rows,
1355                depth + 1,
1356                child,
1357                from,
1358                stack.item_instance_id,
1359                section,
1360                if self_hit { "" } else { filter },
1361            );
1362        }
1363    }
1364}
1365
1366#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1367pub enum ShopTab {
1368    #[default]
1369    Buy,
1370    Sell,
1371}
1372
1373#[derive(Debug, Clone)]
1374pub struct NpcChatState {
1375    pub npc_id: String,
1376    pub npc_label: String,
1377    pub lines: Vec<String>,
1378    pub input: String,
1379    pub pending: bool,
1380    pub talk_depth: flatland_protocol::NpcTalkDepth,
1381    pub trade_allowed: bool,
1382    pub banner: Option<String>,
1383    pub suggested_topics: Vec<String>,
1384}
1385
1386impl Default for NpcChatState {
1387    fn default() -> Self {
1388        Self {
1389            npc_id: String::new(),
1390            npc_label: String::new(),
1391            lines: Vec::new(),
1392            input: String::new(),
1393            pending: false,
1394            talk_depth: flatland_protocol::NpcTalkDepth::Full,
1395            trade_allowed: true,
1396            banner: None,
1397            suggested_topics: Vec::new(),
1398        }
1399    }
1400}
1401
1402/// World XY for HUD, probes, and gfx: prefer live entity transform for wildlife combat bodies.
1403pub fn npc_world_xy(state: &GameState, npc: &NpcView) -> (f32, f32) {
1404    npc.entity_id
1405        .and_then(|eid| state.entities.iter().find(|e| e.id == eid))
1406        .map(|e| (e.transform.position.x, e.transform.position.y))
1407        .unwrap_or((npc.x, npc.y))
1408}
1409
1410#[derive(Debug, Clone)]
1411pub struct GameState {
1412    pub session_id: SessionId,
1413    pub entity_id: EntityId,
1414    /// Logged-in character — used to show owner-only container labels.
1415    pub character_id: Option<uuid::Uuid>,
1416    pub tick: Tick,
1417    pub chunk_rev: u64,
1418    pub content_rev: u64,
1419    pub publish_rev: u64,
1420    pub entities: Vec<EntityState>,
1421    pub player: Option<EntityState>,
1422    pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1423    pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1424    pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1425    pub buildings: Vec<BuildingView>,
1426    pub doors: Vec<DoorView>,
1427    pub interior_map: Option<InteriorMapView>,
1428    pub npcs: Vec<NpcView>,
1429    pub blueprints: Vec<BlueprintView>,
1430    /// Wall/roof packs for the B plot-build menu.
1431    pub building_materials: Vec<flatland_protocol::BuildingMaterialView>,
1432    /// Outdoor play AABB origin (meters).
1433    pub world_x0: f32,
1434    pub world_y0: f32,
1435    pub world_width_m: f32,
1436    pub world_height_m: f32,
1437    pub terrain_zones: Vec<TerrainZoneView>,
1438    pub z_platforms: Vec<ZPlatformView>,
1439    pub z_transitions: Vec<ZTransitionView>,
1440    /// Outdoor z-bands saved before an interior overwrite; restored when leaving.
1441    /// Tick deltas never refresh `z_platforms`, so without this, interior platforms stick.
1442    #[doc(hidden)]
1443    pub z_bands_outdoor_backup: Option<(Vec<ZPlatformView>, Vec<ZTransitionView>)>,
1444    pub world_clock: flatland_protocol::WorldClock,
1445    pub inventory: std::collections::HashMap<String, u32>,
1446    pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1447    /// Server item catalog (labels/categories). Survives inventory resync.
1448    pub item_catalog: std::collections::HashMap<String, ItemCatalogEntryView>,
1449    pub logs: VecDeque<String>,
1450    pub intents_sent: u64,
1451    pub ticks_received: u64,
1452    pub connected: bool,
1453    pub disconnect_reason: Option<String>,
1454    pub show_stats: bool,
1455    /// When true, the bottom system LOG dock is collapsed (sidebar gains the space).
1456    pub hud_log_hidden: bool,
1457    pub show_equip_menu: bool,
1458    pub equip_menu_index: usize,
1459    pub show_craft_menu: bool,
1460    pub craft_menu_index: usize,
1461    /// How many timed crafts to queue when confirming the craft menu.
1462    pub craft_batch_quantity: u32,
1463    /// Active craft list tab (Ready default).
1464    pub craft_tab: CraftTab,
1465    /// Search within the active craft tab (`/` to focus).
1466    pub craft_filter: String,
1467    pub craft_filter_focused: bool,
1468    /// Per-character favorites / recent (loaded when character id is known).
1469    pub craft_prefs: crate::craft_prefs::CraftCharacterPrefs,
1470    /// B — wall/roof material picker for plot buildings.
1471    pub show_plot_build_menu: bool,
1472    /// True = navigate walls; false = navigate roofs (Tab switches).
1473    pub plot_build_focus_wall: bool,
1474    pub plot_build_wall_index: usize,
1475    pub plot_build_roof_index: usize,
1476    pub show_shop_menu: bool,
1477    pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1478    pub bank_panel: Option<flatland_protocol::BankPanel>,
1479    pub bank_menu_index: usize,
1480    pub bank_ui_mode: BankUiMode,
1481    pub storage_panel: Option<flatland_protocol::StoragePanel>,
1482    pub market_panel: Option<flatland_protocol::MarketPanel>,
1483    /// Selected row among filtered market browse listings.
1484    pub market_menu_index: usize,
1485    /// Browse / list-pick text filter (`/` to focus).
1486    pub market_filter: String,
1487    pub market_filter_focused: bool,
1488    /// Category group filter (`None` = All). Values match [`inventory_category_group`] labels.
1489    pub market_category_filter: Option<&'static str>,
1490    /// Pending buy confirm: (listing_id, qty, unit_price, line_total, display_name).
1491    pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
1492    pub market_ui_mode: MarketUiMode,
1493    pub storage_menu_index: usize,
1494    pub storage_ui_mode: StorageUiMode,
1495    pub shop_tab: ShopTab,
1496    pub shop_menu_index: usize,
1497    pub shop_quantity: u32,
1498    /// Recent buy/sell lines while the shop panel is open (gfx dock).
1499    pub shop_trade_log: VecDeque<String>,
1500    pub show_npc_verb_menu: bool,
1501    pub npc_verb_target: Option<String>,
1502    pub npc_verb_index: usize,
1503    /// Last refuse/system line shown while the Talk/Trade verb dock is open.
1504    pub npc_verb_notice: Option<String>,
1505    /// Nearby player Speak / Whisper / Trade dock.
1506    pub player_verbs: crate::social::PlayerVerbState,
1507    pub social_chat: crate::social::SocialChatState,
1508    pub trade_ui: crate::social::TradeUiState,
1509    pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1510    pub show_npc_chat: bool,
1511    pub npc_chat: Option<NpcChatState>,
1512    pub show_inventory_menu: bool,
1513    pub inventory_menu_index: usize,
1514    pub inventory_tab: InventoryTab,
1515    pub inventory_filter: String,
1516    pub inventory_filter_focused: bool,
1517    pub show_move_picker: bool,
1518    pub move_picker_index: usize,
1519    pub move_picker: Option<MovePicker>,
1520    pub show_grant_picker: bool,
1521    pub grant_picker_index: usize,
1522    pub grant_picker: Option<GrantTargetPicker>,
1523    pub show_destroy_picker: bool,
1524    pub destroy_confirm_pending: bool,
1525    pub destroy_picker: Option<DestroyPicker>,
1526    /// Rename prompt for a selected container (`n` in inventory).
1527    pub show_rename_prompt: bool,
1528    /// When set, the rename prompt targets a property plot (deed or on-plot).
1529    pub rename_plot_id: Option<uuid::Uuid>,
1530    /// Plot AABB to emphasize on the map (farm picker / deed focus).
1531    pub highlighted_plot_id: Option<uuid::Uuid>,
1532    /// Rename prompt for a hired worker (`n` in workers menu).
1533    pub show_worker_rename: bool,
1534    pub rename_buffer: String,
1535    /// Slot-1 combat target (mirrors server after SetTarget).
1536    pub combat_target: Option<EntityId>,
1537    pub combat_target_label: Option<String>,
1538    /// Local free-aim ground target (world meters) set via Shift+click on open ground.
1539    /// Used as `Intent::Cast.target_point` for ground/either aim-mode abilities.
1540    pub ground_target: Option<(f32, f32, f32)>,
1541    /// Active combat footprints from the server (`plans/39`).
1542    pub combat_fx: Vec<flatland_protocol::CombatFx>,
1543    /// Lingering ground hazards (boss puddles) from the server.
1544    pub ground_hazards: Vec<flatland_protocol::GroundHazardView>,
1545    /// Authored crown property zones (claimable land) — plan 40.
1546    pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1547    /// Tax overlays for claim cost premium preview.
1548    pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1549    /// Growth / fertility overlays (farming, respawn).
1550    pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1551    /// Climate / biome overlays.
1552    pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1553    /// Terrain kind speeds / impassable flags for auto-nav (from content).
1554    pub terrain_kind_nav: Vec<flatland_protocol::TerrainKindNavView>,
1555    /// Claimed property plots near the observer.
1556    pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1557    /// Server knobs for client-side claim quotes.
1558    pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1559    /// Local claim-footprint editor (not server state).
1560    pub claim_mode: Option<ClaimModeState>,
1561    /// Local relocate ghost for a placed chest/lodging (not server state).
1562    pub relocate_mode: Option<RelocateModeState>,
1563    /// Second `f` within a short window confirms selling this plot back to the crown.
1564    pub sell_plot_confirm: Option<uuid::Uuid>,
1565    /// When `sell_plot_confirm` was armed (for double-tap window).
1566    pub sell_plot_armed_at: Option<Instant>,
1567    /// Choose seed type when planting on owned tilled soil (`f`).
1568    pub show_plant_menu: bool,
1569    pub plant_menu_index: usize,
1570    /// Deed-holder farm access panel (public toggle + allow-list).
1571    pub show_farm_access: bool,
1572    /// Draft name for adding a tenant from the farm-access panel.
1573    pub farm_access_name_draft: String,
1574    /// Public / grant discount draft (bps) while editing farm access.
1575    pub farm_access_discount_bps: u32,
1576    /// Selected row in the farm-access panel (0 = public toggle).
1577    pub farm_access_index: usize,
1578    pub plant_quantity: u32,
1579    pub in_combat: bool,
1580    pub auto_attack: bool,
1581    pub combat_has_los: bool,
1582    pub attack_cd_ticks: u64,
1583    pub gcd_ticks: u64,
1584    pub weapon_ability_id: String,
1585    pub mainhand_template_id: Option<String>,
1586    pub mainhand_label: Option<String>,
1587    pub mainhand_instance_id: Option<uuid::Uuid>,
1588    pub offhand_template_id: Option<String>,
1589    pub offhand_label: Option<String>,
1590    pub offhand_instance_id: Option<uuid::Uuid>,
1591    pub mainhand_hand_slots: u8,
1592    pub defense: Option<flatland_protocol::DefenseHud>,
1593    /// Worn body-slot items — armor, cloak, jewelry, backpack, belt.
1594    pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1595    pub carry_mass: f32,
1596    pub carry_mass_max: f32,
1597    pub encumbrance: flatland_protocol::EncumbranceState,
1598    /// Walk m/s after encumbrance / DEX (from combat HUD). `0` until first HUD.
1599    pub move_speed_mps: f32,
1600    /// Encumbrance+status walk multiplier (`1.0` unloaded). `0` until first HUD.
1601    pub move_speed_mult: f32,
1602    /// Full nested inventory stacks from the server (root only; worn are separate).
1603    pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1604    /// Keys stowed on the virtual keychain (zero carry mass).
1605    pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1606    /// Whisper stones in the pouch (zero carry mass).
1607    pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1608    /// Active status effects on the local player (buff/debuff strip).
1609    pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1610    pub combat_target_detail: Option<CombatTargetHud>,
1611    pub cast_progress: Option<CastProgressHud>,
1612    /// Till / plant / other non-combat timed channels on the local player.
1613    pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1614    /// Owned plot underfoot offer (pad + storage pool) for the B menu.
1615    pub plot_build_offer: Option<flatland_protocol::PlotBuildOfferHud>,
1616    pub ability_cooldowns: Vec<AbilityCooldownHud>,
1617    pub blocking_active: bool,
1618    pub max_target_slots: u8,
1619    pub combat_slots: Vec<CombatSlotHud>,
1620    pub rotation_presets: Vec<RotationPreset>,
1621    /// Learned abilities from combat HUD (usable casts).
1622    pub known_abilities: Vec<String>,
1623    /// Aim / blast metadata for known abilities (ground cast UX), keyed by ability id.
1624    pub ability_meta: std::collections::HashMap<String, flatland_protocol::AbilityMetaHud>,
1625    /// Per-ability mastery from combat HUD (tier / XP).
1626    pub ability_mastery: std::collections::HashMap<String, flatland_protocol::AbilityMasteryHud>,
1627    /// Server-persisted hotbar bindings (index 0 = key 1).
1628    pub hotbar: Vec<Option<String>>,
1629    /// Max abilities allowed in one rotation (mind score).
1630    pub max_abilities_per_rotation: u8,
1631    pub show_loadout_menu: bool,
1632    pub show_keychain_menu: bool,
1633    pub keychain_menu_index: usize,
1634    pub show_rotation_editor: bool,
1635    /// Selected rotation preset in the loadout menu.
1636    pub loadout_menu_index: usize,
1637    /// Selected hotbar slot (`1`–`9`) for bind/clear in the loadout menu.
1638    pub loadout_hotbar_slot: u8,
1639    /// Selected known-ability row in the loadout menu.
1640    pub loadout_ability_index: usize,
1641    /// When true, ↑/↓ navigate presets; when false, navigate known abilities.
1642    pub loadout_focus_presets: bool,
1643    pub rotation_editor: RotationEditorState,
1644    /// True after a harvest intent is accepted until result/reject/disconnect.
1645    pub harvest_in_progress: bool,
1646    /// Wall-clock start of the current harvest; clears stale client state on timeout.
1647    pub harvest_started_at: Option<Instant>,
1648    /// Craft log deferred until the server acks the craft intent.
1649    pub pending_craft_ack: Option<(u32, String, u32)>,
1650    /// Blueprint kept on the Ready tab while its craft channel runs (inputs
1651    /// are spent at begin, so `can_craft` goes false before progress finishes).
1652    pub craft_channel_blueprint_id: Option<String>,
1653    pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1654    pub interactables: Vec<flatland_protocol::InteractableView>,
1655    pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1656    pub career: Option<flatland_protocol::PlayerCareerView>,
1657    pub character_sheet_tab: CharacterSheetTab,
1658    pub ledger_period: LedgerPeriod,
1659    pub show_quest_offer: bool,
1660    pub pending_quest_offers: Vec<flatland_protocol::QuestOffer>,
1661    pub quest_offer_index: usize,
1662    pub show_quest_menu: bool,
1663    pub quest_menu_index: usize,
1664    pub quest_withdraw_confirm: bool,
1665    pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1666    pub show_workers_menu: bool,
1667    pub workers_menu_index: usize,
1668    pub worker_dismiss_confirmation: Option<WorkerDismissConfirmation>,
1669    /// Workers panel: one-line rows instead of full cards (more on screen).
1670    pub workers_menu_compact: bool,
1671    /// Coarse `step:` line held per worker so AOI ticks cannot thrash the menu.
1672    /// Public so out-of-crate tests can construct [`GameState`].
1673    pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1674    /// Held worker route errors (readable when server clears `last_error` each tick).
1675    pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1676    /// Wall-clock expiry for defender HP rings after their last observed hit.
1677    pub worker_health_ring_until: BTreeMap<EntityId, Instant>,
1678    /// Set after a hire request until its worker appears in a roster update.
1679    pub pending_worker_hire_since: Option<Instant>,
1680    /// Give-item sheet opened from the workers menu (`g`) — pick which item to give.
1681    pub show_worker_give_picker: bool,
1682    pub worker_give_picker_index: usize,
1683    pub worker_give_picker: Option<WorkerGivePicker>,
1684    /// Inventory `g` — pick which nearby worker receives the selected stack.
1685    pub show_worker_give_target_picker: bool,
1686    pub worker_give_target_picker_index: usize,
1687    pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1688    /// Take-item sheet opened from the workers menu (`i`) — pick which worker stack to take.
1689    pub show_worker_take_picker: bool,
1690    pub worker_take_picker_index: usize,
1691    pub worker_take_picker: Option<WorkerTakePicker>,
1692    /// Teach-blueprint sheet opened from the workers menu (`t`).
1693    pub show_worker_teach_picker: bool,
1694    pub worker_teach_picker_index: usize,
1695    pub worker_teach_picker: Option<WorkerTeachPicker>,
1696    /// Active harvest-route editor (`h` → `e` on a worker).
1697    pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1698    /// Awaiting IntentAck for the last route save (`SetWorkerJob`).
1699    pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1700    /// Worker currently paused via `AttendHiredWorker` (opened with `f`).
1701    pub attending_worker_instance_id: Option<String>,
1702    /// Server progression curve from the latest combat HUD (matches server-settings.yaml).
1703    pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1704}
1705
1706#[derive(Debug, Clone, PartialEq, Eq)]
1707pub enum NpcVerbAction {
1708    Talk,
1709    Trade,
1710    Bank,
1711    Storage,
1712    Market,
1713    QuestTalk { quest_id: String },
1714    QuestGive { quest_id: String },
1715}
1716
1717#[derive(Debug, Clone, PartialEq, Eq)]
1718pub struct NpcVerbChoice {
1719    pub label: String,
1720    pub action: NpcVerbAction,
1721}
1722
1723impl std::fmt::Display for NpcVerbChoice {
1724    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1725        f.write_str(&self.label)
1726    }
1727}
1728
1729impl GameState {
1730    pub fn selected_quest_offer(&self) -> Option<&flatland_protocol::QuestOffer> {
1731        self.pending_quest_offers.get(self.quest_offer_index)
1732    }
1733
1734    pub fn push_quest_offer(&mut self, offer: flatland_protocol::QuestOffer) {
1735        if self
1736            .pending_quest_offers
1737            .iter()
1738            .any(|existing| existing.quest_id == offer.quest_id)
1739        {
1740            self.show_quest_offer = true;
1741            return;
1742        }
1743        self.pending_quest_offers.push(offer);
1744        self.show_quest_offer = true;
1745    }
1746
1747    pub fn remove_quest_offer(&mut self, quest_id: &str) {
1748        self.pending_quest_offers
1749            .retain(|offer| offer.quest_id != quest_id);
1750        if self.pending_quest_offers.is_empty() {
1751            self.show_quest_offer = false;
1752            self.quest_offer_index = 0;
1753            return;
1754        }
1755        self.quest_offer_index = self
1756            .quest_offer_index
1757            .min(self.pending_quest_offers.len() - 1);
1758        self.show_quest_offer = true;
1759    }
1760
1761    pub fn clear_quest_offers(&mut self) {
1762        self.pending_quest_offers.clear();
1763        self.quest_offer_index = 0;
1764        self.show_quest_offer = false;
1765    }
1766
1767    pub fn move_quest_offer_selection(&mut self, delta: i32) {
1768        let n = self.pending_quest_offers.len();
1769        if n == 0 {
1770            self.quest_offer_index = 0;
1771            return;
1772        }
1773        let idx = self.quest_offer_index as i32;
1774        self.quest_offer_index = (idx + delta).rem_euclid(n as i32) as usize;
1775    }
1776
1777    pub fn push_log(&mut self, line: impl Into<String>) {
1778        self.logs.push_back(line.into());
1779        while self.logs.len() > MAX_LOG_LINES {
1780            self.logs.pop_front();
1781        }
1782    }
1783
1784    pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1785        self.shop_trade_log.push_back(line.into());
1786        while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1787            self.shop_trade_log.pop_front();
1788        }
1789    }
1790
1791    pub fn clear_shop_trade_log(&mut self) {
1792        self.shop_trade_log.clear();
1793    }
1794
1795    fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1796        if !self.show_shop_menu {
1797            return;
1798        }
1799        let msg = notice.message.trim();
1800        if msg.is_empty() {
1801            return;
1802        }
1803        if notice.coins_delta != 0
1804            || msg.starts_with("Bought ")
1805            || msg.starts_with("Sold ")
1806            || msg.contains("taught you how to craft")
1807            || msg.starts_with("need ")
1808        {
1809            self.push_shop_trade_log(msg);
1810        }
1811    }
1812
1813    pub fn is_alive(&self) -> bool {
1814        self.player
1815            .as_ref()
1816            .and_then(|p| p.vitals)
1817            .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1818            .unwrap_or(true)
1819    }
1820
1821    pub fn push_audio(&mut self, cue: crate::social::AudioCue) {
1822        self.social_chat.push_cue(cue);
1823    }
1824
1825    /// Rising/falling edges for combat and progression SFX (call after snapshot/tick apply).
1826    fn sync_gameplay_audio(&mut self) {
1827        use crate::social::AudioCue;
1828        use flatland_protocol::PrimaryAttributes;
1829
1830        let alive = self.is_alive();
1831        let casting = self.cast_progress.is_some();
1832        let telegraph = self.focus_attack_telegraph_active();
1833        let in_aoe = self.player_inside_spatial_telegraph();
1834        let quest_sig = self.quest_audio_signature();
1835        let entity_id = self.entity_id;
1836        let char_level = self
1837            .player
1838            .as_ref()
1839            .and_then(|p| p.attributes)
1840            .map(|a| {
1841                PrimaryAttributes::display(a.strength)
1842                    .saturating_add(PrimaryAttributes::display(a.dexterity))
1843                    .saturating_add(PrimaryAttributes::display(a.intelligence))
1844                    .saturating_add(PrimaryAttributes::display(a.stamina))
1845                    .saturating_add(PrimaryAttributes::display(a.vitality))
1846                    .saturating_add(PrimaryAttributes::display(a.wisdom))
1847                    .saturating_add(PrimaryAttributes::display(a.charisma))
1848            })
1849            .unwrap_or(0);
1850
1851        let fx_ids: Vec<u64> = self.combat_fx.iter().map(|fx| fx.id).collect();
1852        let mut hit_cues = Vec::new();
1853        {
1854            let seen = &self.social_chat.audio_seen_fx_ids;
1855            for fx in &self.combat_fx {
1856                if seen.contains(&fx.id) {
1857                    continue;
1858                }
1859                let Some(hit) = fx.hits.iter().find(|h| h.entity_id == entity_id) else {
1860                    continue;
1861                };
1862                if hit.outcome == CombatFxHitOutcome::Blocked {
1863                    hit_cues.push(AudioCue::CombatBlock);
1864                } else {
1865                    let heavy = matches!(
1866                        fx.kind,
1867                        CombatFxKind::Sphere | CombatFxKind::Cone | CombatFxKind::Beam
1868                    );
1869                    hit_cues.push(if heavy {
1870                        AudioCue::CombatHitHeavy
1871                    } else {
1872                        AudioCue::CombatHitLight
1873                    });
1874                }
1875            }
1876        }
1877
1878        let audio = &mut self.social_chat;
1879        if !audio.audio_bootstrapped {
1880            audio.audio_was_alive = alive;
1881            audio.audio_was_casting = casting;
1882            audio.audio_had_target_telegraph = telegraph;
1883            audio.audio_was_in_aoe = in_aoe;
1884            audio.audio_quest_sig = quest_sig;
1885            audio.audio_char_level = char_level;
1886            audio.audio_seen_fx_ids = fx_ids;
1887            audio.audio_bootstrapped = true;
1888            return;
1889        }
1890
1891        if telegraph && !audio.audio_had_target_telegraph {
1892            audio.push_cue(AudioCue::CombatTelegraphStart);
1893        } else if !telegraph && audio.audio_had_target_telegraph {
1894            audio.push_cue(AudioCue::CombatTelegraphImpact);
1895        }
1896        audio.audio_had_target_telegraph = telegraph;
1897
1898        if in_aoe && !audio.audio_was_in_aoe {
1899            audio.push_cue(AudioCue::CombatAoeWarn);
1900        }
1901        audio.audio_was_in_aoe = in_aoe;
1902
1903        if casting && !audio.audio_was_casting {
1904            audio.push_cue(AudioCue::AbilityCastSelf);
1905        }
1906        audio.audio_was_casting = casting;
1907
1908        if !alive && audio.audio_was_alive {
1909            audio.push_cue(AudioCue::PlayerDeath);
1910        }
1911        audio.audio_was_alive = alive;
1912
1913        if quest_sig != audio.audio_quest_sig && audio.audio_quest_sig != 0 {
1914            audio.push_cue(AudioCue::QuestUpdate);
1915        }
1916        audio.audio_quest_sig = quest_sig;
1917
1918        if char_level > audio.audio_char_level && audio.audio_char_level > 0 {
1919            audio.push_cue(AudioCue::LevelUp);
1920        }
1921        audio.audio_char_level = char_level;
1922
1923        for cue in hit_cues {
1924            audio.push_cue(cue);
1925        }
1926        audio.audio_seen_fx_ids = fx_ids;
1927    }
1928
1929    fn focus_attack_telegraph_active(&self) -> bool {
1930        let Some(tid) = self.combat_target else {
1931            return false;
1932        };
1933        self.entities
1934            .iter()
1935            .find(|e| e.id == tid)
1936            .map(|e| {
1937                e.combat_cues.iter().any(|c| {
1938                    matches!(c.kind, CombatCueKind::AttackTelegraph) && c.until_tick > self.tick
1939                })
1940            })
1941            .unwrap_or(false)
1942    }
1943
1944    fn player_inside_spatial_telegraph(&self) -> bool {
1945        let (px, py) = self.player_position();
1946        for e in &self.entities {
1947            for cue in &e.combat_cues {
1948                if !matches!(cue.kind, CombatCueKind::AttackTelegraph)
1949                    || cue.until_tick <= self.tick
1950                {
1951                    continue;
1952                }
1953                let Some(kind) = cue.telegraph_kind else {
1954                    continue;
1955                };
1956                let (ox, oy) = match (cue.origin_x, cue.origin_y) {
1957                    (Some(x), Some(y)) => (x, y),
1958                    _ => continue,
1959                };
1960                match kind {
1961                    CombatFxKind::Sphere => {
1962                        let r = cue.radius_m.unwrap_or(1.0);
1963                        let dx = px - ox;
1964                        let dy = py - oy;
1965                        if dx * dx + dy * dy <= r * r {
1966                            return true;
1967                        }
1968                    }
1969                    CombatFxKind::Cone | CombatFxKind::MeleeArc => {
1970                        let reach = cue.reach_m.unwrap_or(2.0);
1971                        let yaw = cue.yaw.unwrap_or(0.0);
1972                        let arc = cue.arc_deg.unwrap_or(90.0).to_radians();
1973                        let dx = px - ox;
1974                        let dy = py - oy;
1975                        let dist = (dx * dx + dy * dy).sqrt();
1976                        if dist > reach || dist < 0.05 {
1977                            continue;
1978                        }
1979                        let ang = dx.atan2(dy);
1980                        let mut delta = ang - yaw;
1981                        while delta > std::f32::consts::PI {
1982                            delta -= std::f32::consts::TAU;
1983                        }
1984                        while delta < -std::f32::consts::PI {
1985                            delta += std::f32::consts::TAU;
1986                        }
1987                        if delta.abs() <= arc * 0.5 {
1988                            return true;
1989                        }
1990                    }
1991                    _ => {}
1992                }
1993            }
1994        }
1995        false
1996    }
1997
1998    fn quest_audio_signature(&self) -> u64 {
1999        use std::collections::hash_map::DefaultHasher;
2000        use std::hash::{Hash, Hasher};
2001        let mut h = DefaultHasher::new();
2002        for q in &self.quest_log {
2003            q.quest_id.hash(&mut h);
2004            format!("{:?}", q.status).hash(&mut h);
2005            q.current_step_id.hash(&mut h);
2006            for o in &q.objectives {
2007                o.done.hash(&mut h);
2008                o.current.hash(&mut h);
2009            }
2010        }
2011        h.finish()
2012    }
2013
2014    /// Verb menu entries for the current `npc_verb_target`.
2015    pub fn npc_verb_options(&self) -> Vec<NpcVerbChoice> {
2016        let Some(ref id) = self.npc_verb_target else {
2017            return vec![];
2018        };
2019        let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
2020            return self.with_quest_verbs(id, vec![Self::talk_choice()]);
2021        };
2022        let role = npc.role.as_str();
2023        let rest = if Self::npc_role_is_bank(role) {
2024            vec![
2025                NpcVerbChoice {
2026                    label: "Bank".into(),
2027                    action: NpcVerbAction::Bank,
2028                },
2029                Self::talk_choice(),
2030            ]
2031        } else if Self::npc_role_is_storage(role) {
2032            vec![
2033                NpcVerbChoice {
2034                    label: "Storage".into(),
2035                    action: NpcVerbAction::Storage,
2036                },
2037                Self::talk_choice(),
2038            ]
2039        } else if Self::npc_role_is_market(role) {
2040            vec![
2041                NpcVerbChoice {
2042                    label: "Market".into(),
2043                    action: NpcVerbAction::Market,
2044                },
2045                Self::talk_choice(),
2046            ]
2047        } else if npc.can_trade || Self::npc_role_can_trade(role) {
2048            vec![
2049                Self::talk_choice(),
2050                NpcVerbChoice {
2051                    label: "Trade".into(),
2052                    action: NpcVerbAction::Trade,
2053                },
2054            ]
2055        } else {
2056            vec![Self::talk_choice()]
2057        };
2058        self.with_quest_verbs(id, rest)
2059    }
2060
2061    fn talk_choice() -> NpcVerbChoice {
2062        NpcVerbChoice {
2063            label: "Talk".into(),
2064            action: NpcVerbAction::Talk,
2065        }
2066    }
2067
2068    fn with_quest_verbs(&self, npc_id: &str, rest: Vec<NpcVerbChoice>) -> Vec<NpcVerbChoice> {
2069        let mut opts = self.quest_verb_choices(npc_id);
2070        opts.extend(rest);
2071        opts
2072    }
2073
2074    fn quest_verb_choices(&self, npc_id: &str) -> Vec<NpcVerbChoice> {
2075        if let Some(npc) = self.npcs.iter().find(|n| n.id == npc_id) {
2076            if !npc.quest_verbs.is_empty() {
2077                return npc
2078                    .quest_verbs
2079                    .iter()
2080                    .map(|v| {
2081                        let action = if v.kind == flatland_protocol::NpcQuestVerb::KIND_GIVE {
2082                            NpcVerbAction::QuestGive {
2083                                quest_id: v.quest_id.clone(),
2084                            }
2085                        } else {
2086                            NpcVerbAction::QuestTalk {
2087                                quest_id: v.quest_id.clone(),
2088                            }
2089                        };
2090                        NpcVerbChoice {
2091                            label: v.label.clone(),
2092                            action,
2093                        }
2094                    })
2095                    .collect();
2096            }
2097        }
2098        let catalog_ref = self.npc_quest_catalog_ref(npc_id);
2099        let mut opts = Vec::new();
2100        for q in &self.quest_log {
2101            if q.status != flatland_protocol::QuestStatusView::Active {
2102                continue;
2103            }
2104            let title = if q.title.trim().is_empty() {
2105                "Quest".to_string()
2106            } else {
2107                q.title.clone()
2108            };
2109            for o in &q.objectives {
2110                if o.done || o.npc_ref.as_deref() != Some(catalog_ref.as_str()) {
2111                    continue;
2112                }
2113                if o.kind == "give_item" {
2114                    opts.push(NpcVerbChoice {
2115                        label: format!("Turn in: {title}"),
2116                        action: NpcVerbAction::QuestGive {
2117                            quest_id: q.quest_id.clone(),
2118                        },
2119                    });
2120                } else if o.kind == "talk_npc" {
2121                    opts.push(NpcVerbChoice {
2122                        label: title.clone(),
2123                        action: NpcVerbAction::QuestTalk {
2124                            quest_id: q.quest_id.clone(),
2125                        },
2126                    });
2127                }
2128            }
2129        }
2130        opts
2131    }
2132
2133    fn npc_quest_catalog_ref(&self, npc_id: &str) -> String {
2134        self.npcs
2135            .iter()
2136            .find(|n| n.id == npc_id)
2137            .and_then(|n| n.paperdoll_ref.clone())
2138            .unwrap_or_else(|| npc_id.to_string())
2139    }
2140
2141    fn count_inventory_template(&self, template: &str) -> u32 {
2142        self.inventory_stacks
2143            .iter()
2144            .filter(|s| s.template_id == template)
2145            .map(|s| s.quantity)
2146            .sum()
2147    }
2148
2149    fn npc_role_can_trade(role: &str) -> bool {
2150        matches!(role, "broker" | "cook" | "farmer" | "merchant")
2151    }
2152
2153    fn npc_role_is_bank(role: &str) -> bool {
2154        role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
2155    }
2156
2157    fn npc_role_is_storage(role: &str) -> bool {
2158        role.eq_ignore_ascii_case("storage_manager")
2159    }
2160
2161    fn npc_role_is_market(role: &str) -> bool {
2162        role.eq_ignore_ascii_case("market_clerk")
2163    }
2164
2165    pub fn bank_menu_options(&self) -> Vec<&'static str> {
2166        vec![
2167            "Deposit…",
2168            "Withdraw…",
2169            "Deposit all",
2170            "Withdraw all",
2171            "Transfer…",
2172        ]
2173    }
2174
2175    pub fn storage_menu_options(&self) -> Vec<String> {
2176        let mut opts = vec!["Store…".into(), "Take…".into()];
2177        if let Some(panel) = &self.storage_panel {
2178            for dest in &panel.ship_destinations {
2179                opts.push(format!(
2180                    "Ship → {} ({} cp / {} ticks)",
2181                    dest.label, dest.fee_copper, dest.travel_ticks
2182                ));
2183            }
2184        }
2185        opts
2186    }
2187
2188    /// Loose on-person stacks eligible for town-storage store.
2189    /// Excludes hand-equipped weapons/tools (unequip first) — worn body gear is
2190    /// already outside root inventory.
2191    pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
2192        let equipped = self.hand_equipped_instance_ids();
2193        self.person_rows()
2194            .into_iter()
2195            .filter(|r| r.depth == 0)
2196            .filter_map(|r| {
2197                let id = r.stack.item_instance_id?;
2198                if equipped.contains(&id) {
2199                    return None;
2200                }
2201                Some(StoragePickOption {
2202                    item_instance_id: id,
2203                    template_id: r.stack.template_id.clone(),
2204                    label: storage_stack_label(&r.stack),
2205                    quantity: r.stack.quantity,
2206                    category: r.stack.category.clone().unwrap_or_default(),
2207                })
2208            })
2209            .collect()
2210    }
2211
2212    /// Instance ids currently occupying mainhand / offhand (for store / list filters).
2213    pub fn hand_equipped_instance_ids(&self) -> std::collections::HashSet<uuid::Uuid> {
2214        let mut ids = std::collections::HashSet::new();
2215        if let Some(id) = self.mainhand_instance_id {
2216            ids.insert(id);
2217        } else if let Some(tid) = &self.mainhand_template_id {
2218            if let Some(id) = self
2219                .inventory_stacks
2220                .iter()
2221                .find(|s| &s.template_id == tid)
2222                .and_then(|s| s.item_instance_id)
2223            {
2224                ids.insert(id);
2225            }
2226        }
2227        if let Some(id) = self.offhand_instance_id {
2228            ids.insert(id);
2229        } else if let Some(tid) = &self.offhand_template_id {
2230            if let Some(id) = self
2231                .inventory_stacks
2232                .iter()
2233                .find(|s| {
2234                    &s.template_id == tid
2235                        && s.item_instance_id.is_some_and(|iid| !ids.contains(&iid))
2236                })
2237                .and_then(|s| s.item_instance_id)
2238            {
2239                ids.insert(id);
2240            }
2241        }
2242        ids
2243    }
2244
2245    /// Vault stacks eligible for take / ship.
2246    pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
2247        let Some(panel) = &self.storage_panel else {
2248            return Vec::new();
2249        };
2250        panel
2251            .contents
2252            .iter()
2253            .filter_map(|s| {
2254                let id = s.item_instance_id?;
2255                Some(StoragePickOption {
2256                    item_instance_id: id,
2257                    template_id: s.template_id.clone(),
2258                    label: storage_stack_label(s),
2259                    quantity: s.quantity,
2260                    category: s.category.clone().unwrap_or_default(),
2261                })
2262            })
2263            .collect()
2264    }
2265
2266    /// Source rows for market list: on-person, then each eligible vault (only if they hold stacks).
2267    pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
2268        let mut opts = Vec::new();
2269        if !self
2270            .market_list_item_options(&MarketListSourceKind::Person)
2271            .is_empty()
2272        {
2273            opts.push((MarketListSourceKind::Person, "On person".into()));
2274        }
2275        if let Some(panel) = &self.market_panel {
2276            for vault in &panel.list_vaults {
2277                let source = MarketListSourceKind::TownStorage {
2278                    building_id: vault.building_id.clone(),
2279                };
2280                if self.market_list_item_options(&source).is_empty() {
2281                    continue;
2282                }
2283                let label = if vault.building_label.is_empty() {
2284                    format!("Town storage ({})", vault.building_id)
2285                } else {
2286                    format!("Town storage — {}", vault.building_label)
2287                };
2288                opts.push((source, label));
2289            }
2290        }
2291        opts
2292    }
2293
2294    /// Stacks eligible to list from the chosen market source (excludes non-listable templates).
2295    pub fn market_list_item_options(
2296        &self,
2297        source: &MarketListSourceKind,
2298    ) -> Vec<StoragePickOption> {
2299        let filter = self.market_filter.as_str();
2300        let cat_filter = self.market_category_filter;
2301        let mut opts: Vec<StoragePickOption> = match source {
2302            MarketListSourceKind::Person => {
2303                let equipped = self.hand_equipped_instance_ids();
2304                self.person_rows()
2305                    .into_iter()
2306                    .filter(|r| r.depth == 0)
2307                    .filter(|r| self.stack_is_market_listable(&r.stack))
2308                    .filter_map(|r| {
2309                        let id = r.stack.item_instance_id?;
2310                        if equipped.contains(&id) {
2311                            return None;
2312                        }
2313                        Some(StoragePickOption {
2314                            item_instance_id: id,
2315                            template_id: r.stack.template_id.clone(),
2316                            label: storage_stack_label(&r.stack),
2317                            quantity: r.stack.quantity,
2318                            category: r
2319                                .stack
2320                                .category
2321                                .clone()
2322                                .or_else(|| {
2323                                    self.inventory_item_category(&r.stack.template_id)
2324                                        .map(str::to_string)
2325                                })
2326                                .unwrap_or_default(),
2327                        })
2328                    })
2329                    .collect()
2330            }
2331            MarketListSourceKind::TownStorage { building_id } => {
2332                let Some(panel) = &self.market_panel else {
2333                    return Vec::new();
2334                };
2335                let Some(vault) = panel
2336                    .list_vaults
2337                    .iter()
2338                    .find(|v| &v.building_id == building_id)
2339                else {
2340                    return Vec::new();
2341                };
2342                vault
2343                    .contents
2344                    .iter()
2345                    .filter(|s| self.stack_is_market_listable(s))
2346                    .filter_map(|s| {
2347                        let id = s.item_instance_id?;
2348                        Some(StoragePickOption {
2349                            item_instance_id: id,
2350                            template_id: s.template_id.clone(),
2351                            label: storage_stack_label(s),
2352                            quantity: s.quantity,
2353                            category: s
2354                                .category
2355                                .clone()
2356                                .or_else(|| {
2357                                    self.inventory_item_category(&s.template_id)
2358                                        .map(str::to_string)
2359                                })
2360                                .unwrap_or_default(),
2361                        })
2362                    })
2363                    .collect()
2364            }
2365        };
2366        opts.retain(|o| {
2367            if !list_label_matches(&o.label, filter) {
2368                return false;
2369            }
2370            if let Some(group) = cat_filter {
2371                inventory_category_group(&o.category).0 == group
2372            } else {
2373                true
2374            }
2375        });
2376        opts
2377    }
2378
2379    /// Catalog `base_value_copper` when synced on stacks or inventory hints.
2380    pub fn item_base_value_copper_hint(&self, template_id: &str) -> Option<u32> {
2381        if let Some(hint) = self.inventory_hints.get(template_id) {
2382            if let Some(v) = hint.base_value_copper.filter(|v| *v > 0) {
2383                return Some(v);
2384            }
2385        }
2386        if let Some(v) = self
2387            .inventory_stacks
2388            .iter()
2389            .find(|s| s.template_id == template_id)
2390            .and_then(|s| s.base_value_copper.filter(|v| *v > 0))
2391        {
2392            return Some(v);
2393        }
2394        self.market_panel.as_ref().and_then(|panel| {
2395            panel.list_vaults.iter().find_map(|vault| {
2396                vault.contents.iter().find_map(|stack| {
2397                    (stack.template_id == template_id)
2398                        .then(|| stack.base_value_copper.filter(|v| *v > 0))
2399                        .flatten()
2400                })
2401            })
2402        })
2403    }
2404
2405    /// Estimated net copper per unit for hall NPC-price dump queue (default buyer rates).
2406    pub fn npc_market_dump_unit_estimate(&self, template_id: &str) -> Option<u32> {
2407        let base = self.item_base_value_copper_hint(template_id)?;
2408        npc_market_dump_unit_estimate_copper(base)
2409    }
2410
2411    fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
2412        if crate::currency::is_currency(&stack.template_id) {
2413            return false;
2414        }
2415        if let Some(flag) = stack.listable {
2416            return flag;
2417        }
2418        if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
2419            return hint.listable;
2420        }
2421        let cat = stack
2422            .category
2423            .as_deref()
2424            .or_else(|| self.inventory_item_category(&stack.template_id))
2425            .unwrap_or("");
2426        category_default_listable(cat)
2427    }
2428
2429    /// Category group labels present in the current browse book or list-pick source.
2430    pub fn market_available_category_groups(&self) -> Vec<&'static str> {
2431        let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
2432        match &self.market_ui_mode {
2433            MarketUiMode::ListPick { source, .. } => {
2434                let raw: Vec<_> = match source {
2435                    MarketListSourceKind::Person => self
2436                        .person_rows()
2437                        .into_iter()
2438                        .filter(|r| r.depth == 0)
2439                        .filter(|r| self.stack_is_market_listable(&r.stack))
2440                        .filter(|r| {
2441                            list_label_matches(&storage_stack_label(&r.stack), &self.market_filter)
2442                        })
2443                        .map(|r| {
2444                            r.stack
2445                                .category
2446                                .clone()
2447                                .or_else(|| {
2448                                    self.inventory_item_category(&r.stack.template_id)
2449                                        .map(str::to_string)
2450                                })
2451                                .unwrap_or_default()
2452                        })
2453                        .collect(),
2454                    MarketListSourceKind::TownStorage { building_id } => self
2455                        .market_panel
2456                        .as_ref()
2457                        .and_then(|p| p.list_vaults.iter().find(|v| &v.building_id == building_id))
2458                        .map(|vault| {
2459                            vault
2460                                .contents
2461                                .iter()
2462                                .filter(|s| self.stack_is_market_listable(s))
2463                                .filter(|s| {
2464                                    list_label_matches(&storage_stack_label(s), &self.market_filter)
2465                                })
2466                                .map(|s| {
2467                                    s.category
2468                                        .clone()
2469                                        .or_else(|| {
2470                                            self.inventory_item_category(&s.template_id)
2471                                                .map(str::to_string)
2472                                        })
2473                                        .unwrap_or_default()
2474                                })
2475                                .collect::<Vec<_>>()
2476                        })
2477                        .unwrap_or_default(),
2478                };
2479                for category in raw {
2480                    let (label, ord) = inventory_category_group(&category);
2481                    seen.insert(ord, label);
2482                }
2483            }
2484            _ => {
2485                if let Some(panel) = &self.market_panel {
2486                    for listing in &panel.listings {
2487                        if !list_label_matches(&listing.display_name, &self.market_filter)
2488                            && !list_label_matches(&listing.seller_label, &self.market_filter)
2489                        {
2490                            continue;
2491                        }
2492                        let (label, ord) = inventory_category_group(&listing.category);
2493                        seen.insert(ord, label);
2494                    }
2495                }
2496            }
2497        }
2498        seen.into_values().collect()
2499    }
2500
2501    /// Indices into `market_panel.listings` after category + text filter.
2502    pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
2503        let Some(panel) = &self.market_panel else {
2504            return Vec::new();
2505        };
2506        let filter = self.market_filter.as_str();
2507        let cat_filter = self.market_category_filter;
2508        panel
2509            .listings
2510            .iter()
2511            .enumerate()
2512            .filter(|(_, listing)| {
2513                if !list_label_matches(&listing.display_name, filter)
2514                    && !list_label_matches(&listing.seller_label, filter)
2515                    && !list_label_matches(&listing.template_id, filter)
2516                {
2517                    return false;
2518                }
2519                if let Some(group) = cat_filter {
2520                    inventory_category_group(&listing.category).0 == group
2521                } else {
2522                    true
2523                }
2524            })
2525            .map(|(i, _)| i)
2526            .collect()
2527    }
2528
2529    pub fn clear_harvest_state(&mut self) {
2530        self.harvest_in_progress = false;
2531        self.harvest_started_at = None;
2532    }
2533
2534    fn harvest_state_stale(&self) -> bool {
2535        match self.harvest_started_at {
2536            Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
2537            None => self.harvest_in_progress,
2538        }
2539    }
2540
2541    pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
2542        self.player.as_ref().and_then(|p| p.vitals)
2543    }
2544
2545    pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
2546        let materials_ok = blueprint.inputs.iter().all(|input| {
2547            self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
2548        });
2549        let tools_ok = blueprint
2550            .required_tools
2551            .iter()
2552            .all(|tool| self.player_has_craft_tool(&tool.item));
2553        let station_ok = match blueprint.station.as_deref() {
2554            None | Some("hand") => true,
2555            Some(tag) => self.player_at_station_tag(tag),
2556        };
2557        materials_ok
2558            && tools_ok
2559            && station_ok
2560            && self.craft_has_vessel_room_for_output(blueprint)
2561    }
2562
2563    /// Inventory/worn **or** a nearby placed furniture tool in the same space.
2564    pub fn player_has_craft_tool(&self, tool_template: &str) -> bool {
2565        if self.inventory.get(tool_template).copied().unwrap_or(0) >= 1 {
2566            return true;
2567        }
2568        let Some(player) = self.player.as_ref() else {
2569            return false;
2570        };
2571        let px = player.transform.position.x;
2572        let py = player.transform.position.y;
2573        // Match sim `CONTAINER_INTERACTION_RADIUS_M`.
2574        const RANGE: f32 = 3.0;
2575        self.placed_containers.iter().any(|c| {
2576            if c.template_id != tool_template {
2577                return false;
2578            }
2579            if !self.placed_container_in_current_space(c) {
2580                return false;
2581            }
2582            let dx = c.x - px;
2583            let dy = c.y - py;
2584            dx * dx + dy * dy <= RANGE * RANGE
2585        })
2586    }
2587
2588    /// Bulk/liquid craft outputs need an empty (or matching) vessel after inputs drain.
2589    fn craft_output_needs_vessel(&self, blueprint: &BlueprintView) -> bool {
2590        matches!(
2591            self.inventory_item_category(&blueprint.output),
2592            Some("bulk") | Some("liquid")
2593        ) || matches!(
2594            blueprint.output.as_str(),
2595            "dirt" | "mud" | "sand" | "water" | "milk"
2596        )
2597    }
2598
2599    fn craft_output_category(&self, blueprint: &BlueprintView) -> Option<&str> {
2600        self.inventory_item_category(&blueprint.output).or_else(|| {
2601            match blueprint.output.as_str() {
2602                "dirt" | "mud" | "sand" => Some("bulk"),
2603                "water" | "milk" => Some("liquid"),
2604                _ => None,
2605            }
2606        })
2607    }
2608
2609    fn craft_has_vessel_room_for_output(&self, blueprint: &BlueprintView) -> bool {
2610        if !self.craft_output_needs_vessel(blueprint) {
2611            return true;
2612        }
2613        let need = blueprint.output_qty.max(1);
2614        self.vessel_room_after_craft_inputs(blueprint) >= need
2615    }
2616
2617    /// Free capacity in vessels that can hold `blueprint.output` after consuming inputs.
2618    fn vessel_room_after_craft_inputs(&self, blueprint: &BlueprintView) -> u32 {
2619        let mut stacks = self.inventory_stacks.clone();
2620        for worn in self.worn.values() {
2621            stacks.push(worn.clone());
2622        }
2623        for input in &blueprint.inputs {
2624            let mut left = input.quantity;
2625            drain_payload_from_stacks(&mut stacks, &input.template_id, &mut left);
2626            if left > 0 {
2627                return 0;
2628            }
2629        }
2630        vessel_room_for_payload_in_stacks(
2631            &stacks,
2632            &blueprint.output,
2633            self.craft_output_category(blueprint),
2634        )
2635    }
2636
2637    /// Vessel inventory transparency for the craft detail panel.
2638    pub fn craft_vessel_status(&self, blueprint: &BlueprintView) -> CraftVesselStatus {
2639        let output_label = self.blueprint_output_label(blueprint);
2640        let needs_vessel = self.craft_output_needs_vessel(blueprint);
2641        let need_units = if needs_vessel {
2642            blueprint.output_qty.max(1)
2643        } else {
2644            0
2645        };
2646        let free_after_inputs = if needs_vessel {
2647            self.vessel_room_after_craft_inputs(blueprint)
2648        } else {
2649            0
2650        };
2651        let payload_cat = self.craft_output_category(blueprint);
2652        let mut vessels = Vec::new();
2653        Self::collect_craft_vessel_lines(
2654            &self.inventory_stacks,
2655            "pack",
2656            &blueprint.output,
2657            payload_cat,
2658            &mut vessels,
2659        );
2660        for worn in self.worn.values() {
2661            Self::collect_craft_vessel_lines(
2662                std::slice::from_ref(worn),
2663                "worn",
2664                &blueprint.output,
2665                payload_cat,
2666                &mut vessels,
2667            );
2668        }
2669        CraftVesselStatus {
2670            needs_vessel,
2671            output_label,
2672            need_units,
2673            free_after_inputs,
2674            ok: !needs_vessel || free_after_inputs >= need_units,
2675            vessels,
2676        }
2677    }
2678
2679    fn collect_craft_vessel_lines(
2680        stacks: &[flatland_protocol::ItemStack],
2681        location: &'static str,
2682        payload_id: &str,
2683        payload_category: Option<&str>,
2684        out: &mut Vec<CraftVesselLine>,
2685    ) {
2686        for stack in stacks {
2687            if is_serving_vessel_stack(stack) {
2688                let cap = serving_capacity_of(stack);
2689                let used = payload_units_in_vessel(stack);
2690                let free = vessel_free_room_for_payload(stack, payload_id, payload_category);
2691                let holds = stack
2692                    .props
2693                    .get("serving_holds")
2694                    .cloned()
2695                    .unwrap_or_else(|| {
2696                        if stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
2697                            && stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
2698                        {
2699                            "liquid,bulk".into()
2700                        } else if stack.props.get("bulk_vessel").is_some_and(|v| v == "1") {
2701                            "bulk".into()
2702                        } else if stack.props.get("liquid_vessel").is_some_and(|v| v == "1") {
2703                            "liquid".into()
2704                        } else {
2705                            "?".into()
2706                        }
2707                    });
2708                let label = stack
2709                    .display_name
2710                    .clone()
2711                    .unwrap_or_else(|| stack.template_id.clone());
2712                out.push(CraftVesselLine {
2713                    label,
2714                    holds,
2715                    capacity: cap,
2716                    used,
2717                    free,
2718                    quantity: stack.quantity.max(1),
2719                    accepts_output: free > 0,
2720                    location,
2721                });
2722            }
2723            Self::collect_craft_vessel_lines(
2724                &stack.contents,
2725                location,
2726                payload_id,
2727                payload_category,
2728                out,
2729            );
2730        }
2731    }
2732
2733    fn craft_prefs_key(&self) -> String {
2734        if let Some(cid) = self.character_id {
2735            cid.to_string()
2736        } else if self.entity_id != 0 {
2737            format!("entity:{}", self.entity_id)
2738        } else {
2739            String::new()
2740        }
2741    }
2742
2743    pub fn reload_craft_prefs(&mut self) {
2744        let key = self.craft_prefs_key();
2745        self.craft_prefs = crate::craft_prefs::load_for_character(&key);
2746    }
2747
2748    fn persist_craft_prefs(&self) {
2749        crate::craft_prefs::save_for_character(&self.craft_prefs_key(), &self.craft_prefs);
2750    }
2751
2752    /// Distinct craft tiers present in known blueprints (sorted ascending).
2753    pub fn craft_known_tiers(&self) -> Vec<u32> {
2754        let mut tiers: Vec<u32> = self
2755            .blueprints
2756            .iter()
2757            .map(|bp| bp.craft_tier.max(1))
2758            .collect::<std::collections::BTreeSet<_>>()
2759            .into_iter()
2760            .collect();
2761        tiers.sort_unstable();
2762        tiers
2763    }
2764
2765    /// Tab strip order: Ready, ★, Recent, then T1..Tn that have known recipes.
2766    pub fn craft_tab_strip(&self) -> Vec<CraftTab> {
2767        let mut tabs = vec![CraftTab::Ready, CraftTab::Favorites, CraftTab::Recent];
2768        for t in self.craft_known_tiers() {
2769            tabs.push(CraftTab::Tier(t));
2770        }
2771        tabs
2772    }
2773
2774    pub fn craft_set_tab(&mut self, tab: CraftTab) {
2775        self.craft_tab = tab;
2776        self.craft_menu_index = 0;
2777        self.clamp_craft_menu_index();
2778        self.clamp_craft_batch_quantity();
2779    }
2780
2781    pub fn craft_cycle_tab(&mut self, delta: i32) {
2782        let tabs = self.craft_tab_strip();
2783        if tabs.is_empty() {
2784            return;
2785        }
2786        let cur = tabs
2787            .iter()
2788            .position(|t| *t == self.craft_tab)
2789            .unwrap_or(0) as i32;
2790        let next = (cur + delta).rem_euclid(tabs.len() as i32) as usize;
2791        self.craft_set_tab(tabs[next]);
2792    }
2793
2794    pub fn craft_matches_search(&self, bp: &BlueprintView) -> bool {
2795        let f = self.craft_filter.trim();
2796        if f.is_empty() {
2797            return true;
2798        }
2799        if list_label_matches(&bp.label, f)
2800            || list_label_matches(&bp.output, f)
2801            || list_label_matches(&bp.output_display_name, f)
2802            || bp
2803                .category
2804                .as_deref()
2805                .is_some_and(|c| list_label_matches(c, f))
2806            || bp
2807                .station
2808                .as_deref()
2809                .is_some_and(|s| list_label_matches(s, f))
2810        {
2811            return true;
2812        }
2813        bp.inputs.iter().any(|i| {
2814            list_label_matches(&i.template_id, f) || list_label_matches(&i.display_name, f)
2815        }) || bp.required_tools.iter().any(|t| {
2816            list_label_matches(&t.item, f) || list_label_matches(&t.display_name, f)
2817        })
2818    }
2819
2820    /// True while this blueprint's craft channel is running (Ready-tab pin).
2821    pub fn craft_blueprint_in_channel(&self, blueprint_id: &str) -> bool {
2822        self.craft_channel_blueprint_id.as_deref() == Some(blueprint_id)
2823            && self.active_craft_channel().is_some()
2824    }
2825
2826    /// Blueprint indices for the active tab + search, ready-first then A–Z.
2827    pub fn craft_filtered_indices(&self) -> Vec<usize> {
2828        let mut idxs: Vec<usize> = (0..self.blueprints.len())
2829            .filter(|&i| {
2830                let bp = &self.blueprints[i];
2831                if !self.craft_matches_search(bp) {
2832                    return false;
2833                }
2834                match self.craft_tab {
2835                    CraftTab::Ready => {
2836                        self.can_craft_blueprint(bp) || self.craft_blueprint_in_channel(&bp.id)
2837                    }
2838                    CraftTab::Favorites => self.craft_prefs.is_favorite(&bp.id),
2839                    CraftTab::Recent => self.craft_prefs.recent.iter().any(|id| id == &bp.id),
2840                    CraftTab::Tier(t) => bp.craft_tier.max(1) == t,
2841                }
2842            })
2843            .collect();
2844        match self.craft_tab {
2845            CraftTab::Recent => {
2846                idxs.sort_by_key(|&i| {
2847                    self.craft_prefs
2848                        .recent
2849                        .iter()
2850                        .position(|id| id == &self.blueprints[i].id)
2851                        .unwrap_or(usize::MAX)
2852                });
2853            }
2854            _ => {
2855                idxs.sort_by(|&a, &b| {
2856                    let ba = &self.blueprints[a];
2857                    let bb = &self.blueprints[b];
2858                    let ia = self.craft_blueprint_in_channel(&ba.id);
2859                    let ib = self.craft_blueprint_in_channel(&bb.id);
2860                    // In-progress first, then craftable, then A–Z.
2861                    ib.cmp(&ia)
2862                        .then_with(|| {
2863                            let ra = self.can_craft_blueprint(ba);
2864                            let rb = self.can_craft_blueprint(bb);
2865                            rb.cmp(&ra)
2866                        })
2867                        .then_with(|| ba.label.to_ascii_lowercase().cmp(&bb.label.to_ascii_lowercase()))
2868                });
2869            }
2870        }
2871        idxs
2872    }
2873
2874    pub fn craft_selected_blueprint(&self) -> Option<&BlueprintView> {
2875        let idxs = self.craft_filtered_indices();
2876        idxs.get(self.craft_menu_index)
2877            .and_then(|&i| self.blueprints.get(i))
2878    }
2879
2880    pub fn clamp_craft_menu_index(&mut self) {
2881        let n = self.craft_filtered_indices().len();
2882        if n == 0 {
2883            self.craft_menu_index = 0;
2884        } else {
2885            self.craft_menu_index = self.craft_menu_index.min(n - 1);
2886        }
2887    }
2888
2889    pub fn craft_is_favorite(&self, blueprint_id: &str) -> bool {
2890        self.craft_prefs.is_favorite(blueprint_id)
2891    }
2892
2893    pub fn craft_toggle_favorite_selected(&mut self) {
2894        let Some(id) = self.craft_selected_blueprint().map(|bp| bp.id.clone()) else {
2895            return;
2896        };
2897        self.craft_prefs.toggle_favorite(&id);
2898        self.persist_craft_prefs();
2899        if matches!(self.craft_tab, CraftTab::Favorites) {
2900            self.clamp_craft_menu_index();
2901        }
2902    }
2903
2904    pub fn craft_record_completed(&mut self, blueprint_id: &str) {
2905        self.craft_prefs.record_crafted(blueprint_id);
2906        self.persist_craft_prefs();
2907    }
2908
2909    pub fn focus_craft_filter(&mut self) {
2910        self.craft_filter_focused = true;
2911    }
2912
2913    pub fn append_craft_filter_char(&mut self, ch: char) {
2914        if !self.craft_filter_focused {
2915            return;
2916        }
2917        if is_list_filter_char(ch) {
2918            self.craft_filter.push(ch);
2919            self.craft_menu_index = 0;
2920            self.clamp_craft_menu_index();
2921        }
2922    }
2923
2924    pub fn craft_filter_backspace(&mut self) {
2925        if !self.craft_filter_focused {
2926            return;
2927        }
2928        self.craft_filter.pop();
2929        self.craft_menu_index = 0;
2930        self.clamp_craft_menu_index();
2931    }
2932
2933    /// Esc while filter focused: clear then blur. Returns true if handled.
2934    pub fn clear_or_blur_craft_filter(&mut self) -> bool {
2935        if self.craft_filter_focused {
2936            if !self.craft_filter.is_empty() {
2937                self.craft_filter.clear();
2938                self.craft_menu_index = 0;
2939                self.clamp_craft_menu_index();
2940            } else {
2941                self.craft_filter_focused = false;
2942            }
2943            return true;
2944        }
2945        if !self.craft_filter.is_empty() {
2946            self.craft_filter.clear();
2947            self.craft_menu_index = 0;
2948            self.clamp_craft_menu_index();
2949            return true;
2950        }
2951        false
2952    }
2953
2954    pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
2955        if !self.can_craft_blueprint(blueprint) {
2956            return 0;
2957        }
2958        let mut limit = u32::MAX;
2959        for input in &blueprint.inputs {
2960            if input.quantity == 0 {
2961                continue;
2962            }
2963            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2964            limit = limit.min(have / input.quantity);
2965        }
2966        for tool in &blueprint.required_tools {
2967            if tool.consumed {
2968                let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2969                limit = limit.min(have);
2970            }
2971        }
2972        let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
2973        if CRAFT_STAMINA_COST > 0.0 {
2974            limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
2975        }
2976        if self.craft_output_needs_vessel(blueprint) {
2977            let need = blueprint.output_qty.max(1);
2978            let room = self.vessel_room_after_craft_inputs(blueprint);
2979            if need > 0 {
2980                limit = limit.min(room / need);
2981            }
2982        }
2983        limit
2984    }
2985
2986    pub fn clamp_craft_batch_quantity(&mut self) {
2987        let Some(bp) = self.craft_selected_blueprint().cloned() else {
2988            self.craft_batch_quantity = 1;
2989            return;
2990        };
2991        let max = self.max_craft_batches(&bp).max(1);
2992        self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
2993    }
2994
2995    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
2996        let Some(bp) = self.craft_selected_blueprint().cloned() else {
2997            return;
2998        };
2999        let max = self.max_craft_batches(&bp).max(1);
3000        let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
3001        self.craft_batch_quantity = next as u32;
3002    }
3003
3004    pub fn craft_batch_set_max(&mut self) {
3005        let Some(bp) = self.craft_selected_blueprint().cloned() else {
3006            return;
3007        };
3008        let max = self.max_craft_batches(&bp);
3009        self.craft_batch_quantity = if max == 0 { 1 } else { max };
3010    }
3011
3012    pub fn craft_batch_set_min(&mut self) {
3013        self.craft_batch_quantity = 1;
3014    }
3015
3016    pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
3017        let preserve_ui = self.show_shop_menu;
3018        let tab = self.shop_tab;
3019        let index = self.shop_menu_index;
3020        let qty = self.shop_quantity;
3021
3022        self.show_shop_menu = true;
3023        self.bank_panel = None;
3024        self.show_craft_menu = false;
3025        self.show_inventory_menu = false;
3026        self.show_stats = false;
3027        if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
3028            self.npc_verb_target = Some(catalog.npc_id.clone());
3029        }
3030        self.shop_catalog = Some(catalog);
3031
3032        if preserve_ui {
3033            self.shop_tab = tab;
3034            self.shop_menu_index = index;
3035            self.shop_quantity = qty;
3036        } else {
3037            self.shop_tab = ShopTab::Buy;
3038            self.shop_menu_index = 0;
3039            self.shop_quantity = 1;
3040            self.clear_shop_trade_log();
3041        }
3042        self.show_npc_verb_menu = false;
3043        self.clamp_shop_selection();
3044    }
3045
3046    pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
3047        let same_teller = self
3048            .bank_panel
3049            .as_ref()
3050            .is_some_and(|p| p.npc_id == panel.npc_id);
3051        self.bank_panel = Some(panel);
3052        self.storage_panel = None;
3053        self.market_panel = None;
3054        self.shop_catalog = None;
3055        self.show_shop_menu = false;
3056        self.show_craft_menu = false;
3057        self.show_inventory_menu = false;
3058        self.show_stats = false;
3059        self.show_npc_verb_menu = false;
3060        self.show_npc_chat = false;
3061        self.npc_chat = None;
3062        if !same_teller {
3063            self.bank_menu_index = 0;
3064            self.bank_ui_mode = BankUiMode::Menu;
3065        }
3066        if let Some(panel) = &self.bank_panel {
3067            if self.npc_verb_target.is_none() {
3068                self.npc_verb_target = Some(panel.npc_id.clone());
3069            }
3070        }
3071    }
3072
3073    pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
3074        let same_manager = self
3075            .storage_panel
3076            .as_ref()
3077            .is_some_and(|p| p.npc_id == panel.npc_id);
3078        self.storage_panel = Some(panel);
3079        self.bank_panel = None;
3080        self.market_panel = None;
3081        self.bank_ui_mode = BankUiMode::Menu;
3082        self.shop_catalog = None;
3083        self.show_shop_menu = false;
3084        self.show_craft_menu = false;
3085        self.show_inventory_menu = false;
3086        self.show_stats = false;
3087        self.show_npc_verb_menu = false;
3088        self.show_npc_chat = false;
3089        self.npc_chat = None;
3090        if !same_manager {
3091            self.storage_menu_index = 0;
3092            self.storage_ui_mode = StorageUiMode::Menu;
3093        } else {
3094            self.clamp_storage_pick_index();
3095        }
3096        if let Some(panel) = &self.storage_panel {
3097            if self.npc_verb_target.is_none() {
3098                self.npc_verb_target = Some(panel.npc_id.clone());
3099            }
3100        }
3101    }
3102
3103    pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
3104        for vault in &panel.list_vaults {
3105            self.merge_stack_catalog_hints(&vault.contents);
3106        }
3107        self.market_panel = Some(panel);
3108        self.bank_panel = None;
3109        self.storage_panel = None;
3110        self.shop_catalog = None;
3111        self.show_shop_menu = false;
3112        self.show_craft_menu = false;
3113        self.show_inventory_menu = false;
3114        self.show_stats = false;
3115        self.show_npc_verb_menu = false;
3116        self.show_npc_chat = false;
3117        self.npc_chat = None;
3118        self.market_menu_index = 0;
3119        self.market_buy_confirm = None;
3120        self.market_ui_mode = MarketUiMode::Browse;
3121        self.market_filter.clear();
3122        self.market_filter_focused = false;
3123        self.market_category_filter = None;
3124        if let Some(panel) = &self.market_panel {
3125            if self.npc_verb_target.is_none() {
3126                self.npc_verb_target = Some(panel.npc_id.clone());
3127            }
3128        }
3129    }
3130
3131    pub fn clear_market_panel(&mut self) {
3132        self.market_panel = None;
3133        self.market_menu_index = 0;
3134        self.market_buy_confirm = None;
3135        self.market_ui_mode = MarketUiMode::Browse;
3136        self.market_filter.clear();
3137        self.market_filter_focused = false;
3138        self.market_category_filter = None;
3139    }
3140
3141    pub fn clear_bank_panel(&mut self) {
3142        self.bank_panel = None;
3143        self.bank_menu_index = 0;
3144        self.bank_ui_mode = BankUiMode::Menu;
3145    }
3146
3147    pub fn clear_storage_panel(&mut self) {
3148        self.storage_panel = None;
3149        self.storage_menu_index = 0;
3150        self.storage_ui_mode = StorageUiMode::Menu;
3151    }
3152
3153    fn clamp_storage_pick_index(&mut self) {
3154        match &self.storage_ui_mode {
3155            StorageUiMode::StorePick { index } => {
3156                let n = self.storage_store_options().len();
3157                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3158                self.storage_ui_mode = StorageUiMode::StorePick { index: next };
3159            }
3160            StorageUiMode::TakePick { index } => {
3161                let n = self.storage_vault_options().len();
3162                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3163                self.storage_ui_mode = StorageUiMode::TakePick { index: next };
3164            }
3165            StorageUiMode::ShipPick {
3166                dest_building_id,
3167                dest_label,
3168                index,
3169            } => {
3170                let n = self.storage_vault_options().len();
3171                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3172                self.storage_ui_mode = StorageUiMode::ShipPick {
3173                    dest_building_id: dest_building_id.clone(),
3174                    dest_label: dest_label.clone(),
3175                    index: next,
3176                };
3177            }
3178            StorageUiMode::Menu
3179            | StorageUiMode::StoreAmount { .. }
3180            | StorageUiMode::TakeAmount { .. }
3181            | StorageUiMode::ShipAmount { .. } => {}
3182        }
3183    }
3184
3185    pub fn shop_list_len(&self) -> usize {
3186        let Some(catalog) = &self.shop_catalog else {
3187            return 0;
3188        };
3189        match self.shop_tab {
3190            ShopTab::Buy => catalog.sells.len(),
3191            ShopTab::Sell => catalog.buys.len(),
3192        }
3193    }
3194
3195    pub fn shop_menu_move(&mut self, delta: i32) {
3196        let n = self.shop_list_len();
3197        if n == 0 {
3198            return;
3199        }
3200        let idx = self.shop_menu_index as i32;
3201        let next = (idx + delta).rem_euclid(n as i32);
3202        self.shop_menu_index = next as usize;
3203        self.clamp_shop_quantity();
3204    }
3205
3206    pub fn shop_quantity_adjust(&mut self, delta: i32) {
3207        let max = self.shop_quantity_max();
3208        if max == 0 {
3209            self.shop_quantity = 0;
3210            return;
3211        }
3212        let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
3213        self.shop_quantity = next as u32;
3214    }
3215
3216    pub(crate) fn clamp_shop_selection(&mut self) {
3217        let n = self.shop_list_len();
3218        if n == 0 {
3219            self.shop_menu_index = 0;
3220        } else {
3221            self.shop_menu_index = self.shop_menu_index.min(n - 1);
3222        }
3223        self.clamp_shop_quantity();
3224    }
3225
3226    fn shop_quantity_max(&self) -> u32 {
3227        let Some(catalog) = &self.shop_catalog else {
3228            return 1;
3229        };
3230        match self.shop_tab {
3231            ShopTab::Buy => {
3232                if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
3233                    if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
3234                        return 1;
3235                    }
3236                }
3237                99
3238            }
3239            ShopTab::Sell => catalog
3240                .buys
3241                .get(self.shop_menu_index)
3242                .map(|l| l.quantity)
3243                .unwrap_or(0),
3244        }
3245    }
3246
3247    pub fn shop_quantity_set_max(&mut self) {
3248        self.shop_quantity = self.shop_quantity_max();
3249    }
3250
3251    pub fn shop_quantity_set_min(&mut self) {
3252        let max = self.shop_quantity_max();
3253        self.shop_quantity = if max == 0 { 0 } else { 1 };
3254    }
3255
3256    fn clamp_shop_quantity(&mut self) {
3257        let max = self.shop_quantity_max();
3258        if max == 0 {
3259            self.shop_quantity = 0;
3260        } else {
3261            self.shop_quantity = self.shop_quantity.max(1).min(max);
3262        }
3263    }
3264
3265    pub fn player_at_station_tag(&self, tag: &str) -> bool {
3266        let Some(id) = self.effective_inside_building() else {
3267            return false;
3268        };
3269        self.buildings
3270            .iter()
3271            .find(|b| b.id == id)
3272            .is_some_and(|b| b.tags.iter().any(|t| t == tag))
3273    }
3274
3275    /// Short hint for UI when a recipe cannot be started.
3276    pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
3277        if self.can_craft_blueprint(blueprint) {
3278            return None;
3279        }
3280        let mut missing = Vec::new();
3281        for input in &blueprint.inputs {
3282            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
3283            if have < input.quantity {
3284                let name = self.blueprint_ingredient_label(input);
3285                let vessel_note = if self.inventory_item_category(&input.template_id)
3286                    == Some("liquid")
3287                    || matches!(input.template_id.as_str(), "water" | "milk")
3288                {
3289                    "; fill a bottle/waterskin"
3290                } else if self.inventory_item_category(&input.template_id) == Some("bulk")
3291                    || matches!(input.template_id.as_str(), "dirt" | "mud" | "sand")
3292                {
3293                    "; scoop into a sack/bucket"
3294                } else {
3295                    ""
3296                };
3297                missing.push(format!(
3298                    "{}×{} (have {have}{vessel_note})",
3299                    input.quantity, name
3300                ));
3301            }
3302        }
3303        for tool in &blueprint.required_tools {
3304            if !self.player_has_craft_tool(&tool.item) {
3305                missing.push(format!("tool: {}", self.blueprint_tool_label(tool)));
3306            }
3307        }
3308        if let Some(station) = blueprint.station.as_deref() {
3309            if station != "hand" && !self.player_at_station_tag(station) {
3310                missing.push(format!("station: {station} (enter building)"));
3311            }
3312        }
3313        if self.craft_output_needs_vessel(blueprint) && !self.craft_has_vessel_room_for_output(blueprint)
3314        {
3315            let name = self
3316                .inventory_hints
3317                .get(&blueprint.output)
3318                .map(|h| h.display_name.as_str())
3319                .unwrap_or(blueprint.output.as_str());
3320            let need = blueprint.output_qty.max(1);
3321            let free = self.vessel_room_after_craft_inputs(blueprint);
3322            let accepting = self
3323                .craft_vessel_status(blueprint)
3324                .vessels
3325                .iter()
3326                .filter(|v| v.accepts_output)
3327                .count();
3328            missing.push(format!(
3329                "vessel room for {name}: need {need} free after inputs, have {free} ({accepting} accepting vessel(s))"
3330            ));
3331        }
3332        if missing.is_empty() {
3333            None
3334        } else {
3335            Some(missing.join(", "))
3336        }
3337    }
3338
3339    /// In-progress inventory craft (n-menu), if any.
3340    pub fn active_craft_channel(&self) -> Option<&flatland_protocol::TimedChannelHud> {
3341        self.timed_channel
3342            .as_ref()
3343            .filter(|c| c.channel == flatland_protocol::TimedChannelKind::Craft)
3344    }
3345
3346    pub fn player_entity(&self) -> Option<&EntityState> {
3347        self.player
3348            .as_ref()
3349            .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
3350    }
3351
3352    /// Apply persisted HUD / workers UI prefs from `client.json`.
3353    pub fn apply_client_ui_prefs(&mut self) {
3354        let cfg = crate::client_config::ClientConfig::load();
3355        if let Some(hidden) = cfg.hud_log_hidden {
3356            self.hud_log_hidden = hidden;
3357        }
3358        if let Some(compact) = cfg.workers_menu_compact {
3359            self.workers_menu_compact = compact;
3360        }
3361    }
3362
3363    pub fn player_position(&self) -> (f32, f32) {
3364        let (x, y, _) = self.player_position_with_z();
3365        (x, y)
3366    }
3367
3368    pub fn player_position_with_z(&self) -> (f32, f32, f32) {
3369        if let Some(p) = self.player_entity() {
3370            (
3371                p.transform.position.x,
3372                p.transform.position.y,
3373                p.transform.position.z,
3374            )
3375        } else {
3376            (0.0, 0.0, 0.0)
3377        }
3378    }
3379
3380    pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
3381        let mut rows: Vec<(String, u32, String)> = self
3382            .inventory
3383            .iter()
3384            .filter(|(_, q)| **q > 0)
3385            .map(|(id, qty)| {
3386                let label = self
3387                    .inventory_hints
3388                    .get(id)
3389                    .map(|h| h.display_name.clone())
3390                    .unwrap_or_else(|| id.clone());
3391                (id.clone(), *qty, label)
3392            })
3393            .collect();
3394        rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
3395        rows
3396    }
3397
3398    pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
3399        self.inventory_hints
3400            .get(template_id)
3401            .map(|h| h.category.as_str())
3402            .filter(|c| !c.is_empty())
3403    }
3404
3405    pub fn stack_is_serving(stack: &flatland_protocol::ItemStack) -> bool {
3406        stack.props.get("serving").is_some_and(|v| v == "1")
3407            || Self::stack_is_liquid_vessel(stack)
3408    }
3409
3410    pub fn stack_is_liquid_vessel(stack: &flatland_protocol::ItemStack) -> bool {
3411        stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
3412            || stack.props.get("serving_holds").is_some_and(|v| {
3413                v.split(',').any(|p| p.trim() == "liquid")
3414            })
3415    }
3416
3417    pub fn stack_is_food_serving(stack: &flatland_protocol::ItemStack) -> bool {
3418        stack.props.get("serving_holds").is_some_and(|v| {
3419            v.split(',').any(|p| p.trim() == "food")
3420        })
3421    }
3422
3423    pub fn stack_is_bulk_vessel(stack: &flatland_protocol::ItemStack) -> bool {
3424        stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
3425            || stack.props.get("serving_holds").is_some_and(|v| {
3426                v.split(',').any(|p| p.trim() == "bulk")
3427            })
3428    }
3429
3430    pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
3431        stack
3432            .props
3433            .get("grants_item_status_effect")
3434            .map(|s| !s.is_empty())
3435            .unwrap_or(false)
3436    }
3437
3438    pub fn stack_is_blueprint_scroll(stack: &flatland_protocol::ItemStack) -> bool {
3439        stack
3440            .props
3441            .get("teaches_blueprint")
3442            .map(|s| !s.trim().is_empty())
3443            .unwrap_or(false)
3444    }
3445
3446    pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
3447        stack
3448            .props
3449            .get("grants_item_status_effect")
3450            .map(String::as_str)
3451            .filter(|s| !s.is_empty())
3452    }
3453
3454    pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
3455        stack
3456            .props
3457            .get("grants_item_status_mode")
3458            .map(String::as_str)
3459            .unwrap_or("on_hit")
3460    }
3461
3462    /// Candidate gear for a grant consumable (inventory + worn).
3463    pub fn grant_target_options(
3464        &self,
3465        grant: &flatland_protocol::ItemStack,
3466    ) -> Vec<GrantTargetOption> {
3467        let mode = Self::grant_mode(grant);
3468        let grant_tags: Vec<&str> = grant
3469            .props
3470            .get("grants_item_status_tags")
3471            .map(|s| {
3472                s.split(',')
3473                    .map(str::trim)
3474                    .filter(|t| !t.is_empty())
3475                    .collect()
3476            })
3477            .unwrap_or_default();
3478        let grant_id = grant.item_instance_id;
3479        let mut out = Vec::new();
3480        let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
3481            let Some(iid) = stack.item_instance_id else {
3482                return;
3483            };
3484            if Some(iid) == grant_id {
3485                return;
3486            }
3487            if stack.props.get("enchantable").map(String::as_str) == Some("0") {
3488                return;
3489            }
3490            if !grant_target_matches_mode(stack, mode) {
3491                return;
3492            }
3493            if !grant_tags_match(stack, &grant_tags) {
3494                return;
3495            }
3496            let name = stack
3497                .display_name
3498                .clone()
3499                .unwrap_or_else(|| stack.template_id.clone());
3500            let bindings = if stack.status_bindings.is_empty() {
3501                String::new()
3502            } else {
3503                format!(
3504                    " · {}",
3505                    stack
3506                        .status_bindings
3507                        .iter()
3508                        .map(|b| b.effect_id.as_str())
3509                        .collect::<Vec<_>>()
3510                        .join(", ")
3511                )
3512            };
3513            out.push(GrantTargetOption {
3514                label: format!("{where_label}: {name}{bindings}"),
3515                target_instance_id: iid,
3516            });
3517        };
3518        fn walk(
3519            stacks: &[flatland_protocol::ItemStack],
3520            where_label: &str,
3521            push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
3522        ) {
3523            for s in stacks {
3524                push(s, where_label);
3525                if !s.contents.is_empty() {
3526                    let nested = format!(
3527                        "{where_label}/{}",
3528                        s.display_name.as_deref().unwrap_or(s.template_id.as_str())
3529                    );
3530                    walk(&s.contents, &nested, push);
3531                }
3532            }
3533        }
3534        walk(&self.inventory_stacks, "Bag", &mut push);
3535        for (slot, stack) in &self.worn {
3536            push(stack, body_slot_label(*slot));
3537            let nest = format!(
3538                "{}/{}",
3539                body_slot_label(*slot),
3540                stack
3541                    .display_name
3542                    .as_deref()
3543                    .unwrap_or(stack.template_id.as_str())
3544            );
3545            walk(&stack.contents, &nest, &mut push);
3546        }
3547        out
3548    }
3549
3550    pub fn item_base_mass(&self, template_id: &str) -> f32 {
3551        self.inventory_hints
3552            .get(template_id)
3553            .and_then(|h| h.base_mass)
3554            .unwrap_or(0.5)
3555    }
3556
3557    pub fn item_base_volume(&self, template_id: &str) -> f32 {
3558        self.inventory_hints
3559            .get(template_id)
3560            .and_then(|h| h.base_volume)
3561            .unwrap_or(1.0)
3562    }
3563
3564    pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
3565        let unit = stack
3566            .base_mass
3567            .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
3568        unit * stack.quantity as f32
3569    }
3570
3571    fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
3572        let unit = stack.base_volume.unwrap_or(1.0);
3573        unit * stack.quantity as f32
3574            + stack
3575                .contents
3576                .iter()
3577                .map(Self::stack_tree_volume)
3578                .sum::<f32>()
3579    }
3580
3581    fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
3582        contents.iter().map(Self::stack_tree_volume).sum()
3583    }
3584
3585    fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
3586        self.inventory_hints
3587            .get(template_id)
3588            .and_then(|h| h.capacity_volume)
3589            .filter(|c| *c > 0.0)
3590    }
3591
3592    fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
3593        stack
3594            .capacity_volume
3595            .filter(|c| *c > 0.0)
3596            .or_else(|| self.template_capacity_volume(&stack.template_id))
3597    }
3598
3599    /// Volume used / capacity / free space label for storage containers in the inventory UI.
3600    pub fn container_volume_label(&self, row: &InventoryRow) -> String {
3601        let Some((used, cap)) = self.container_volume_stats(row) else {
3602            return String::new();
3603        };
3604        let free = (cap - used).max(0.0);
3605        format!("  vol {used:.0}/{cap:.0} ({free:.0} free)")
3606    }
3607
3608    fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
3609        if row.is_chest_shell {
3610            let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
3611                return None;
3612            };
3613            let chest = self
3614                .placed_containers
3615                .iter()
3616                .find(|c| c.id == *container_id)?;
3617            let cap = self
3618                .stack_capacity_volume(&row.stack)
3619                .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
3620            let used = if chest.accessible {
3621                Self::contents_used_volume(&chest.contents)
3622            } else {
3623                0.0
3624            };
3625            return Some((used, cap));
3626        }
3627
3628        let cap = self.stack_capacity_volume(&row.stack)?;
3629        let used = Self::contents_used_volume(&row.stack.contents);
3630        Some((used, cap))
3631    }
3632
3633    pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
3634        if row.is_chest_shell {
3635            return true;
3636        }
3637        if row.is_equip_shell {
3638            return self.inventory_item_category(&row.stack.template_id) == Some("container");
3639        }
3640        self.inventory_item_category(&row.stack.template_id) == Some("container")
3641            || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
3642    }
3643
3644    fn container_stack_for(
3645        &self,
3646        location: &flatland_protocol::InventoryLocation,
3647        parent_instance_id: Option<uuid::Uuid>,
3648    ) -> Option<flatland_protocol::ItemStack> {
3649        match location {
3650            flatland_protocol::InventoryLocation::Root => {
3651                let pid = parent_instance_id?;
3652                self.find_stack_by_instance(&self.inventory_stacks, pid)
3653            }
3654            flatland_protocol::InventoryLocation::Worn { slot } => {
3655                let worn = self.worn.get(slot)?;
3656                if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
3657                    Some(worn.clone())
3658                } else {
3659                    self.find_stack_by_instance(&worn.contents, parent_instance_id?)
3660                }
3661            }
3662            flatland_protocol::InventoryLocation::Placed { container_id } => {
3663                let chest = self
3664                    .placed_containers
3665                    .iter()
3666                    .find(|c| c.id == *container_id)?;
3667                if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
3668                    Some(flatland_protocol::ItemStack {
3669                        template_id: chest.template_id.clone(),
3670                        quantity: 1,
3671                        item_instance_id: chest.item_instance_id,
3672                        props: Default::default(),
3673                        status_bindings: Vec::new(),
3674                        contents: chest.contents.clone(),
3675                        display_name: Some(chest.display_name.clone()),
3676                        category: Some("container".into()),
3677                        capacity_volume: self
3678                            .inventory_hints
3679                            .get(&chest.template_id)
3680                            .and_then(|h| h.capacity_volume),
3681                        worker_lodging_capacity: chest.worker_lodging_capacity,
3682                        ..Default::default()
3683                    })
3684                } else {
3685                    self.find_stack_by_instance(&chest.contents, parent_instance_id?)
3686                }
3687            }
3688            flatland_protocol::InventoryLocation::Keychain => None,
3689            flatland_protocol::InventoryLocation::WhisperPouch => None,
3690        }
3691    }
3692
3693    fn find_stack_by_instance(
3694        &self,
3695        stacks: &[flatland_protocol::ItemStack],
3696        instance_id: uuid::Uuid,
3697    ) -> Option<flatland_protocol::ItemStack> {
3698        for stack in stacks {
3699            if stack.item_instance_id == Some(instance_id) {
3700                return Some(stack.clone());
3701            }
3702            if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
3703                return Some(found);
3704            }
3705        }
3706        None
3707    }
3708
3709    /// Client-side estimate of how many units can move to `to` (server clamps authoritatively).
3710    pub fn max_movable_to(
3711        &self,
3712        template_id: &str,
3713        stack_qty: u32,
3714        from: &flatland_protocol::InventoryLocation,
3715        to: &flatland_protocol::InventoryLocation,
3716        parent_instance_id: Option<uuid::Uuid>,
3717    ) -> u32 {
3718        let unit_vol = self.item_base_volume(template_id);
3719        let mut limit = stack_qty;
3720
3721        if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
3722            let cap = parent
3723                .capacity_volume
3724                .or_else(|| {
3725                    self.inventory_hints
3726                        .get(&parent.template_id)
3727                        .and_then(|h| h.capacity_volume)
3728                })
3729                .unwrap_or(0.0);
3730            if cap > 0.0 && unit_vol > 0.0 {
3731                let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
3732                limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
3733            }
3734        }
3735
3736        let _ = from;
3737        limit.max(0).min(stack_qty)
3738    }
3739
3740    pub fn move_picker_max_at_selection(&self) -> u32 {
3741        let Some(picker) = &self.move_picker else {
3742            return 1;
3743        };
3744        let Some(opt) = picker.options.get(self.move_picker_index) else {
3745            return picker.stack_quantity;
3746        };
3747        match &opt.kind {
3748            MoveOptionKind::Cancel
3749            | MoveOptionKind::Drop
3750            | MoveOptionKind::Use
3751            | MoveOptionKind::GrantApply
3752            | MoveOptionKind::SellPlotToCrown { .. }
3753            | MoveOptionKind::PickupPlaced { .. }
3754            | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
3755            MoveOptionKind::Move {
3756                location,
3757                parent_instance_id,
3758            } => self.max_movable_to(
3759                &picker.template_id,
3760                picker.stack_quantity,
3761                &picker.from,
3762                location,
3763                *parent_instance_id,
3764            ),
3765        }
3766    }
3767
3768    pub fn clamp_move_picker_quantity(&mut self) {
3769        let max = self.move_picker_max_at_selection();
3770        if let Some(picker) = &mut self.move_picker {
3771            if max == 0 {
3772                picker.quantity = 1;
3773            } else {
3774                picker.quantity = picker.quantity.clamp(1, max);
3775            }
3776        }
3777    }
3778
3779    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
3780        let max = self.move_picker_max_at_selection().max(1);
3781        if let Some(picker) = &mut self.move_picker {
3782            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3783            picker.quantity = next as u32;
3784        }
3785    }
3786
3787    pub fn move_picker_set_quantity_max(&mut self) {
3788        let max = self.move_picker_max_at_selection();
3789        if let Some(picker) = &mut self.move_picker {
3790            picker.quantity = if max == 0 {
3791                1
3792            } else {
3793                max.min(picker.stack_quantity)
3794            };
3795        }
3796    }
3797
3798    pub fn move_picker_set_quantity_min(&mut self) {
3799        if let Some(picker) = &mut self.move_picker {
3800            picker.quantity = 1;
3801        }
3802    }
3803
3804    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
3805        if let Some(picker) = &mut self.destroy_picker {
3806            let max = picker.stack_quantity.max(1);
3807            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3808            picker.quantity = next as u32;
3809        }
3810    }
3811
3812    pub fn destroy_picker_set_quantity_max(&mut self) {
3813        if let Some(picker) = &mut self.destroy_picker {
3814            picker.quantity = picker.stack_quantity.max(1);
3815        }
3816    }
3817
3818    pub fn destroy_picker_set_quantity_min(&mut self) {
3819        if let Some(picker) = &mut self.destroy_picker {
3820            picker.quantity = 1;
3821        }
3822    }
3823
3824    pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3825        let have = self.inventory.get(template_id).copied().unwrap_or(0);
3826        (have, have >= need)
3827    }
3828
3829    /// Have/need using plot-build storage pool (town vault / nearby chest + inventory).
3830    pub fn plot_build_stock_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3831        let have = self
3832            .plot_build_offer
3833            .as_ref()
3834            .and_then(|o| {
3835                o.available
3836                    .iter()
3837                    .find(|s| s.template_id == template_id)
3838                    .map(|s| s.quantity)
3839            })
3840            .unwrap_or_else(|| self.inventory.get(template_id).copied().unwrap_or(0));
3841        (have, have >= need)
3842    }
3843
3844    pub fn plot_build_wall_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3845        self.building_materials
3846            .iter()
3847            .filter(|m| m.can_wall)
3848            .collect()
3849    }
3850
3851    pub fn plot_build_roof_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3852        self.building_materials
3853            .iter()
3854            .filter(|m| m.can_roof)
3855            .collect()
3856    }
3857
3858    pub fn plot_build_selected_wall(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3859        self.plot_build_wall_options()
3860            .get(self.plot_build_wall_index)
3861            .copied()
3862    }
3863
3864    pub fn plot_build_selected_roof(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3865        self.plot_build_roof_options()
3866            .get(self.plot_build_roof_index)
3867            .copied()
3868    }
3869
3870    /// BOM lines for the current wall/roof selection and tilled pad area.
3871    pub fn plot_build_bom_lines(&self) -> Vec<(String, String, u32)> {
3872        let Some(wall) = self.plot_build_selected_wall() else {
3873            return Vec::new();
3874        };
3875        let Some(roof) = self.plot_build_selected_roof() else {
3876            return Vec::new();
3877        };
3878        let area = self
3879            .plot_build_offer
3880            .as_ref()
3881            .filter(|o| o.pad_ok)
3882            .map(|o| o.pad_width_m * o.pad_depth_m)
3883            .unwrap_or(0.0);
3884        if area <= 0.0 {
3885            return Vec::new();
3886        }
3887        let mut map: std::collections::HashMap<String, (String, u32)> =
3888            std::collections::HashMap::new();
3889        for line in &wall.wall_bom {
3890            let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3891            if qty == 0 {
3892                continue;
3893            }
3894            let name = if line.display_name.is_empty() {
3895                line.template_id.clone()
3896            } else {
3897                line.display_name.clone()
3898            };
3899            let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3900            entry.1 = entry.1.saturating_add(qty);
3901        }
3902        for line in &roof.roof_bom {
3903            let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3904            if qty == 0 {
3905                continue;
3906            }
3907            let name = if line.display_name.is_empty() {
3908                line.template_id.clone()
3909            } else {
3910                line.display_name.clone()
3911            };
3912            let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3913            entry.1 = entry.1.saturating_add(qty);
3914        }
3915        let mut out: Vec<_> = map
3916            .into_iter()
3917            .map(|(id, (name, qty))| (id, name, qty))
3918            .collect();
3919        out.sort_by(|a, b| a.0.cmp(&b.0));
3920        out
3921    }
3922
3923    pub fn plot_build_duration_secs(&self) -> Option<f32> {
3924        let wall = self.plot_build_selected_wall()?;
3925        let roof = self.plot_build_selected_roof()?;
3926        let offer = self.plot_build_offer.as_ref()?;
3927        if !offer.pad_ok {
3928            return None;
3929        }
3930        let area = offer.pad_width_m * offer.pad_depth_m;
3931        let mult = wall.tick_mult.max(roof.tick_mult).max(0.1);
3932        let ticks = (offer.base_ticks as f32 + area * offer.tick_per_m2 as f32 * mult).ceil();
3933        Some(ticks.max(2.0) / 30.0)
3934    }
3935
3936    pub fn plot_build_can_afford(&self) -> bool {
3937        if self.plot_build_offer.as_ref().is_none_or(|o| !o.pad_ok) {
3938            return false;
3939        }
3940        self.plot_build_bom_lines()
3941            .iter()
3942            .all(|(id, _, need)| self.plot_build_stock_status(id, *need).1)
3943    }
3944
3945    pub fn currency_display(&self) -> String {
3946        crate::currency::currency_line(&self.inventory)
3947    }
3948
3949    /// True when standing in a shallow-water terrain zone from the segment snapshot.
3950    pub fn in_shallow_water(&self) -> bool {
3951        let (px, py) = self.player_position();
3952        self.terrain_at(px, py)
3953            .is_some_and(|k| k == TerrainKindView::ShallowWater)
3954    }
3955
3956    /// On or orthogonally next to fillable water, or next to a well-tagged building.
3957    pub fn near_liquid_fill_source(&self) -> bool {
3958        let (px, py) = self.player_position();
3959        const CELL: f32 = 1.0;
3960        let offsets = [
3961            (0.0, 0.0),
3962            (CELL, 0.0),
3963            (-CELL, 0.0),
3964            (0.0, CELL),
3965            (0.0, -CELL),
3966        ];
3967        for (dx, dy) in offsets {
3968            if matches!(
3969                self.terrain_at(px + dx, py + dy),
3970                Some(TerrainKindView::ShallowWater | TerrainKindView::DeepWater)
3971            ) {
3972                return true;
3973            }
3974        }
3975        self.buildings.iter().any(|b| {
3976            if !b.tags.iter().any(|t| t == "well") {
3977                return false;
3978            }
3979            let hw = b.width_m * 0.5;
3980            let hd = b.depth_m * 0.5;
3981            let nx = px.clamp(b.x - hw, b.x + hw);
3982            let ny = py.clamp(b.y - hd, b.y + hd);
3983            let dx = px - nx;
3984            let dy = py - ny;
3985            dx * dx + dy * dy <= INTERACTION_RADIUS_M * INTERACTION_RADIUS_M
3986        })
3987    }
3988
3989    pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
3990        self.terrain_zone_at(x, y).map(|z| z.kind)
3991    }
3992
3993    /// First terrain zone containing `(x, y)` — highest `z_order` wins.
3994    pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
3995        use std::cell::RefCell;
3996
3997        const CHUNK: i32 = 8;
3998        thread_local! {
3999            static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
4000                RefCell::new(None);
4001        }
4002
4003        let zones = &self.terrain_zones;
4004        if zones.is_empty() {
4005            return None;
4006        }
4007        if zones.len() <= 48 {
4008            return zones
4009                .iter()
4010                .enumerate()
4011                .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
4012                .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
4013                .map(|(_, z)| z);
4014        }
4015
4016        let ptr = zones.as_ptr();
4017        let len = zones.len();
4018        INDEX.with(|cell| {
4019            let mut slot = cell.borrow_mut();
4020            let stale = match slot.as_ref() {
4021                Some((p, l, _)) => *p != ptr || *l != len,
4022                None => true,
4023            };
4024            if stale {
4025                let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
4026                    std::collections::HashMap::new();
4027                for (zi, z) in zones.iter().enumerate() {
4028                    let x0 = z.x0.min(z.x1).floor() as i32;
4029                    let y0 = z.y0.min(z.y1).floor() as i32;
4030                    let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
4031                    let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
4032                    let cx0 = x0.div_euclid(CHUNK);
4033                    let cy0 = y0.div_euclid(CHUNK);
4034                    let cx1 = x1.div_euclid(CHUNK);
4035                    let cy1 = y1.div_euclid(CHUNK);
4036                    for cy in cy0..=cy1 {
4037                        for cx in cx0..=cx1 {
4038                            chunks.entry((cx, cy)).or_default().push(zi);
4039                        }
4040                    }
4041                }
4042                *slot = Some((ptr, len, chunks));
4043            }
4044            let chunks = &slot.as_ref().expect("index").2;
4045            let cx = (x.floor() as i32).div_euclid(CHUNK);
4046            let cy = (y.floor() as i32).div_euclid(CHUNK);
4047            let mut best: Option<(usize, &TerrainZoneView)> = None;
4048            if let Some(list) = chunks.get(&(cx, cy)) {
4049                for &zi in list {
4050                    let Some(z) = zones.get(zi) else { continue };
4051                    if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
4052                        continue;
4053                    }
4054                    best = match best {
4055                        None => Some((zi, z)),
4056                        Some((bi, bz)) => {
4057                            if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
4058                                Some((zi, z))
4059                            } else {
4060                                Some((bi, bz))
4061                            }
4062                        }
4063                    };
4064                }
4065            }
4066            best.map(|(_, z)| z)
4067        })
4068    }
4069
4070    /// Ground elevation from terrain zones (m).
4071    pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
4072        self.terrain_zone_at(x, y)
4073            .map(|z| z.elevation)
4074            .unwrap_or(0.0)
4075    }
4076
4077    /// Walkable z levels at a map column (terrain + platforms).
4078    pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
4079        const TOL: f32 = 0.35;
4080        let mut levels = vec![self.elevation_at(x, y)];
4081        for p in &self.z_platforms {
4082            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
4083                levels.push(p.z);
4084            }
4085        }
4086        levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
4087        levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
4088        levels
4089    }
4090
4091    pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
4092        const TOL: f32 = 0.35;
4093        self.walkable_levels_at(x, y)
4094            .iter()
4095            .any(|&l| (l - z).abs() <= TOL)
4096    }
4097
4098    pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
4099        let mut top = self.elevation_at(x, y);
4100        for p in &self.z_platforms {
4101            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
4102                top = top.max(p.z);
4103            }
4104        }
4105        top
4106    }
4107
4108    /// Authoritative interior context from the server (`inside_building` flag).
4109    pub fn effective_inside_building(&self) -> Option<String> {
4110        self.player_entity().and_then(|p| p.inside_building.clone())
4111    }
4112
4113    /// True when a placed chest shares the player's outdoor/interior space.
4114    /// Owned indoor chests may still arrive in AOI while outdoors (worker route
4115    /// labels); they must not be drawn or treated as nearby world props.
4116    pub fn placed_container_in_current_space(
4117        &self,
4118        c: &flatland_protocol::PlacedContainerView,
4119    ) -> bool {
4120        match (
4121            self.effective_inside_building().as_deref(),
4122            c.building_id.as_deref(),
4123        ) {
4124            (None, None) => true,
4125            (Some(a), Some(b)) => a == b,
4126            _ => false,
4127        }
4128    }
4129
4130    fn merge_stack_catalog_hints(&mut self, stacks: &[flatland_protocol::ItemStack]) {
4131        fn walk(
4132            stacks: &[flatland_protocol::ItemStack],
4133            hints: &mut std::collections::HashMap<String, InventoryHint>,
4134        ) {
4135            for stack in stacks {
4136                if stack.display_name.is_some()
4137                    || stack.category.is_some()
4138                    || stack.base_mass.is_some()
4139                    || stack.base_volume.is_some()
4140                    || stack.base_value_copper.is_some()
4141                {
4142                    hints.insert(
4143                        stack.template_id.clone(),
4144                        InventoryHint {
4145                            display_name: stack
4146                                .display_name
4147                                .clone()
4148                                .unwrap_or_else(|| stack.template_id.clone()),
4149                            category: stack.category.clone().unwrap_or_default(),
4150                            base_mass: stack.base_mass,
4151                            base_volume: stack.base_volume,
4152                            capacity_volume: stack.capacity_volume,
4153                            stackable: stack.stackable.unwrap_or(true),
4154                            listable: stack.listable.unwrap_or_else(|| {
4155                                category_default_listable(stack.category.as_deref().unwrap_or(""))
4156                            }),
4157                            base_value_copper: stack.base_value_copper,
4158                        },
4159                    );
4160                }
4161                walk(&stack.contents, hints);
4162            }
4163        }
4164        walk(stacks, &mut self.inventory_hints);
4165    }
4166
4167    pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
4168        self.inventory_stacks = stacks.to_vec();
4169        self.inventory.clear();
4170        self.inventory_hints.clear();
4171        fn walk(
4172            stacks: &[flatland_protocol::ItemStack],
4173            inventory: &mut std::collections::HashMap<String, u32>,
4174            hints: &mut std::collections::HashMap<String, InventoryHint>,
4175        ) {
4176            for stack in stacks {
4177                *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
4178                if stack.display_name.is_some()
4179                    || stack.category.is_some()
4180                    || stack.base_mass.is_some()
4181                    || stack.base_volume.is_some()
4182                    || stack.base_value_copper.is_some()
4183                {
4184                    hints.insert(
4185                        stack.template_id.clone(),
4186                        InventoryHint {
4187                            display_name: stack
4188                                .display_name
4189                                .clone()
4190                                .unwrap_or_else(|| stack.template_id.clone()),
4191                            category: stack.category.clone().unwrap_or_default(),
4192                            base_mass: stack.base_mass,
4193                            base_volume: stack.base_volume,
4194                            capacity_volume: stack.capacity_volume,
4195                            stackable: stack.stackable.unwrap_or(true),
4196                            listable: stack.listable.unwrap_or_else(|| {
4197                                category_default_listable(stack.category.as_deref().unwrap_or(""))
4198                            }),
4199                            base_value_copper: stack.base_value_copper,
4200                        },
4201                    );
4202                }
4203                walk(&stack.contents, inventory, hints);
4204            }
4205        }
4206        walk(stacks, &mut self.inventory, &mut self.inventory_hints);
4207        // Include worn items (and nested contents, e.g. belt-clipped pouches) in craft counts.
4208        for item in self.worn.values() {
4209            walk(
4210                std::slice::from_ref(item),
4211                &mut self.inventory,
4212                &mut self.inventory_hints,
4213            );
4214        }
4215    }
4216
4217    fn sync_item_catalog(&mut self, entries: &[ItemCatalogEntryView]) {
4218        if entries.is_empty() {
4219            return;
4220        }
4221        self.item_catalog.clear();
4222        self.item_catalog.reserve(entries.len());
4223        for entry in entries {
4224            if entry.template_id.is_empty() {
4225                continue;
4226            }
4227            self.item_catalog
4228                .insert(entry.template_id.clone(), entry.clone());
4229        }
4230    }
4231
4232    /// Remove a carried stack by instance id (root or nested in a worn bag).
4233    /// Used for optimistic give-to-worker so the bag UI does not keep the item
4234    /// until the next tick — and so a later "Gave …" interaction cannot add it back.
4235    fn remove_carried_instance(&mut self, instance_id: uuid::Uuid, quantity: Option<u32>) {
4236        fn take_from(
4237            stacks: &mut Vec<flatland_protocol::ItemStack>,
4238            instance_id: uuid::Uuid,
4239            qty: Option<u32>,
4240        ) -> bool {
4241            if let Some(i) = stacks
4242                .iter()
4243                .position(|s| s.item_instance_id == Some(instance_id))
4244            {
4245                let have = stacks[i].quantity;
4246                let take = qty.unwrap_or(have).min(have);
4247                if take >= have {
4248                    stacks.remove(i);
4249                } else {
4250                    stacks[i].quantity = have - take;
4251                }
4252                return true;
4253            }
4254            stacks
4255                .iter_mut()
4256                .any(|stack| take_from(&mut stack.contents, instance_id, qty))
4257        }
4258
4259        if take_from(&mut self.inventory_stacks, instance_id, quantity) {
4260            let stacks = self.inventory_stacks.clone();
4261            self.sync_inventory_from_stacks(&stacks);
4262            self.refresh_inventory_ui();
4263            return;
4264        }
4265        let slots: Vec<_> = self.worn.keys().copied().collect();
4266        for slot in slots {
4267            let Some(item) = self.worn.get_mut(&slot) else {
4268                continue;
4269            };
4270            if take_from(&mut item.contents, instance_id, quantity) {
4271                let stacks = self.inventory_stacks.clone();
4272                self.sync_inventory_from_stacks(&stacks);
4273                self.refresh_inventory_ui();
4274                return;
4275            }
4276        }
4277    }
4278
4279    /// Apply server interaction deltas immediately (quest rewards, shop, etc.) so the
4280    /// inventory UI updates before the next tick snapshot arrives.
4281    pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
4282        // Worker handoff notices reuse `inventory_delta` as a toast payload, not a
4283        // gain. The same tick already dropped the stack; applying it as an add put
4284        // the item back in the player bag.
4285        if notice.message.starts_with("Gave ") {
4286            if notice.coins_delta != 0 {
4287                crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
4288                let stacks = self.inventory_stacks.clone();
4289                self.sync_inventory_from_stacks(&stacks);
4290            }
4291            self.record_shop_trade_notice(notice);
4292            return;
4293        }
4294        let subtract_items =
4295            notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
4296        for stack in &notice.inventory_delta {
4297            if stack.quantity == 0 {
4298                continue;
4299            }
4300            if subtract_items {
4301                crate::currency::drain_template_stacks(
4302                    &mut self.inventory_stacks,
4303                    &stack.template_id,
4304                    stack.quantity,
4305                );
4306                continue;
4307            }
4308            let stackable = self
4309                .inventory_hints
4310                .get(&stack.template_id)
4311                .map(|h| h.stackable)
4312                .or(stack.stackable)
4313                .unwrap_or(true);
4314            if stackable {
4315                if let Some(existing) = self
4316                    .inventory_stacks
4317                    .iter_mut()
4318                    .find(|s| s.template_id == stack.template_id)
4319                {
4320                    existing.quantity = existing.quantity.saturating_add(stack.quantity);
4321                    if stack.display_name.is_some() {
4322                        existing.display_name = stack.display_name.clone();
4323                    }
4324                    if stack.category.is_some() {
4325                        existing.category = stack.category.clone();
4326                    }
4327                    continue;
4328                }
4329            }
4330            self.inventory_stacks.push(stack.clone());
4331        }
4332        if notice.coins_delta != 0 {
4333            crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
4334        }
4335        if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
4336            let stacks = self.inventory_stacks.clone();
4337            self.sync_inventory_from_stacks(&stacks);
4338        }
4339        self.record_shop_trade_notice(notice);
4340    }
4341
4342    /// Worn body-slot items — each shown as a shell row (unequip via Enter) followed by
4343    /// its nested contents (e.g. pouches clipped onto a worn belt). `BodySlot` derives
4344    /// `Ord` in display order (Head/Body/Arms/Legs/Feet/Back/Waist), so `BTreeMap`
4345    /// iteration alone gives a stable row order.
4346    pub fn worn_rows(&self) -> Vec<InventoryRow> {
4347        let mut rows = Vec::new();
4348        for (slot, item) in &self.worn {
4349            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4350            rows.push(InventoryRow {
4351                depth: 0,
4352                stack: item.clone(),
4353                from: from.clone(),
4354                from_parent_instance_id: None,
4355                is_equip_shell: true,
4356                is_chest_shell: false,
4357                section: InventorySection::Worn,
4358            });
4359            for child in &item.contents {
4360                push_inventory_rows(
4361                    &mut rows,
4362                    1,
4363                    child,
4364                    &from,
4365                    item.item_instance_id,
4366                    InventorySection::Worn,
4367                );
4368            }
4369        }
4370        rows
4371    }
4372
4373    /// Root stacks eligible to present in a player trade (excludes hand-equipped gear).
4374    pub fn trade_presentable_stacks(&self) -> Vec<&flatland_protocol::ItemStack> {
4375        let equipped = self.hand_equipped_instance_ids();
4376        self.inventory_stacks
4377            .iter()
4378            .filter(|s| s.item_instance_id.is_some_and(|id| !equipped.contains(&id)))
4379            .collect()
4380    }
4381
4382    /// Root on-person stacks that can be handed to a hired worker.
4383    pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
4384        let equipped = self.hand_equipped_instance_ids();
4385        self.inventory_stacks
4386            .iter()
4387            .filter_map(|stack| {
4388                let item_instance_id = stack.item_instance_id?;
4389                if equipped.contains(&item_instance_id) {
4390                    return None;
4391                }
4392                let label = stack
4393                    .display_name
4394                    .clone()
4395                    .unwrap_or_else(|| stack.template_id.clone());
4396                let label = if stack.quantity > 1 {
4397                    format!("{label} ×{}", stack.quantity)
4398                } else {
4399                    label
4400                };
4401                Some(WorkerGiveOption {
4402                    item_instance_id,
4403                    label,
4404                    quantity: stack.quantity,
4405                    template_id: stack.template_id.clone(),
4406                })
4407            })
4408            .collect()
4409    }
4410
4411    /// Employer-known blueprints the selected worker does not yet know.
4412    pub fn teachable_blueprint_options(
4413        &self,
4414        worker: &flatland_protocol::HiredWorkerView,
4415    ) -> Vec<WorkerTeachOption> {
4416        let copper = crate::currency::copper_from_counts(&self.inventory);
4417        let mut options: Vec<WorkerTeachOption> = self
4418            .blueprints
4419            .iter()
4420            .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
4421            .map(|bp| {
4422                let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
4423                let cost = bp.worker_train_copper;
4424                WorkerTeachOption {
4425                    blueprint_id: bp.id.clone(),
4426                    label: if bp.label.is_empty() {
4427                        bp.id.clone()
4428                    } else {
4429                        bp.label.clone()
4430                    },
4431                    cost_copper: cost,
4432                    min_level,
4433                    worker_level: worker.level,
4434                    can_afford: copper >= cost,
4435                    level_ok: worker.level >= min_level,
4436                }
4437            })
4438            .collect();
4439        options.sort_by(|a, b| a.label.cmp(&b.label));
4440        options
4441    }
4442
4443    /// Loose on-person inventory (not worn, not inside a placed chest).
4444    /// Root stacks are ordered by category group; nested contents stay under their parent.
4445    pub fn person_rows(&self) -> Vec<InventoryRow> {
4446        self.person_rows_filtered("")
4447    }
4448
4449    pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4450        let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
4451        roots.sort_by(|a, b| {
4452            let ca = a
4453                .category
4454                .as_deref()
4455                .or_else(|| self.inventory_item_category(&a.template_id))
4456                .unwrap_or("");
4457            let cb = b
4458                .category
4459                .as_deref()
4460                .or_else(|| self.inventory_item_category(&b.template_id))
4461                .unwrap_or("");
4462            let ga = inventory_category_group(ca).1;
4463            let gb = inventory_category_group(cb).1;
4464            ga.cmp(&gb).then_with(|| {
4465                let na = a.display_name.as_deref().unwrap_or(a.template_id.as_str());
4466                let nb = b.display_name.as_deref().unwrap_or(b.template_id.as_str());
4467                na.cmp(nb)
4468            })
4469        });
4470        let mut rows = Vec::new();
4471        for stack in roots {
4472            push_inventory_rows_filtered(
4473                &mut rows,
4474                0,
4475                stack,
4476                &flatland_protocol::InventoryLocation::Root,
4477                None,
4478                InventorySection::Person,
4479                filter,
4480            );
4481        }
4482        rows
4483    }
4484
4485    pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4486        if filter.is_empty() {
4487            return self.worn_rows();
4488        }
4489        let mut rows = Vec::new();
4490        for (slot, item) in &self.worn {
4491            if !stack_matches_filter(item, filter) {
4492                continue;
4493            }
4494            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4495            let self_hit = {
4496                let f = filter.to_ascii_lowercase();
4497                let name = item
4498                    .display_name
4499                    .as_deref()
4500                    .unwrap_or("")
4501                    .to_ascii_lowercase();
4502                let tid = item.template_id.to_ascii_lowercase();
4503                name.contains(&f) || tid.contains(&f)
4504            };
4505            rows.push(InventoryRow {
4506                depth: 0,
4507                stack: item.clone(),
4508                from: from.clone(),
4509                from_parent_instance_id: None,
4510                is_equip_shell: true,
4511                is_chest_shell: false,
4512                section: InventorySection::Worn,
4513            });
4514            for child in &item.contents {
4515                if self_hit || stack_matches_filter(child, filter) {
4516                    push_inventory_rows_filtered(
4517                        &mut rows,
4518                        1,
4519                        child,
4520                        &from,
4521                        item.item_instance_id,
4522                        InventorySection::Worn,
4523                        if self_hit { "" } else { filter },
4524                    );
4525                }
4526            }
4527        }
4528        rows
4529    }
4530
4531    /// Items carried inside equipped containers, without showing the equipped
4532    /// container shells themselves. Equipped gear belongs in the paperdoll;
4533    /// these rows are still movable because the contents are carried by the player.
4534    pub fn carried_worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4535        let mut rows = Vec::new();
4536        for (slot, item) in &self.worn {
4537            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4538            for child in &item.contents {
4539                push_inventory_rows_filtered(
4540                    &mut rows,
4541                    0,
4542                    child,
4543                    &from,
4544                    item.item_instance_id,
4545                    InventorySection::Person,
4546                    filter,
4547                );
4548            }
4549        }
4550        rows
4551    }
4552
4553    /// Legacy alias used by the HUD sidebar summary (worn + on-person, unchanged).
4554    pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
4555        let mut rows = self.worn_rows();
4556        rows.extend(self.person_rows());
4557        rows.into_iter().map(|r| (r.depth, r.stack)).collect()
4558    }
4559
4560    /// Placed chests within `CONTAINER_RANGE_M`, nearest first. Contents are only
4561    /// populated when `accessible` — this is what makes a chest's contents
4562    /// disappear the moment you walk away or it's locked without your key.
4563    pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
4564        let (px, py) = self.player_position();
4565        let mut list: Vec<NearbyContainer> = self
4566            .placed_containers
4567            .iter()
4568            .filter(|c| self.placed_container_in_current_space(c))
4569            .filter_map(|c| {
4570                let distance_m = (c.x - px).hypot(c.y - py);
4571                if distance_m > CONTAINER_RANGE_M {
4572                    return None;
4573                }
4574                let mut rows = Vec::new();
4575                let from = flatland_protocol::InventoryLocation::Placed {
4576                    container_id: c.id.clone(),
4577                };
4578                rows.push(InventoryRow {
4579                    depth: 0,
4580                    stack: flatland_protocol::ItemStack {
4581                        template_id: c.template_id.clone(),
4582                        quantity: 1,
4583                        item_instance_id: c.item_instance_id,
4584                        props: Default::default(),
4585                        status_bindings: Vec::new(),
4586                        contents: Vec::new(),
4587                        display_name: Some(c.display_name.clone()),
4588                        category: Some("container".into()),
4589                        capacity_volume: c.capacity_volume,
4590                        worker_lodging_capacity: c.worker_lodging_capacity,
4591                        ..Default::default()
4592                    },
4593                    from: from.clone(),
4594                    from_parent_instance_id: None,
4595                    is_equip_shell: false,
4596                    is_chest_shell: true,
4597                    section: InventorySection::Nearby,
4598                });
4599                if c.accessible {
4600                    for child in &c.contents {
4601                        push_inventory_rows(
4602                            &mut rows,
4603                            1,
4604                            child,
4605                            &from,
4606                            c.item_instance_id,
4607                            InventorySection::Nearby,
4608                        );
4609                    }
4610                }
4611                Some(NearbyContainer {
4612                    view: c.clone(),
4613                    distance_m,
4614                    rows,
4615                })
4616            })
4617            .collect();
4618        list.sort_by(|a, b| {
4619            a.distance_m
4620                .partial_cmp(&b.distance_m)
4621                .unwrap_or(std::cmp::Ordering::Equal)
4622        });
4623        list
4624    }
4625
4626    /// Nearest placed chest within `max_dist`, regardless of accessibility.
4627    pub fn nearest_placed_container(
4628        &self,
4629        max_dist: f32,
4630    ) -> Option<flatland_protocol::PlacedContainerView> {
4631        let (px, py) = self.player_position();
4632        self.placed_containers
4633            .iter()
4634            .filter(|c| self.placed_container_in_current_space(c))
4635            .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
4636            .min_by(|a, b| {
4637                let da = (a.x - px).hypot(a.y - py);
4638                let db = (b.x - px).hypot(b.y - py);
4639                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
4640            })
4641            .cloned()
4642    }
4643
4644    /// Selectable rows for the **active inventory tab** (and current filter).
4645    /// Index into this with `inventory_menu_index`; browser lines must use the same order.
4646    pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
4647        let filter = self.inventory_filter.as_str();
4648        match self.inventory_tab {
4649            InventoryTab::OnPerson => {
4650                let mut rows = self.carried_worn_rows_filtered(filter);
4651                rows.extend(self.person_rows_filtered(filter));
4652                rows
4653            }
4654            InventoryTab::Nearby => {
4655                let mut rows = Vec::new();
4656                for nc in self.nearby_containers() {
4657                    if filter.is_empty() {
4658                        rows.extend(nc.rows);
4659                        continue;
4660                    }
4661                    let shell = nc.rows.first().cloned();
4662                    let contents: Vec<_> = nc
4663                        .rows
4664                        .iter()
4665                        .skip(1)
4666                        .filter(|r| stack_matches_filter(&r.stack, filter))
4667                        .cloned()
4668                        .collect();
4669                    let shell_hit = shell
4670                        .as_ref()
4671                        .map(|s| stack_matches_filter(&s.stack, filter))
4672                        .unwrap_or(false);
4673                    if shell_hit || !contents.is_empty() {
4674                        if let Some(s) = shell {
4675                            rows.push(s);
4676                        }
4677                        if shell_hit {
4678                            rows.extend(nc.rows.into_iter().skip(1));
4679                        } else {
4680                            rows.extend(contents);
4681                        }
4682                    }
4683                }
4684                rows
4685            }
4686        }
4687    }
4688
4689    pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
4690        self.inventory_selectable_rows()
4691            .into_iter()
4692            .nth(self.inventory_menu_index)
4693    }
4694
4695    fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
4696        let cat = self
4697            .inventory_item_category(&row.stack.template_id)
4698            .unwrap_or("");
4699        if cat == "key" {
4700            self.key_inventory_label(&row.stack)
4701        } else {
4702            row.stack
4703                .display_name
4704                .clone()
4705                .unwrap_or_else(|| row.stack.template_id.clone())
4706        }
4707    }
4708
4709    /// Signature of everything already shown on the gfx row title (bindings, grants, qty, worn slot).
4710    fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
4711        let bindings =
4712            format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
4713        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
4714            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
4715            let mode = Self::grant_mode(&row.stack);
4716            format!(" [grant {effect} · {mode} — e apply]")
4717        } else {
4718            String::new()
4719        };
4720        let qty = if row.stack.quantity > 1 {
4721            format!(" ×{}", row.stack.quantity)
4722        } else {
4723            String::new()
4724        };
4725        let worn_slot = if row.is_equip_shell {
4726            match row.from {
4727                flatland_protocol::InventoryLocation::Worn { slot } => {
4728                    format!(" ({})", body_slot_label(slot))
4729                }
4730                _ => String::new(),
4731            }
4732        } else {
4733            String::new()
4734        };
4735        format!("{grant_hint}{bindings}{qty}{worn_slot}")
4736    }
4737
4738    fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
4739        (
4740            row.stack.template_id.clone(),
4741            self.inventory_row_base_label(row),
4742            self.inventory_row_visible_mod_signature(row),
4743        )
4744    }
4745
4746    /// Keys for which two or more instanced rows look the same in the browser (need hover disambiguation).
4747    fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
4748        let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
4749        for row in self.inventory_selectable_rows() {
4750            if row.stack.item_instance_id.is_none() {
4751                continue;
4752            }
4753            let key = self.inventory_row_instance_identity_key(&row);
4754            *counts.entry(key).or_default() += 1;
4755        }
4756        counts
4757            .into_iter()
4758            .filter(|(_, n)| *n > 1)
4759            .map(|(k, _)| k)
4760            .collect()
4761    }
4762
4763    fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
4764        let hex: String = id
4765            .as_simple()
4766            .to_string()
4767            .chars()
4768            .filter(|c| c.is_ascii_hexdigit())
4769            .collect();
4770        let short = if hex.len() >= 4 {
4771            &hex[hex.len() - 4..]
4772        } else {
4773            hex.as_str()
4774        };
4775        format!("Instance {id} (#{short})")
4776    }
4777
4778    /// Format one selectable inventory row for TUI/gfx (label + hints + mass/volume).
4779    pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
4780        let cat = self
4781            .inventory_item_category(&row.stack.template_id)
4782            .unwrap_or("");
4783        let label = self.inventory_row_base_label(row);
4784        let hint: String = if row.is_equip_shell {
4785            " [worn — Enter to unequip]".into()
4786        } else if row.is_chest_shell {
4787            let (locked, lodging_note) = match &row.from {
4788                flatland_protocol::InventoryLocation::Placed { container_id } => {
4789                    let locked = self
4790                        .placed_containers
4791                        .iter()
4792                        .find(|c| c.id == *container_id)
4793                        .map(|c| c.locked)
4794                        .unwrap_or(false);
4795                    let lodging_note = self
4796                        .lodging_occupancy_label(container_id)
4797                        .map(|who| format!(" [lodging: {who}]"))
4798                        .unwrap_or_default();
4799                    (locked, lodging_note)
4800                }
4801                _ => (false, String::new()),
4802            };
4803            if locked {
4804                format!(" [locked — Enter pick up · l unlock]{lodging_note}")
4805            } else {
4806                format!(" [Enter pick up · l lock]{lodging_note}")
4807            }
4808        } else if cat == "key" {
4809            self.key_inventory_hint(&row.stack)
4810        } else {
4811            match cat {
4812                "weapon" => " [weapon]".into(),
4813                "container" => " [bag/chest/belt]".into(),
4814                "lodging" => " [worker lodging]".into(),
4815                "armor" => " [armor]".into(),
4816                _ => String::new(),
4817            }
4818        };
4819        let qty = if row.stack.quantity > 1 {
4820            format!(" ×{}", row.stack.quantity)
4821        } else {
4822            String::new()
4823        };
4824        let bindings =
4825            format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
4826        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
4827            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
4828            let mode = Self::grant_mode(&row.stack);
4829            format!(" [grant {effect} · {mode} — e apply]")
4830        } else {
4831            String::new()
4832        };
4833        let mass = self.stack_mass(&row.stack);
4834        let mass_kg = (mass >= 0.05).then_some(mass);
4835        let mass_str = mass_kg.map(|m| format!("  {m:.1} kg")).unwrap_or_default();
4836        let volume = self.container_volume_stats(row);
4837        let vol_str = self.container_volume_label(row);
4838
4839        let mut title = label.clone();
4840        title.push_str(&qty);
4841        if row.is_equip_shell {
4842            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
4843                title.push_str(&format!(" ({})", body_slot_label(slot)));
4844            }
4845        }
4846
4847        InventoryRowView {
4848            depth: row.depth,
4849            text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
4850            title: format!("{title}{grant_hint}{bindings}"),
4851            mass_kg,
4852            volume,
4853            instance_tooltip: None,
4854        }
4855    }
4856
4857    fn push_browser_item(
4858        &self,
4859        lines: &mut Vec<InventoryBrowserLine>,
4860        row: &InventoryRow,
4861        global_idx: &mut usize,
4862        target: usize,
4863        highlight: bool,
4864        ambiguous_instance_keys: &HashSet<(String, String, String)>,
4865    ) {
4866        let mut view = self.format_inventory_row(row);
4867        if let Some(id) = row.stack.item_instance_id {
4868            let key = self.inventory_row_instance_identity_key(row);
4869            if ambiguous_instance_keys.contains(&key) {
4870                view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
4871            }
4872        }
4873        lines.push(InventoryBrowserLine::Item {
4874            selectable_index: *global_idx,
4875            selected: highlight && *global_idx == target,
4876            depth: view.depth,
4877            text: view.text,
4878            title: view.title,
4879            mass_kg: view.mass_kg,
4880            volume: view.volume,
4881            instance_tooltip: view.instance_tooltip,
4882        });
4883        *global_idx += 1;
4884    }
4885
4886    /// Sectioned inventory browser lines for the active tab. Selectable rows carry
4887    /// `selectable_index` matching `inventory_menu_index`.
4888    pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
4889        let mut lines = Vec::new();
4890        let target = self.inventory_menu_index;
4891        let highlight = !self.show_move_picker && !self.show_grant_picker;
4892        let filter = self.inventory_filter.as_str();
4893        let mut global_idx = 0usize;
4894        let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
4895
4896        match self.inventory_tab {
4897            InventoryTab::OnPerson => {
4898                lines.push(InventoryBrowserLine::Section("— In carried bags —".into()));
4899                let carried = self.carried_worn_rows_filtered(filter);
4900                if carried.is_empty() {
4901                    lines.push(InventoryBrowserLine::Hint(
4902                        "  (no items in carried bags)".into(),
4903                    ));
4904                } else {
4905                    for row in &carried {
4906                        self.push_browser_item(
4907                            &mut lines,
4908                            row,
4909                            &mut global_idx,
4910                            target,
4911                            highlight,
4912                            &ambiguous_instance_keys,
4913                        );
4914                    }
4915                }
4916
4917                lines.push(InventoryBrowserLine::Blank);
4918                lines.push(InventoryBrowserLine::Section(
4919                    "— On you (loose, not worn) —".into(),
4920                ));
4921                let person = self.person_rows_filtered(filter);
4922                if person.is_empty() {
4923                    lines.push(InventoryBrowserLine::Hint("  (empty)".into()));
4924                } else {
4925                    let mut last_group: Option<&'static str> = None;
4926                    for row in &person {
4927                        if row.depth == 0 {
4928                            let cat = row
4929                                .stack
4930                                .category
4931                                .as_deref()
4932                                .or_else(|| self.inventory_item_category(&row.stack.template_id))
4933                                .unwrap_or("");
4934                            let (group, _) = inventory_category_group(cat);
4935                            if last_group != Some(group) {
4936                                lines.push(InventoryBrowserLine::SlotLabel(format!("  {group}")));
4937                                last_group = Some(group);
4938                            }
4939                        }
4940                        self.push_browser_item(
4941                            &mut lines,
4942                            row,
4943                            &mut global_idx,
4944                            target,
4945                            highlight,
4946                            &ambiguous_instance_keys,
4947                        );
4948                    }
4949                }
4950            }
4951            InventoryTab::Nearby => {
4952                let nearby = self.nearby_containers();
4953                if nearby.is_empty() {
4954                    lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
4955                    lines.push(InventoryBrowserLine::Hint(
4956                        "  (none within reach — walk up to a chest)".into(),
4957                    ));
4958                    lines.push(InventoryBrowserLine::Hint(
4959                        "  Select an on-person item, then m / Enter → move into chest.".into(),
4960                    ));
4961                } else {
4962                    let mut any_visible = false;
4963                    for nc in &nearby {
4964                        let shell = nc.rows.first();
4965                        let contents: Vec<&InventoryRow> = if filter.is_empty() {
4966                            nc.rows.iter().skip(1).collect()
4967                        } else {
4968                            let shell_hit = shell
4969                                .map(|s| {
4970                                    let f = filter.to_ascii_lowercase();
4971                                    let name = s
4972                                        .stack
4973                                        .display_name
4974                                        .as_deref()
4975                                        .unwrap_or("")
4976                                        .to_ascii_lowercase();
4977                                    let tid = s.stack.template_id.to_ascii_lowercase();
4978                                    name.contains(&f) || tid.contains(&f)
4979                                })
4980                                .unwrap_or(false);
4981                            if shell_hit {
4982                                nc.rows.iter().skip(1).collect()
4983                            } else {
4984                                nc.rows
4985                                    .iter()
4986                                    .skip(1)
4987                                    .filter(|r| stack_matches_filter(&r.stack, filter))
4988                                    .collect()
4989                            }
4990                        };
4991                        let shell_visible = filter.is_empty()
4992                            || shell
4993                                .map(|s| stack_matches_filter(&s.stack, filter))
4994                                .unwrap_or(false)
4995                            || !contents.is_empty();
4996                        if !shell_visible && shell.is_some() {
4997                            continue;
4998                        }
4999                        any_visible = true;
5000                        lines.push(InventoryBrowserLine::Blank);
5001                        let lock_note = if nc.view.locked && nc.view.accessible {
5002                            "  unlocked with your key"
5003                        } else if nc.view.locked {
5004                            "  locked"
5005                        } else {
5006                            ""
5007                        };
5008                        lines.push(InventoryBrowserLine::Section(format!(
5009                            "— {} ({:.0}m away){lock_note} —",
5010                            nc.view.display_name, nc.distance_m
5011                        )));
5012                        if !nc.view.accessible {
5013                            lines.push(InventoryBrowserLine::Hint(
5014                                "  locked — need the matching key (l to try)".into(),
5015                            ));
5016                        } else if nc.rows.is_empty() {
5017                            lines.push(InventoryBrowserLine::Hint(
5018                                "  (empty — switch to On person, select an item, m to move in)"
5019                                    .into(),
5020                            ));
5021                        } else if let Some(shell_row) = shell {
5022                            self.push_browser_item(
5023                                &mut lines,
5024                                shell_row,
5025                                &mut global_idx,
5026                                target,
5027                                highlight,
5028                                &ambiguous_instance_keys,
5029                            );
5030                            for row in contents {
5031                                self.push_browser_item(
5032                                    &mut lines,
5033                                    row,
5034                                    &mut global_idx,
5035                                    target,
5036                                    highlight,
5037                                    &ambiguous_instance_keys,
5038                                );
5039                            }
5040                        }
5041                    }
5042                    if !any_visible {
5043                        lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
5044                        lines.push(InventoryBrowserLine::Hint(
5045                            "  (no matching items — clear filter with Esc)".into(),
5046                        ));
5047                    }
5048                }
5049            }
5050        }
5051        lines
5052    }
5053
5054    /// Destinations for picking up a placed chest/crate into inventory.
5055    pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
5056        let mut opts = Vec::new();
5057        opts.push(MoveOption {
5058            label: "Relocate…".into(),
5059            kind: MoveOptionKind::RelocatePlaced {
5060                container_id: container_id.to_string(),
5061            },
5062        });
5063        opts.push(MoveOption {
5064            label: "On your person (loose)".into(),
5065            kind: MoveOptionKind::PickupPlaced {
5066                container_id: container_id.to_string(),
5067                nest_location: flatland_protocol::InventoryLocation::Root,
5068                nest_parent_instance_id: None,
5069            },
5070        });
5071        for (slot, item) in &self.worn {
5072            if item.category.as_deref() != Some("container") {
5073                continue;
5074            }
5075            if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
5076                continue;
5077            }
5078            let Some(parent_id) = item.item_instance_id else {
5079                continue;
5080            };
5081            let shell_name = item
5082                .display_name
5083                .clone()
5084                .unwrap_or_else(|| item.template_id.clone());
5085            opts.push(MoveOption {
5086                label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
5087                kind: MoveOptionKind::PickupPlaced {
5088                    container_id: container_id.to_string(),
5089                    nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
5090                    nest_parent_instance_id: Some(parent_id),
5091                },
5092            });
5093            // Nested pouches inside the worn bag.
5094            Self::append_chest_pickup_nested(
5095                &mut opts,
5096                container_id,
5097                flatland_protocol::InventoryLocation::Worn { slot: *slot },
5098                item,
5099                &format!("in {shell_name}"),
5100            );
5101        }
5102        opts.push(MoveOption {
5103            label: "Cancel".into(),
5104            kind: MoveOptionKind::Cancel,
5105        });
5106        opts
5107    }
5108
5109    fn append_chest_pickup_nested(
5110        opts: &mut Vec<MoveOption>,
5111        container_id: &str,
5112        location: flatland_protocol::InventoryLocation,
5113        parent: &flatland_protocol::ItemStack,
5114        context: &str,
5115    ) {
5116        for child in &parent.contents {
5117            if child.category.as_deref() != Some("container") {
5118                continue;
5119            }
5120            if !Self::is_volume_container_stack(child) {
5121                continue;
5122            }
5123            // Skip placeable nested chests — relocating into another chest is not useful.
5124            if child.world_placeable == Some(true) {
5125                continue;
5126            }
5127            let Some(child_id) = child.item_instance_id else {
5128                continue;
5129            };
5130            let name = child
5131                .display_name
5132                .clone()
5133                .unwrap_or_else(|| child.template_id.clone());
5134            opts.push(MoveOption {
5135                label: format!("{name} ({context})"),
5136                kind: MoveOptionKind::PickupPlaced {
5137                    container_id: container_id.to_string(),
5138                    nest_location: location.clone(),
5139                    nest_parent_instance_id: Some(child_id),
5140                },
5141            });
5142            Self::append_chest_pickup_nested(
5143                opts,
5144                container_id,
5145                location.clone(),
5146                child,
5147                &format!("in {name}"),
5148            );
5149        }
5150    }
5151
5152    /// Build the "move to…" destination list for an item currently at `from`.
5153    pub fn move_destinations_for(
5154        &self,
5155        from: &flatland_protocol::InventoryLocation,
5156        from_parent_instance_id: Option<uuid::Uuid>,
5157        moving_instance_id: Option<uuid::Uuid>,
5158        moving_template_id: &str,
5159    ) -> Vec<MoveOption> {
5160        let mut opts = Vec::new();
5161        if *from != flatland_protocol::InventoryLocation::Root {
5162            opts.push(MoveOption {
5163                label: "On your person (loose)".into(),
5164                kind: MoveOptionKind::Move {
5165                    location: flatland_protocol::InventoryLocation::Root,
5166                    parent_instance_id: None,
5167                },
5168            });
5169        }
5170        for (slot, item) in &self.worn {
5171            if item.category.as_deref() != Some("container") {
5172                continue;
5173            }
5174            let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
5175            let shell_name = item
5176                .display_name
5177                .clone()
5178                .unwrap_or_else(|| item.template_id.clone());
5179
5180            // Backpack and other worn volume containers — store directly inside the shell.
5181            if *slot != BodySlot::Waist
5182                && item.item_instance_id != moving_instance_id
5183                && Self::is_volume_container_stack(item)
5184            {
5185                Self::push_move_destination(
5186                    &mut opts,
5187                    format!("{shell_name} (worn {})", body_slot_label(*slot)),
5188                    location.clone(),
5189                    item.item_instance_id,
5190                    from,
5191                    from_parent_instance_id,
5192                );
5193            }
5194
5195            // Belt loops only accept pouch attachments — not loose materials.
5196            if *slot == BodySlot::Waist
5197                && Self::attaches_to_belt_loop(moving_template_id)
5198                && item.item_instance_id != moving_instance_id
5199            {
5200                Self::push_move_destination(
5201                    &mut opts,
5202                    format!("{shell_name} (belt loop)"),
5203                    location.clone(),
5204                    item.item_instance_id,
5205                    from,
5206                    from_parent_instance_id,
5207                );
5208            }
5209
5210            let context = if *slot == BodySlot::Waist {
5211                format!("on {shell_name}")
5212            } else {
5213                format!("in {shell_name}")
5214            };
5215            Self::append_nested_container_destinations(
5216                &mut opts,
5217                location,
5218                item,
5219                &context,
5220                from,
5221                from_parent_instance_id,
5222                moving_instance_id,
5223            );
5224        }
5225        for nc in self.nearby_containers() {
5226            if !nc.view.accessible {
5227                continue;
5228            }
5229            let location = flatland_protocol::InventoryLocation::Placed {
5230                container_id: nc.view.id.clone(),
5231            };
5232            Self::push_move_destination(
5233                &mut opts,
5234                format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
5235                location,
5236                nc.view.item_instance_id,
5237                from,
5238                from_parent_instance_id,
5239            );
5240        }
5241        let allow_drop = moving_instance_id
5242            .map(|id| !self.hand_equipped_instance_ids().contains(&id))
5243            .unwrap_or(true)
5244            && moving_instance_id
5245                .and_then(|id| self.stack_for_instance(id))
5246                .map(|stack| {
5247                    !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
5248                })
5249                .unwrap_or(
5250                    moving_template_id != KEY_TEMPLATE
5251                        && moving_template_id != PROPERTY_DEED_TEMPLATE,
5252                );
5253        if allow_drop {
5254            opts.push(MoveOption {
5255                label: "Drop on the ground".into(),
5256                kind: MoveOptionKind::Drop,
5257            });
5258        }
5259        opts.push(MoveOption {
5260            label: "Cancel".into(),
5261            kind: MoveOptionKind::Cancel,
5262        });
5263        opts
5264    }
5265
5266    fn is_same_container_dest(
5267        dest_location: &flatland_protocol::InventoryLocation,
5268        dest_parent: Option<uuid::Uuid>,
5269        from: &flatland_protocol::InventoryLocation,
5270        from_parent: Option<uuid::Uuid>,
5271    ) -> bool {
5272        dest_location == from && dest_parent == from_parent
5273    }
5274
5275    fn push_move_destination(
5276        opts: &mut Vec<MoveOption>,
5277        label: String,
5278        location: flatland_protocol::InventoryLocation,
5279        parent_instance_id: Option<uuid::Uuid>,
5280        from: &flatland_protocol::InventoryLocation,
5281        from_parent_instance_id: Option<uuid::Uuid>,
5282    ) {
5283        if Self::is_same_container_dest(
5284            &location,
5285            parent_instance_id,
5286            from,
5287            from_parent_instance_id,
5288        ) {
5289            return;
5290        }
5291        opts.push(MoveOption {
5292            label,
5293            kind: MoveOptionKind::Move {
5294                location,
5295                parent_instance_id,
5296            },
5297        });
5298    }
5299
5300    fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
5301        stack.capacity_volume.is_some_and(|c| c > 0.0)
5302    }
5303
5304    fn attaches_to_belt_loop(template_id: &str) -> bool {
5305        matches!(template_id, "leather_pouch" | "dimensional_pouch")
5306    }
5307
5308    fn append_nested_container_destinations(
5309        opts: &mut Vec<MoveOption>,
5310        location: flatland_protocol::InventoryLocation,
5311        container: &flatland_protocol::ItemStack,
5312        context: &str,
5313        from: &flatland_protocol::InventoryLocation,
5314        from_parent_instance_id: Option<uuid::Uuid>,
5315        moving_instance_id: Option<uuid::Uuid>,
5316    ) {
5317        for child in &container.contents {
5318            if Self::is_volume_container_stack(child)
5319                && child.item_instance_id != moving_instance_id
5320            {
5321                let name = child
5322                    .display_name
5323                    .clone()
5324                    .unwrap_or_else(|| child.template_id.clone());
5325                Self::push_move_destination(
5326                    opts,
5327                    format!("{name} ({context})"),
5328                    location.clone(),
5329                    child.item_instance_id,
5330                    from,
5331                    from_parent_instance_id,
5332                );
5333            }
5334            let nested_context = format!(
5335                "in {}",
5336                child.display_name.as_deref().unwrap_or(&child.template_id)
5337            );
5338            Self::append_nested_container_destinations(
5339                opts,
5340                location.clone(),
5341                child,
5342                &nested_context,
5343                from,
5344                from_parent_instance_id,
5345                moving_instance_id,
5346            );
5347        }
5348    }
5349
5350    fn clamp_inventory_indices(&mut self) {
5351        let n = self.inventory_selectable_rows().len();
5352        self.inventory_menu_index = if n == 0 {
5353            0
5354        } else {
5355            self.inventory_menu_index.min(n - 1)
5356        };
5357        if let Some(picker) = &self.move_picker {
5358            let pn = picker.options.len();
5359            self.move_picker_index = if pn == 0 {
5360                0
5361            } else {
5362                self.move_picker_index.min(pn - 1)
5363            };
5364        }
5365    }
5366
5367    /// Drop interior map layers whenever the player is outdoors.
5368    /// Restores outdoor z-bands saved before [`Self::sync_interior_z_bands`] overwrote them.
5369    /// Tick deltas never refresh `z_platforms`; without restore, stale interior platforms
5370    /// force `build_grid` into a full-map z pass on every click until client restart.
5371    fn sync_interior_map_context(&mut self) {
5372        if self.effective_inside_building().is_none() {
5373            self.interior_map = None;
5374            if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
5375                self.z_platforms = platforms;
5376                self.z_transitions = transitions;
5377            }
5378            return;
5379        }
5380        self.sync_interior_z_bands();
5381    }
5382
5383    /// While indoors, z-bands come from the active interior blueprint (multi-floor stairs).
5384    fn sync_interior_z_bands(&mut self) {
5385        if self.effective_inside_building().is_some() {
5386            if let Some(map) = &self.interior_map {
5387                if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
5388                    if self.z_bands_outdoor_backup.is_none() {
5389                        self.z_bands_outdoor_backup = Some((
5390                            std::mem::take(&mut self.z_platforms),
5391                            std::mem::take(&mut self.z_transitions),
5392                        ));
5393                    }
5394                    self.z_platforms = map.z_platforms.clone();
5395                    self.z_transitions = map.z_transitions.clone();
5396                }
5397            }
5398        }
5399    }
5400
5401    fn apply_snapshot_fields(
5402        &mut self,
5403        snapshot: &flatland_protocol::Snapshot,
5404        entity_id: EntityId,
5405    ) {
5406        self.tick = snapshot.tick;
5407        self.chunk_rev = snapshot.chunk_rev;
5408        self.content_rev = snapshot.content_rev;
5409        self.publish_rev = snapshot.publish_rev;
5410        self.resource_nodes = snapshot.resource_nodes.clone();
5411        self.ground_drops = snapshot.ground_drops.clone();
5412        self.placed_containers = snapshot.placed_containers.clone();
5413        self.world_x0 = snapshot.world_x0;
5414        self.world_y0 = snapshot.world_y0;
5415        self.world_width_m = snapshot.world_width_m;
5416        self.world_height_m = snapshot.world_height_m;
5417        self.world_clock = snapshot.world_clock;
5418        self.terrain_zones = snapshot.terrain_zones.clone();
5419        self.z_platforms = snapshot.z_platforms.clone();
5420        self.z_transitions = snapshot.z_transitions.clone();
5421        // Snapshot is authoritative for the current context; drop any prior enter/leave stash.
5422        self.z_bands_outdoor_backup = None;
5423        self.buildings = snapshot.buildings.clone();
5424        self.doors = snapshot.doors.clone();
5425        self.interior_map = snapshot.interior_map.clone();
5426        self.npcs = snapshot.npcs.clone();
5427        self.blueprints = snapshot.blueprints.clone();
5428        self.building_materials = snapshot.building_materials.clone();
5429        self.sync_inventory_from_stacks(&snapshot.inventory);
5430        self.player = snapshot
5431            .entities
5432            .iter()
5433            .find(|e| e.id == entity_id)
5434            .cloned();
5435        self.entities = snapshot.entities.clone();
5436        self.quest_log = snapshot.quest_log.clone();
5437        self.apply_hired_workers(snapshot.hired_workers.clone());
5438        self.interactables = snapshot.interactables.clone();
5439        self.ledger = snapshot.ledger.clone();
5440        self.career = snapshot.career.clone();
5441        self.combat_fx = snapshot.combat_fx.clone();
5442        self.ground_hazards = snapshot.ground_hazards.clone();
5443        self.property_zones = snapshot.property_zones.clone();
5444        self.tax_zones = snapshot.tax_zones.clone();
5445        self.growth_zones = snapshot.growth_zones.clone();
5446        self.biome_zones = snapshot.biome_zones.clone();
5447        self.terrain_kind_nav = snapshot.terrain_kind_nav.clone();
5448        self.property_plots = snapshot.property_plots.clone();
5449        self.property_plot_settings = snapshot.property_plot_settings.clone();
5450        self.sync_item_catalog(&snapshot.item_catalog);
5451        // If welcome/reconnect lands indoors, seed an empty outdoor restore target so
5452        // sync_interior_z_bands does not stash interior snapshot bands as "outdoor".
5453        if self.effective_inside_building().is_some() {
5454            self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
5455        }
5456        self.sync_interior_map_context();
5457        self.refresh_whisper_range();
5458        self.sync_gameplay_audio();
5459    }
5460
5461    /// Re-derive inventory selection state after a snapshot/tick — chests that
5462    /// went out of range or got locked simply vanish from the row list, and the
5463    /// move picker (if any) closes once its item is no longer reachable.
5464    fn refresh_inventory_ui(&mut self) {
5465        if let Some(picker) = &self.move_picker {
5466            let instance_id = picker.item_instance_id;
5467            let still_exists = self
5468                .inventory_selectable_rows()
5469                .iter()
5470                .any(|r| r.stack.item_instance_id == Some(instance_id));
5471            if !still_exists {
5472                self.move_picker = None;
5473                self.show_move_picker = false;
5474            }
5475        }
5476        if let Some(picker) = &self.destroy_picker {
5477            let instance_id = picker.item_instance_id;
5478            let still_exists = self
5479                .inventory_selectable_rows()
5480                .iter()
5481                .any(|r| r.stack.item_instance_id == Some(instance_id));
5482            if !still_exists {
5483                self.destroy_picker = None;
5484                self.show_destroy_picker = false;
5485                self.destroy_confirm_pending = false;
5486            }
5487        }
5488        self.clamp_inventory_indices();
5489    }
5490
5491    /// Replace the hired-worker list from a snapshot/delta.
5492    ///
5493    /// Keeps a stable sort and remaps `workers_menu_index` by instance id so the
5494    /// selected worker (and its step line) does not jump when the server rebuilds
5495    /// the list.
5496    fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
5497        let selected_id = self
5498            .hired_workers
5499            .get(self.workers_menu_index)
5500            .map(|w| w.instance_id.clone());
5501        let previous_worker_ids: HashSet<String> = self
5502            .hired_workers
5503            .iter()
5504            .map(|worker| worker.instance_id.clone())
5505            .collect();
5506        workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
5507        let now = Instant::now();
5508        let saw_new_worker = workers
5509            .iter()
5510            .any(|worker| !previous_worker_ids.contains(&worker.instance_id));
5511        for worker in &workers {
5512            let was_hit = self
5513                .hired_workers
5514                .iter()
5515                .find(|previous| previous.instance_id == worker.instance_id)
5516                .is_some_and(|previous| {
5517                    matches!(worker.mode, flatland_protocol::WorkerModeView::Defender)
5518                        && worker.vitals.health_pct + 0.01 < previous.vitals.health_pct
5519                });
5520            if was_hit {
5521                self.worker_health_ring_until
5522                    .insert(worker.entity_id, now + WORKER_HEALTH_RING_HOLD);
5523            }
5524        }
5525        let worker_entity_ids: HashSet<EntityId> =
5526            workers.iter().map(|worker| worker.entity_id).collect();
5527        self.worker_health_ring_until
5528            .retain(|entity_id, _| worker_entity_ids.contains(entity_id));
5529        for w in &workers {
5530            let prev_err = self
5531                .hired_workers
5532                .iter()
5533                .find(|p| p.instance_id == w.instance_id)
5534                .and_then(|p| p.last_error.as_deref());
5535            let new_err = w.last_error.as_deref();
5536            if new_err != prev_err {
5537                if let Some(err) = new_err {
5538                    if !worker_error_is_transient(err) {
5539                        self.push_log(format!("Worker {}: {err}", w.label));
5540                    }
5541                }
5542            }
5543        }
5544        let mut next_display = BTreeMap::new();
5545        let mut next_errors = BTreeMap::new();
5546        for w in &workers {
5547            let mut sticky = self
5548                .worker_step_display
5549                .remove(&w.instance_id)
5550                .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
5551            sticky.observe(&w.step_label, now);
5552            next_display.insert(w.instance_id.clone(), sticky);
5553
5554            let mut err_sticky = self
5555                .worker_error_display
5556                .remove(&w.instance_id)
5557                .unwrap_or_default();
5558            err_sticky.observe(w.last_error.as_deref(), now);
5559            if err_sticky.shown(now).is_some() {
5560                next_errors.insert(w.instance_id.clone(), err_sticky);
5561            }
5562        }
5563        self.worker_step_display = next_display;
5564        self.worker_error_display = next_errors;
5565        self.hired_workers = workers;
5566        if saw_new_worker {
5567            self.pending_worker_hire_since = None;
5568        }
5569        self.sync_worker_take_picker_from_hired();
5570        if let Some(id) = selected_id {
5571            if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
5572                self.workers_menu_index = idx;
5573                return;
5574            }
5575        }
5576        if self.workers_menu_index >= self.hired_workers.len() {
5577            self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
5578        }
5579    }
5580
5581    /// Keep the take-from-worker sheet in sync after hired-worker inventory updates.
5582    fn sync_worker_take_picker_from_hired(&mut self) {
5583        if !self.show_worker_take_picker {
5584            return;
5585        }
5586        let Some(picker) = self.worker_take_picker.clone() else {
5587            return;
5588        };
5589        let Some(worker) = self
5590            .hired_workers
5591            .iter()
5592            .find(|w| w.instance_id == picker.worker_instance_id)
5593            .cloned()
5594        else {
5595            self.show_worker_take_picker = false;
5596            self.worker_take_picker = None;
5597            self.worker_take_picker_index = 0;
5598            return;
5599        };
5600        let options: Vec<WorkerGiveOption> = worker
5601            .inventory
5602            .iter()
5603            .filter_map(|stack| {
5604                let item_instance_id = stack.item_instance_id?;
5605                let label = stack
5606                    .display_name
5607                    .clone()
5608                    .unwrap_or_else(|| stack.template_id.clone());
5609                let label = if stack.quantity > 1 {
5610                    format!("{label} ×{}", stack.quantity)
5611                } else {
5612                    label
5613                };
5614                Some(WorkerGiveOption {
5615                    item_instance_id,
5616                    label,
5617                    quantity: stack.quantity,
5618                    template_id: stack.template_id.clone(),
5619                })
5620            })
5621            .collect();
5622        if options.is_empty() {
5623            self.show_worker_take_picker = false;
5624            self.worker_take_picker = None;
5625            self.worker_take_picker_index = 0;
5626            return;
5627        }
5628        let prev_id = picker
5629            .options
5630            .get(self.worker_take_picker_index)
5631            .map(|o| o.item_instance_id);
5632        let idx = prev_id
5633            .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
5634            .unwrap_or(0)
5635            .min(options.len().saturating_sub(1));
5636        let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
5637        let quantity = picker.quantity.clamp(1, max_qty);
5638        self.worker_take_picker_index = idx;
5639        self.worker_take_picker = Some(WorkerTakePicker {
5640            worker_instance_id: picker.worker_instance_id,
5641            worker_label: picker.worker_label,
5642            options,
5643            quantity,
5644        });
5645    }
5646
5647    /// Held coarse step label for the workers menu (`step:` line).
5648    pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
5649        self.worker_step_display
5650            .get(worker_instance_id)
5651            .map(|s| s.shown.as_str())
5652            .or_else(|| {
5653                self.hired_workers
5654                    .iter()
5655                    .find(|w| w.instance_id == worker_instance_id)
5656                    .map(|w| w.step_label.as_str())
5657            })
5658            .unwrap_or("")
5659    }
5660
5661    /// Held error line for workers UI (detail + compact).
5662    pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
5663        let now = Instant::now();
5664        self.worker_error_display
5665            .get(worker_instance_id)
5666            .and_then(|s| s.shown(now))
5667            .or_else(|| {
5668                self.hired_workers
5669                    .iter()
5670                    .find(|w| w.instance_id == worker_instance_id)
5671                    .and_then(|w| w.last_error.as_deref())
5672                    .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
5673            })
5674            .filter(|e| !worker_error_is_hud_noise(e))
5675    }
5676
5677    fn apply_combat_hud(&mut self, combat: &CombatHud) {
5678        self.in_combat = combat.in_combat;
5679        self.auto_attack = combat.auto_attack;
5680        self.combat_has_los = combat.has_los;
5681        self.attack_cd_ticks = combat.attack_cd_ticks;
5682        self.gcd_ticks = combat.gcd_ticks;
5683        self.weapon_ability_id = combat.ability_id.clone();
5684        self.mainhand_template_id = combat.mainhand_template_id.clone();
5685        self.mainhand_label = combat.mainhand_label.clone();
5686        self.mainhand_instance_id = combat.mainhand_instance_id;
5687        self.offhand_template_id = combat.offhand_template_id.clone();
5688        self.offhand_label = combat.offhand_label.clone();
5689        self.offhand_instance_id = combat.offhand_instance_id;
5690        self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
5691            1
5692        } else {
5693            combat.mainhand_hand_slots
5694        };
5695        self.defense = combat.defense.clone();
5696        self.worn = combat.worn.iter().cloned().collect();
5697        self.carry_mass = combat.carry_mass;
5698        self.carry_mass_max = combat.carry_mass_max;
5699        self.encumbrance = combat.encumbrance;
5700        self.move_speed_mps = combat.move_speed_mps;
5701        self.move_speed_mult = combat.move_speed_mult;
5702        self.cast_progress = combat.cast.clone();
5703        self.timed_channel = combat.timed_channel.clone();
5704        if self.active_craft_channel().is_none() {
5705            self.craft_channel_blueprint_id = None;
5706        }
5707        self.plot_build_offer = combat.plot_build.clone();
5708        self.ability_cooldowns = combat.ability_cooldowns.clone();
5709        self.blocking_active = combat.blocking_active;
5710        self.max_target_slots = combat.max_target_slots.max(1);
5711        self.combat_slots = combat.slots.clone();
5712        self.rotation_presets = combat.rotation_presets.clone();
5713        self.known_abilities = combat.known_abilities.clone();
5714        self.ability_meta = combat
5715            .ability_meta
5716            .iter()
5717            .cloned()
5718            .map(|meta| (meta.id.clone(), meta))
5719            .collect();
5720        self.ability_mastery = combat
5721            .ability_mastery
5722            .iter()
5723            .cloned()
5724            .map(|row| (row.ability_id.clone(), row))
5725            .collect();
5726        self.hotbar = combat.hotbar.clone();
5727        self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
5728        self.keychain_stacks = combat.keychain.clone();
5729        self.whisper_pouch_stacks = combat.whisper_pouch.clone();
5730        self.combat_target_detail = combat.target.clone();
5731        self.statuses = combat.statuses.clone();
5732        self.combat_target = combat.target_entity_id;
5733        if combat.progression_xp_base > 0.0 {
5734            self.progression_curve = Some(flatland_protocol::ProgressionCurve {
5735                baseline_display: combat.progression_baseline,
5736                xp_base: combat.progression_xp_base,
5737                xp_growth: combat.progression_xp_growth,
5738            });
5739        }
5740        if let Some(xp) = &combat.progression_xp {
5741            if let Some(player) = &mut self.player {
5742                player.progression_xp = Some(xp.clone());
5743                if let Some(attrs) = combat.attributes {
5744                    player.attributes = Some(attrs);
5745                }
5746                if let Some(skills) = &combat.skills {
5747                    player.skills = Some(skills.clone());
5748                }
5749            }
5750        }
5751        if let Some(label) = &combat.target_label {
5752            self.combat_target_label = Some(label.clone());
5753        } else if let Some(id) = combat.target_entity_id {
5754            self.combat_target_label = self
5755                .entities
5756                .iter()
5757                .find(|e| e.id == id)
5758                .map(|e| e.label.clone())
5759                .or_else(|| self.combat_target_label.clone());
5760        }
5761        self.refresh_inventory_ui();
5762    }
5763
5764    /// Entity id assigned to a combat slot (from server HUD).
5765    pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
5766        self.combat_slots
5767            .iter()
5768            .find(|s| s.slot_index == slot)
5769            .and_then(|s| s.target_entity_id)
5770            .or_else(|| if slot == 1 { self.combat_target } else { None })
5771    }
5772
5773    /// True if `ability_id` may be cast at a free ground point (`aim_mode` `ground` or `either`).
5774    pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
5775        self.ability_meta
5776            .get(ability_id)
5777            .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
5778            // Until HUD meta syncs, still allow ground cast when an aim point is armed —
5779            // the server validates aim_mode.
5780            .unwrap_or(self.ground_target.is_some())
5781    }
5782
5783    /// True if `ability_id` requires a free ground point (`aim_mode` `ground`).
5784    pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
5785        self.ability_meta
5786            .get(ability_id)
5787            .map(|meta| meta.aim_mode == "ground")
5788            .unwrap_or(false)
5789    }
5790
5791    /// True when the ability may be placed in an auto-rotation preset.
5792    /// Missing meta (pre-sync) defaults to eligible; server still validates.
5793    pub fn ability_auto_rotation_eligible(&self, ability_id: &str) -> bool {
5794        self.ability_meta
5795            .get(ability_id)
5796            .map(|meta| meta.auto_rotation_eligible)
5797            .unwrap_or(true)
5798    }
5799
5800    /// Set the local free-aim ground target (world meters, Shift+click on open ground).
5801    pub fn set_ground_target(&mut self, x: f32, y: f32) {
5802        self.ground_target = Some((x, y, 0.0));
5803    }
5804
5805    /// Clear the local free-aim ground target.
5806    pub fn clear_ground_target(&mut self) {
5807        self.ground_target = None;
5808    }
5809
5810    /// Ability bound to hotbar key `1`–`9` from server-persisted state.
5811    /// May be an ability id or `item:<template_id>` consumable binding.
5812    pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
5813        if !(1..=9).contains(&slot_1_to_9) {
5814            return None;
5815        }
5816        self.hotbar
5817            .get((slot_1_to_9 - 1) as usize)
5818            .and_then(|a| a.as_deref())
5819            .filter(|id| !id.is_empty())
5820    }
5821
5822    /// Short label for a hotbar slot (ability id, or consumable name × qty).
5823    pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
5824        let binding = self.hotbar_ability(slot_1_to_9)?;
5825        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
5826            let name = self
5827                .inventory_hints
5828                .get(template_id)
5829                .map(|h| h.display_name.as_str())
5830                .unwrap_or(template_id);
5831            let qty = self.inventory.get(template_id).copied().unwrap_or(0);
5832            Some(format!("{name}×{qty}"))
5833        } else {
5834            Some(binding.to_string())
5835        }
5836    }
5837
5838    /// Known abilities plus current weapon ability (for loadout / rotation pickers).
5839    pub fn loadout_ability_choices(&self) -> Vec<String> {
5840        let mut out = self.known_abilities.clone();
5841        let weapon = self.weapon_ability_id.trim();
5842        if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
5843            out.push(weapon.to_string());
5844        }
5845        out
5846    }
5847
5848    /// Hotbar bind candidates: learned abilities, then on-person consumables.
5849    pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
5850        let mut out = Vec::new();
5851        for ability in self.loadout_ability_choices() {
5852            let meta = if ability == self.weapon_ability_id {
5853                Some("weapon".into())
5854            } else {
5855                None
5856            };
5857            out.push(LoadoutHotbarChoice {
5858                binding: ability.clone(),
5859                label: ability,
5860                meta,
5861            });
5862        }
5863        let mut consumables: Vec<(String, String, u32)> = Vec::new();
5864        for stack in &self.inventory_stacks {
5865            if Self::stack_is_item_grant(stack) {
5866                continue;
5867            }
5868            if Self::stack_is_blueprint_scroll(stack) {
5869                continue;
5870            }
5871            if self.inventory_item_category(&stack.template_id) != Some("consumable")
5872                && !Self::stack_is_serving(stack)
5873            {
5874                continue;
5875            }
5876            let qty = stack.quantity.max(1);
5877            if let Some((_, _, existing)) = consumables
5878                .iter_mut()
5879                .find(|(id, _, _)| id == &stack.template_id)
5880            {
5881                *existing = existing.saturating_add(qty);
5882            } else {
5883                let label = stack
5884                    .display_name
5885                    .clone()
5886                    .or_else(|| {
5887                        self.inventory_hints
5888                            .get(&stack.template_id)
5889                            .map(|h| h.display_name.clone())
5890                    })
5891                    .unwrap_or_else(|| stack.template_id.clone());
5892                consumables.push((stack.template_id.clone(), label, qty));
5893            }
5894        }
5895        consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
5896        for (template_id, label, qty) in consumables {
5897            out.push(LoadoutHotbarChoice {
5898                binding: flatland_protocol::hotbar_consumable_binding(&template_id),
5899                label: format!("{label} ×{qty}"),
5900                meta: Some("use".into()),
5901            });
5902        }
5903        out
5904    }
5905
5906    /// Hostile wildlife / monsters for T1 (`Tab`).
5907    pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
5908        self.combat_candidates()
5909    }
5910
5911    /// Allies first, then monsters, for T2 (`Shift+Tab`). Includes self for heals.
5912    pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
5913        let (px, py) = self.player_position();
5914        let dist = |id: EntityId| {
5915            self.entities
5916                .iter()
5917                .find(|e| e.id == id)
5918                .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
5919                .unwrap_or(f32::MAX)
5920        };
5921
5922        let mut allies = Vec::new();
5923        // Self first — heal_touch on T2.
5924        if let Some(me) = self.player.as_ref() {
5925            let alive = me
5926                .vitals
5927                .as_ref()
5928                .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
5929                .unwrap_or(true);
5930            if alive {
5931                allies.push((self.entity_id, "Yourself".into()));
5932            }
5933        }
5934        for entity in &self.entities {
5935            if entity.id == self.entity_id {
5936                continue;
5937            }
5938            if entity.vitals.is_some() {
5939                let alive = entity
5940                    .vitals
5941                    .as_ref()
5942                    .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
5943                    .unwrap_or(true);
5944                if alive {
5945                    allies.push((entity.id, entity.label.clone()));
5946                }
5947            }
5948        }
5949        allies.sort_by(|(a, _), (b, _)| {
5950            if *a == self.entity_id {
5951                return std::cmp::Ordering::Less;
5952            }
5953            if *b == self.entity_id {
5954                return std::cmp::Ordering::Greater;
5955            }
5956            dist(*a)
5957                .partial_cmp(&dist(*b))
5958                .unwrap_or(std::cmp::Ordering::Equal)
5959        });
5960
5961        let mut monsters = self.combat_candidates();
5962        monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
5963        allies.into_iter().chain(monsters).collect()
5964    }
5965
5966    fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
5967        match slot_index {
5968            2 => self.t2_candidates(),
5969            _ => self.t1_candidates(),
5970        }
5971    }
5972
5973    /// Nearest combat candidate within `radius_m` of world click (gfx click-to-target).
5974    pub fn pick_combat_target_at(
5975        &self,
5976        wx: f32,
5977        wy: f32,
5978        slot_index: u8,
5979        radius_m: f32,
5980    ) -> Option<(EntityId, String)> {
5981        let mut best: Option<(f32, EntityId, String)> = None;
5982        for (id, label) in self.candidates_for_slot(slot_index) {
5983            let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
5984                // NPCs may only appear in NpcView — fall back to npc list coords.
5985                if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
5986                    let d = distance(wx, wy, npc.x, npc.y);
5987                    if d <= radius_m {
5988                        best = match best {
5989                            Some((bd, _, _)) if bd <= d => best,
5990                            _ => Some((d, id, label)),
5991                        };
5992                    }
5993                }
5994                continue;
5995            };
5996            let d = distance(
5997                wx,
5998                wy,
5999                entity.transform.position.x,
6000                entity.transform.position.y,
6001            );
6002            if d <= radius_m {
6003                best = match best {
6004                    Some((bd, _, _)) if bd <= d => best,
6005                    _ => Some((d, id, label)),
6006                };
6007            }
6008        }
6009        best.map(|(_, id, label)| (id, label))
6010    }
6011
6012    /// Full client reset after Welcome (initial connect or reconnect).
6013    pub(crate) fn restore_from_welcome(
6014        &mut self,
6015        session_id: SessionId,
6016        entity_id: EntityId,
6017        snapshot: &flatland_protocol::Snapshot,
6018    ) {
6019        self.clear_harvest_state();
6020        self.disconnect_reason = None;
6021        self.show_stats = false;
6022        self.show_craft_menu = false;
6023        self.show_shop_menu = false;
6024        self.shop_catalog = None;
6025        self.show_inventory_menu = false;
6026        self.session_id = session_id;
6027        self.entity_id = entity_id;
6028        self.connected = true;
6029        self.apply_snapshot_fields(snapshot, entity_id);
6030        if let Some(combat) = &snapshot.combat {
6031            self.apply_combat_hud(combat);
6032            let stacks = self.inventory_stacks.clone();
6033            self.sync_inventory_from_stacks(&stacks);
6034        }
6035    }
6036
6037    fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
6038        self.tick = delta.tick;
6039        self.world_clock = delta.world_clock;
6040
6041        // Degenerate AOI tick (observer missing server-side): keep welcome snapshot layers.
6042        if delta.entities.is_empty() {
6043            self.ground_drops = delta.ground_drops.clone();
6044            self.combat_fx = delta.combat_fx.clone();
6045            self.ground_hazards = delta.ground_hazards.clone();
6046            self.property_plots = delta.property_plots.clone();
6047            self.apply_terrain_overlays(&delta.terrain_overlays);
6048            if let Some(combat) = &delta.combat {
6049                self.apply_combat_hud(combat);
6050                let stacks = self.inventory_stacks.clone();
6051                self.sync_inventory_from_stacks(&stacks);
6052            }
6053            // Peer vanished from AOI — drop directed whisper.
6054            self.refresh_whisper_range();
6055            self.sync_gameplay_audio();
6056            return;
6057        }
6058        if !delta.buildings.is_empty() {
6059            self.buildings = delta.buildings.clone();
6060        }
6061        if !delta.blueprints.is_empty() {
6062            self.blueprints = delta.blueprints.clone();
6063        }
6064        if !delta.building_materials.is_empty() {
6065            self.building_materials = delta.building_materials.clone();
6066        }
6067        self.sync_inventory_from_stacks(&delta.inventory);
6068
6069        if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
6070            self.player = Some(updated.clone());
6071        }
6072        self.entities = delta.entities.clone();
6073        if self.player.is_none() {
6074            self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
6075        }
6076
6077        self.sync_interior_map_context();
6078
6079        // Static world layers: ticks often omit these (indoors, or unchanged).
6080        // Never wipe the welcome snapshot with an empty vec — except when indoors,
6081        // where empty means "no nodes in this building" (clear outdoor leftovers).
6082        if !delta.resource_nodes.is_empty() {
6083            self.resource_nodes = delta.resource_nodes.clone();
6084        } else if delta.interior_map.is_some() || self.effective_inside_building().is_some() {
6085            self.resource_nodes = delta.resource_nodes.clone();
6086        }
6087        self.ground_drops = delta.ground_drops.clone();
6088        // Always replace — empty indoors means no chests in this building (do not keep outdoor ghosts).
6089        self.placed_containers = delta.placed_containers.clone();
6090        if !delta.doors.is_empty() {
6091            self.doors = delta.doors.clone();
6092        }
6093        if self.effective_inside_building().is_some() {
6094            if let Some(map) = &delta.interior_map {
6095                self.interior_map = Some(map.clone());
6096            }
6097        } else {
6098            self.interior_map = None;
6099        }
6100        self.sync_interior_z_bands();
6101        // Always replace NPC AOI — empty means none in range (do not keep ghosts).
6102        self.npcs = delta.npcs.clone();
6103        if !delta.quest_log.is_empty() {
6104            self.quest_log = delta.quest_log.clone();
6105        }
6106        self.apply_hired_workers(delta.hired_workers.clone());
6107        if !delta.interactables.is_empty() {
6108            self.interactables = delta.interactables.clone();
6109        }
6110        if delta.ledger.is_some() {
6111            self.ledger = delta.ledger.clone();
6112        }
6113        if delta.career.is_some() {
6114            self.career = delta.career.clone();
6115        }
6116        self.combat_fx = delta.combat_fx.clone();
6117        self.ground_hazards = delta.ground_hazards.clone();
6118        // Empty = unchanged (server may stagger plot rebuilds); non-empty replaces.
6119        if !delta.property_plots.is_empty() {
6120            self.property_plots = delta.property_plots.clone();
6121        }
6122        self.apply_terrain_overlays(&delta.terrain_overlays);
6123        if let Some(combat) = &delta.combat {
6124            self.apply_combat_hud(combat);
6125            let stacks = self.inventory_stacks.clone();
6126            self.sync_inventory_from_stacks(&stacks);
6127        } else {
6128            self.refresh_inventory_ui();
6129        }
6130        self.refresh_whisper_range();
6131        self.sync_gameplay_audio();
6132    }
6133
6134    /// Merge runtime cultivate overlays into `terrain_zones` (ids prefixed `rt:`).
6135    /// Always replaces prior `rt:` entries with the server's near-set (empty = none nearby).
6136    fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
6137        self.terrain_zones.retain(|z| !z.id.starts_with("rt:"));
6138        self.terrain_zones.extend(overlays.iter().cloned());
6139    }
6140
6141    /// End directed whisper when the peer is farther than interact range (or missing).
6142    /// Each client runs this locally so both players drop whisper mode when either walks away.
6143    fn refresh_whisper_range(&mut self) {
6144        let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
6145            return;
6146        };
6147        let (px, py) = self.player_position();
6148        let in_range = self.entities.iter().any(|e| {
6149            e.id == peer
6150                && distance(px, py, e.transform.position.x, e.transform.position.y)
6151                    <= INTERACTION_RADIUS_M
6152        });
6153        if !in_range {
6154            self.social_chat.cancel_whisper_out_of_range();
6155        }
6156    }
6157
6158    /// Wildlife and other combat targets visible in AOI (`NpcView.entity_id`).
6159    pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
6160        let (px, py) = self.player_position();
6161        let mut out = Vec::new();
6162        for npc in &self.npcs {
6163            let Some(eid) = npc.entity_id else {
6164                continue;
6165            };
6166            let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
6167            let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
6168            if alive && has_hp {
6169                out.push((eid, npc.label.clone()));
6170            }
6171        }
6172        out.sort_by(|(a_id, a_label), (b_id, b_label)| {
6173            let dist = |id: EntityId| {
6174                self.entities
6175                    .iter()
6176                    .find(|e| e.id == id)
6177                    .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
6178                    .unwrap_or(f32::MAX)
6179            };
6180            dist(*a_id)
6181                .partial_cmp(&dist(*b_id))
6182                .unwrap_or(std::cmp::Ordering::Equal)
6183                .then_with(|| a_label.cmp(b_label))
6184                .then_with(|| a_id.cmp(b_id))
6185        });
6186        out
6187    }
6188
6189    pub fn refresh_combat_target_label(&mut self) {
6190        let Some(id) = self.combat_target else {
6191            return;
6192        };
6193        if let Some((_, label)) = self
6194            .combat_candidates()
6195            .into_iter()
6196            .find(|(eid, _)| *eid == id)
6197        {
6198            self.combat_target_label = Some(label);
6199        } else if let Some(label) = self
6200            .entities
6201            .iter()
6202            .find(|e| e.id == id)
6203            .map(|e| e.label.clone())
6204        {
6205            self.combat_target_label = Some(label);
6206        }
6207    }
6208
6209    pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
6210        self.quest_log
6211            .iter()
6212            .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
6213            .collect()
6214    }
6215
6216    /// True when the observer has at least one free lodging slot (max workers = beds).
6217    pub fn has_worker_lodging(&self) -> bool {
6218        self.free_worker_lodging_slots() > 0
6219    }
6220
6221    /// Owned lodging capacity minus currently hired workers.
6222    pub fn free_worker_lodging_slots(&self) -> i64 {
6223        let slots: u32 = self
6224            .placed_containers
6225            .iter()
6226            .filter(|c| match (self.character_id, c.owner_character_id) {
6227                (Some(me), Some(owner)) => me == owner,
6228                (Some(_), None) => false,
6229                (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
6230            })
6231            .map(|c| c.worker_lodging_capacity.unwrap_or(0))
6232            .sum();
6233        let used = self.hired_workers.len() as u32;
6234        slots as i64 - used as i64
6235    }
6236
6237    /// Display names of hired workers assigned to this lodging container.
6238    pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
6239        let mut names: Vec<String> = self
6240            .hired_workers
6241            .iter()
6242            .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
6243            .map(|w| w.label.clone())
6244            .collect();
6245        names.sort();
6246        names
6247    }
6248
6249    /// Compact lodging occupancy for labels: `"Elda, Ana"`, `"vacant"`, or `""` if not lodging.
6250    pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
6251        let is_lodging = self
6252            .placed_containers
6253            .iter()
6254            .find(|c| c.id == container_id)
6255            .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
6256        if !is_lodging {
6257            return None;
6258        }
6259        let names = self.lodging_occupant_labels(container_id);
6260        Some(if names.is_empty() {
6261            "vacant".into()
6262        } else {
6263            names.join(", ")
6264        })
6265    }
6266
6267    /// True when a lodging bed has at least one hired worker assigned.
6268    pub fn lodging_is_occupied(&self, container_id: &str) -> bool {
6269        matches!(
6270            self.lodging_occupancy_label(container_id),
6271            Some(label) if label != "vacant"
6272        )
6273    }
6274
6275    pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
6276        self.quest_log
6277            .iter()
6278            .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
6279            .or_else(|| {
6280                self.quest_log
6281                    .iter()
6282                    .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
6283            })
6284    }
6285
6286    /// True when a lockable exterior door is within interact range (prefer over loadout `l`).
6287    pub fn nearby_lockable_door(&self) -> bool {
6288        let (px, py) = self.player_position();
6289        self.doors
6290            .iter()
6291            .any(|d| d.lock_id.is_some() && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M)
6292    }
6293
6294    /// True when an open player-house door is in range for Enter-to-go-inside.
6295    pub fn nearby_open_player_door(&self) -> bool {
6296        if self.effective_inside_building().is_some() {
6297            return false;
6298        }
6299        let (px, py) = self.player_position();
6300        self.doors.iter().any(|d| {
6301            if !d.open || d.locked {
6302                return false;
6303            }
6304            let player_house = self
6305                .buildings
6306                .iter()
6307                .find(|b| b.id == d.building_id)
6308                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6309            player_house && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M
6310        })
6311    }
6312
6313    /// True when inside a player house near the exterior portal (Enter exits freely).
6314    pub fn nearby_player_exit_door(&self) -> bool {
6315        let Some(bid) = self.effective_inside_building() else {
6316            return false;
6317        };
6318        let (px, py) = self.player_position();
6319        self.doors.iter().any(|d| {
6320            if d.building_id != bid || d.portal.is_none() {
6321                return false;
6322            }
6323            let player_house = self
6324                .buildings
6325                .iter()
6326                .find(|b| b.id == d.building_id)
6327                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6328            player_house && (d.x - px).hypot(d.y - py) <= 1.5
6329        })
6330    }
6331
6332    /// Nearest interactable target for `f` (doors, NPCs, quest boards).
6333    pub fn nearest_interact_target(&self) -> Option<String> {
6334        let (px, py) = self.player_position();
6335        let inside = self.effective_inside_building();
6336
6337        #[derive(Clone, Copy, PartialEq, Eq)]
6338        enum Kind {
6339            Player,
6340            Npc,
6341            HiredWorker,
6342            QuestBoard,
6343            ExitDoor,
6344            EnterDoor,
6345        }
6346
6347        fn kind_class(kind: Kind) -> u8 {
6348            match kind {
6349                Kind::EnterDoor => 0,
6350                Kind::QuestBoard => 1,
6351                Kind::Player | Kind::Npc => 2,
6352                Kind::ExitDoor => 3,
6353                Kind::HiredWorker => 4,
6354            }
6355        }
6356
6357        fn kind_priority(kind: Kind) -> u8 {
6358            match kind {
6359                Kind::EnterDoor => 0,
6360                Kind::QuestBoard => 1,
6361                Kind::Player | Kind::Npc => 2,
6362                Kind::ExitDoor => 3,
6363                Kind::HiredWorker => 4,
6364            }
6365        }
6366
6367        let mut best: Option<(f32, Kind, String)> = None;
6368
6369        let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
6370            if dist > max {
6371                return;
6372            }
6373            let replace = match best {
6374                None => true,
6375                Some((_, bk, _)) if kind_class(kind) < kind_class(bk) => true,
6376                Some((bd, bk, _))
6377                    if kind_class(kind) == kind_class(bk) && dist < bd - 0.05 =>
6378                {
6379                    true
6380                }
6381                Some((bd, bk, _))
6382                    if kind_class(kind) == kind_class(bk) && (dist - bd).abs() <= 0.05 =>
6383                {
6384                    kind_priority(kind) < kind_priority(bk)
6385                }
6386                _ => false,
6387            };
6388            if replace {
6389                best = Some((dist, kind, id));
6390            }
6391        };
6392
6393        for npc in &self.npcs {
6394            consider(
6395                distance(px, py, npc.x, npc.y),
6396                INTERACTION_RADIUS_M,
6397                Kind::Npc,
6398                npc.id.clone(),
6399            );
6400        }
6401
6402        for worker in &self.hired_workers {
6403            consider(
6404                distance(px, py, worker.x, worker.y),
6405                INTERACTION_RADIUS_M,
6406                Kind::HiredWorker,
6407                worker.instance_id.clone(),
6408            );
6409        }
6410
6411        for entity in &self.entities {
6412            if entity.id == self.entity_id
6413                || entity.vitals.is_none()
6414                || entity.label.trim().is_empty()
6415            {
6416                continue;
6417            }
6418            // Hired workers are NPCs with their own UI — not Whisper/Trade peers.
6419            if self.hired_workers.iter().any(|w| w.entity_id == entity.id) {
6420                continue;
6421            }
6422            consider(
6423                distance(
6424                    px,
6425                    py,
6426                    entity.transform.position.x,
6427                    entity.transform.position.y,
6428                ),
6429                INTERACTION_RADIUS_M,
6430                Kind::Player,
6431                entity.id.to_string(),
6432            );
6433        }
6434
6435        for door in &self.doors {
6436            if let Some(ref bid) = inside {
6437                if door.building_id != *bid {
6438                    continue;
6439                }
6440                let is_exit = door.portal.is_some();
6441                let max = if is_exit {
6442                    INTERACTION_RADIUS_M
6443                } else {
6444                    DOOR_INTERACTION_RADIUS_M
6445                };
6446                let kind = if is_exit {
6447                    Kind::ExitDoor
6448                } else {
6449                    Kind::EnterDoor
6450                };
6451                consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
6452                continue;
6453            }
6454            consider(
6455                distance(px, py, door.x, door.y),
6456                DOOR_INTERACTION_RADIUS_M,
6457                Kind::EnterDoor,
6458                door.id.clone(),
6459            );
6460        }
6461
6462        if inside.is_none() {
6463            for inter in &self.interactables {
6464                if inter.kind == "quest_board" {
6465                    consider(
6466                        distance(px, py, inter.x, inter.y),
6467                        QUEST_BOARD_INTERACTION_RADIUS_M,
6468                        Kind::QuestBoard,
6469                        inter.id.clone(),
6470                    );
6471                }
6472            }
6473        }
6474
6475        best.map(|(_, _, id)| id)
6476    }
6477
6478    /// Nearest quest board and distance (any distance), for out-of-range feedback.
6479    pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
6480        if self.effective_inside_building().is_some() {
6481            return None;
6482        }
6483        let (px, py) = self.player_position();
6484        self.interactables
6485            .iter()
6486            .filter(|i| i.kind == "quest_board")
6487            .map(|i| {
6488                let label = if i.label.is_empty() {
6489                    "Quest board".to_string()
6490                } else {
6491                    i.label.clone()
6492                };
6493                (label, distance(px, py, i.x, i.y))
6494            })
6495            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
6496    }
6497
6498    /// Human-readable template name from inventory hints, then the synced item catalog.
6499    pub fn template_display_name(&self, template_id: &str) -> String {
6500        if let Some(name) = self
6501            .inventory_hints
6502            .get(template_id)
6503            .map(|h| h.display_name.clone())
6504            .filter(|n| !n.is_empty())
6505        {
6506            return name;
6507        }
6508        if let Some(entry) = self.item_catalog.get(template_id) {
6509            if !entry.display_name.trim().is_empty() {
6510                return entry.display_name.clone();
6511            }
6512        }
6513        humanize_template_id(template_id)
6514    }
6515
6516    pub fn catalog_entry(&self, template_id: &str) -> Option<&ItemCatalogEntryView> {
6517        self.item_catalog.get(template_id)
6518    }
6519
6520    /// Prefer blueprint-view display name, then inventory hints / humanized id.
6521    pub fn blueprint_item_label(&self, template_id: &str, display_name: &str) -> String {
6522        if !display_name.is_empty() {
6523            display_name.to_string()
6524        } else {
6525            self.template_display_name(template_id)
6526        }
6527    }
6528
6529    pub fn blueprint_output_label(&self, blueprint: &BlueprintView) -> String {
6530        self.blueprint_item_label(&blueprint.output, &blueprint.output_display_name)
6531    }
6532
6533    pub fn blueprint_ingredient_label(
6534        &self,
6535        input: &flatland_protocol::BlueprintIngredientView,
6536    ) -> String {
6537        self.blueprint_item_label(&input.template_id, &input.display_name)
6538    }
6539
6540    pub fn blueprint_tool_label(&self, tool: &flatland_protocol::ToolRequirementView) -> String {
6541        self.blueprint_item_label(&tool.item, &tool.display_name)
6542    }
6543
6544    /// Harvest picker rows for the worker route editor — distance/sort from rest bed, not player.
6545    pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
6546        use crate::worker_route_editor::{
6547            node_candidates, node_candidates_stable, route_editor_lodging_anchor,
6548        };
6549        let lodging = self
6550            .worker_route_editor
6551            .as_ref()
6552            .and_then(|ed| ed.lodging_container_id.as_deref());
6553        match route_editor_lodging_anchor(lodging, &self.placed_containers) {
6554            Some((ax, ay)) => node_candidates(&self.resource_nodes, ax, ay),
6555            None => node_candidates_stable(&self.resource_nodes),
6556        }
6557    }
6558
6559    pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
6560        if dist_m.is_nan() {
6561            return "—".into();
6562        }
6563        let from_bed = self
6564            .worker_route_editor
6565            .as_ref()
6566            .and_then(|ed| ed.lodging_container_id.as_deref())
6567            .and_then(|id| {
6568                self.placed_containers
6569                    .iter()
6570                    .find(|c| c.id == id)
6571                    .map(|c| c.display_name.clone())
6572            });
6573        match from_bed {
6574            Some(bed) => format!("{dist_m:.0}m from {bed}"),
6575            None => format!("{dist_m:.0}m"),
6576        }
6577    }
6578
6579    /// Chest label for the location panel — generic type unless this player owns it.
6580    pub fn placed_container_public_label(
6581        &self,
6582        c: &flatland_protocol::PlacedContainerView,
6583    ) -> String {
6584        let is_owner = match (self.character_id, c.owner_character_id) {
6585            (Some(me), Some(owner)) => me == owner,
6586            _ => false,
6587        };
6588        if is_owner {
6589            c.display_name.clone()
6590        } else {
6591            self.template_display_name(&c.template_id)
6592        }
6593    }
6594
6595    /// Keys on person and stowed on the keychain for the keychain overlay.
6596    pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
6597        let mut out = Vec::new();
6598        for stack in &self.inventory_stacks {
6599            if stack.template_id == KEY_TEMPLATE {
6600                out.push(KeychainEntry {
6601                    stack: stack.clone(),
6602                    stowed: false,
6603                });
6604            }
6605        }
6606        for stack in &self.keychain_stacks {
6607            if stack.template_id == KEY_TEMPLATE {
6608                out.push(KeychainEntry {
6609                    stack: stack.clone(),
6610                    stowed: true,
6611                });
6612            }
6613        }
6614        out
6615    }
6616
6617    /// Display name of the chest a `container_key` opens, when known on the client.
6618    pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
6619        if stack.template_id != KEY_TEMPLATE {
6620            return None;
6621        }
6622        if let Some(name) = stack
6623            .props
6624            .get(PROP_OPENS_CONTAINER_NAME)
6625            .filter(|n| !n.is_empty())
6626        {
6627            return Some(name.clone());
6628        }
6629        let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
6630        self.container_name_for_lock_id(opens)
6631    }
6632
6633    /// Keys always show the catalog name — never a chest rename or stray `custom_name`.
6634    pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
6635        if stack.template_id == KEY_TEMPLATE {
6636            self.template_display_name(KEY_TEMPLATE)
6637        } else {
6638            stack
6639                .display_name
6640                .clone()
6641                .unwrap_or_else(|| stack.template_id.clone())
6642        }
6643    }
6644
6645    /// Hint suffix for a key row (`[key for …]` or `[key — unpaired]`).
6646    pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
6647        if stack.template_id != KEY_TEMPLATE {
6648            return String::new();
6649        }
6650        match self.key_pair_chest_label(stack) {
6651            Some(chest) if self.key_drop_blocked(stack) => {
6652                format!(" [key for {chest} — can't drop while locked]")
6653            }
6654            Some(chest) => format!(" [key for {chest}]"),
6655            None => " [key — unpaired]".into(),
6656        }
6657    }
6658
6659    /// Resolve a lock id to a container label (placed chest or one still on your person).
6660    pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
6661        for c in &self.placed_containers {
6662            if c.lock_id.as_deref() == Some(lock) {
6663                return Some(c.display_name.clone());
6664            }
6665        }
6666        Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
6667            self.worn
6668                .values()
6669                .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
6670        })
6671    }
6672
6673    /// Keys cannot be dropped while their paired chest is locked.
6674    pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
6675        if stack.template_id != KEY_TEMPLATE {
6676            return false;
6677        }
6678        let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
6679            return false;
6680        };
6681        for c in &self.placed_containers {
6682            if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
6683                return true;
6684            }
6685        }
6686        if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
6687            return true;
6688        }
6689        self.worn
6690            .values()
6691            .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
6692    }
6693
6694    /// Property deeds cannot be dropped or destroyed (store or trade only).
6695    pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
6696        stack.template_id == PROPERTY_DEED_TEMPLATE
6697    }
6698
6699    pub fn is_property_deed_template(template_id: &str) -> bool {
6700        template_id == PROPERTY_DEED_TEMPLATE
6701    }
6702
6703    pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
6704        stack
6705            .props
6706            .get("plot_id")
6707            .and_then(|s| uuid::Uuid::parse_str(s).ok())
6708    }
6709
6710    /// World position (cell center) of the plot cell the player is standing on, if tillable.
6711    pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
6712        let (px, py) = self.player_position();
6713        let (cx, cy) = self.farm_plot_cell_under_player()?;
6714        let tx = cx as f32 + 0.5;
6715        let ty = cy as f32 + 0.5;
6716        if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
6717            return None;
6718        }
6719        let kind = self.terrain_at(tx, ty).or_else(|| self.terrain_at(px, py));
6720        if kind == Some(TerrainKindView::Tilled) {
6721            return None;
6722        }
6723        if matches!(
6724            kind,
6725            Some(TerrainKindView::ShallowWater)
6726                | Some(TerrainKindView::DeepWater)
6727                | Some(TerrainKindView::Rock)
6728        ) {
6729            return None;
6730        }
6731        Some((tx, ty))
6732    }
6733
6734    fn container_name_in_stacks(
6735        stacks: &[flatland_protocol::ItemStack],
6736        lock: &str,
6737    ) -> Option<String> {
6738        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
6739            for s in stacks {
6740                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
6741                    return Some(GameState::stack_container_label(s));
6742                }
6743                if let Some(name) = walk(&s.contents, lock) {
6744                    return Some(name);
6745                }
6746            }
6747            None
6748        }
6749        walk(stacks, lock)
6750    }
6751
6752    fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
6753        stack
6754            .props
6755            .get(PROP_CUSTOM_NAME)
6756            .cloned()
6757            .or_else(|| stack.display_name.clone())
6758            .unwrap_or_else(|| stack.template_id.clone())
6759    }
6760
6761    fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6762        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6763            for s in stacks {
6764                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
6765                    return true;
6766                }
6767                if walk(&s.contents, lock) {
6768                    return true;
6769                }
6770            }
6771            false
6772        }
6773        walk(stacks, lock)
6774    }
6775
6776    fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
6777        if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
6778            return Some(stack.clone());
6779        }
6780        for worn in self.worn.values() {
6781            if worn.item_instance_id == Some(instance_id) {
6782                return Some(worn.clone());
6783            }
6784            if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
6785                return Some(stack.clone());
6786            }
6787        }
6788        None
6789    }
6790
6791    /// Highest-`z_order` property zone covering `(x, y)`.
6792    pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
6793        self.property_zones
6794            .iter()
6795            .enumerate()
6796            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
6797            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
6798            .map(|(_, z)| z)
6799    }
6800
6801    /// Highest-`z_order` tax zone covering `(x, y)`.
6802    pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
6803        self.tax_zones
6804            .iter()
6805            .enumerate()
6806            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
6807            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
6808            .map(|(_, z)| z)
6809    }
6810
6811    /// Max tax `rate_bps` across 1 m cell centers of the claim rect (matches server).
6812    pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
6813        let mut max_bps = 0u32;
6814        let mut y = y0 + 0.5;
6815        while y < y1 {
6816            let mut x = x0 + 0.5;
6817            while x < x1 {
6818                if let Some(tz) = self.tax_zone_at(x, y) {
6819                    max_bps = max_bps.max(tz.rate_bps);
6820                }
6821                x += 1.0;
6822            }
6823            y += 1.0;
6824        }
6825        max_bps
6826    }
6827
6828    /// Claim footprint as half-open world AABB `(x0, y0, x1, y1)`.
6829    pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
6830        let mode = self.claim_mode.as_ref()?;
6831        let w = mode.width_m.max(1) as f32;
6832        let h = mode.height_m.max(1) as f32;
6833        Some((
6834            mode.anchor_x,
6835            mode.anchor_y,
6836            mode.anchor_x + w,
6837            mode.anchor_y + h,
6838        ))
6839    }
6840
6841    /// Relocate ghost as half-open 1×1 world AABB `(x0, y0, x1, y1)`.
6842    pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
6843        let mode = self.relocate_mode.as_ref()?;
6844        let x0 = mode.cursor_x.floor();
6845        let y0 = mode.cursor_y.floor();
6846        Some((x0, y0, x0 + 1.0, y0 + 1.0))
6847    }
6848
6849    /// Client quote matching server `quote_plot`:
6850    /// `(purchase, upkeep, area, premium, can_afford, valid, reason)`.
6851    pub fn claim_quote(&self) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
6852        let mode = self.claim_mode.as_ref()?;
6853        let zone = self.property_zones.iter().find(|z| z.id == mode.zone_id)?;
6854        let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
6855        let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
6856        let zone_area = zone_view_area_m2(zone).max(1.0);
6857        let area_frac = (area / zone_area).clamp(0.0, 1.0);
6858        let weight = self
6859            .property_plot_settings
6860            .as_ref()
6861            .map(|s| s.tax_premium_weight)
6862            .unwrap_or(0.5)
6863            .max(0.0);
6864        let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
6865        let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
6866        let purchase = ((zone.crown_price_copper as f64) * (area_frac as f64) * (premium as f64))
6867            .ceil()
6868            .max(0.0) as u64;
6869        let upkeep = if zone.upkeep_copper_per_day == 0 {
6870            0
6871        } else {
6872            ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
6873                .ceil()
6874                .max(1.0) as u64
6875        };
6876        let copper = crate::currency::copper_from_counts(&self.inventory);
6877        let can_afford = copper >= purchase;
6878        let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
6879        Some((purchase, upkeep, area, premium, can_afford, valid, reason))
6880    }
6881
6882    fn validate_claim_footprint(
6883        &self,
6884        zone: &flatland_protocol::PropertyZoneView,
6885        x0: f32,
6886        y0: f32,
6887        x1: f32,
6888        y1: f32,
6889        area: f32,
6890    ) -> (bool, String) {
6891        let min_area = self
6892            .property_plot_settings
6893            .as_ref()
6894            .map(|s| s.min_plot_area_m2)
6895            .unwrap_or(4.0);
6896        if area + f32::EPSILON < min_area {
6897            return (false, "plot too small".into());
6898        }
6899        if zone.max_area_m2.is_some_and(|m| area > m) {
6900            return (false, "plot exceeds max area".into());
6901        }
6902        if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
6903            return (false, "plot must lie inside the property zone".into());
6904        }
6905        if self
6906            .property_plots
6907            .iter()
6908            .any(|p| rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1))
6909        {
6910            return (false, "plot overlaps an existing claim".into());
6911        }
6912        (true, String::new())
6913    }
6914
6915    /// Standing in a property zone on a free cell (not already claimed).
6916    pub fn free_property_zone_under_player(&self) -> Option<&flatland_protocol::PropertyZoneView> {
6917        let (px, py) = self.player_position();
6918        let zone = self.property_zone_at(px, py)?;
6919        if self.property_plots.iter().any(|p| point_in_plot(px, py, p)) {
6920            return None;
6921        }
6922        Some(zone)
6923    }
6924
6925    /// Own plot covering the player (`is_mine`).
6926    pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
6927        let (px, py) = self.player_position();
6928        self.property_plots
6929            .iter()
6930            .find(|p| p.is_mine && point_in_plot(px, py, p))
6931    }
6932
6933    /// Plot under the player that they may farm (deed, grant, or public).
6934    pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
6935        let (px, py) = self.player_position();
6936        self.property_plots
6937            .iter()
6938            .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
6939    }
6940
6941    /// Grid cell under the player on a farmable plot (`[cx,cx+1)` × `[cy,cy+1)` contains feet).
6942    pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
6943        if self.farmable_plot_under_player().is_none() {
6944            return None;
6945        }
6946        let (px, py) = self.player_position();
6947        Some((px.floor() as i32, py.floor() as i32))
6948    }
6949
6950    fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
6951        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6952        self.resource_nodes.iter().any(|n| {
6953            let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
6954            ncx == cx && ncy == cy || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
6955        })
6956    }
6957
6958    fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
6959        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6960        let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
6961            || self
6962                .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
6963                .is_some_and(|z| z.kind == TerrainKindView::Tilled);
6964        if !tilled {
6965            return false;
6966        }
6967        !self.resource_node_occupies_farm_cell(cx, cy)
6968    }
6969
6970    /// Empty tilled soil on the cell the player is standing on (no crop node).
6971    pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
6972        let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
6973            return false;
6974        };
6975        self.free_tilled_plant_slot_at(cx, cy)
6976    }
6977
6978    /// True when a free tilled cell (no resource node) is within interact range.
6979    pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
6980        let (px, py) = self.player_position();
6981        for dy in -2..=2 {
6982            for dx in -2..=2 {
6983                let cx = px.floor() as i32 + dx;
6984                let cy = py.floor() as i32 + dy;
6985                let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6986                if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
6987                    continue;
6988                }
6989                if self.free_tilled_plant_slot_at(cx, cy) {
6990                    return true;
6991                }
6992            }
6993        }
6994        false
6995    }
6996
6997    fn stack_is_farm_seed(&self, stack: &flatland_protocol::ItemStack) -> bool {
6998        if stack.quantity == 0 {
6999            return false;
7000        }
7001        if stack.props.contains_key("seed_for") {
7002            return true;
7003        }
7004        if let Some(entry) = self.item_catalog.get(&stack.template_id) {
7005            if entry.is_farm_seed() {
7006                return true;
7007            }
7008        }
7009        stack.template_id.ends_with("_seed")
7010    }
7011
7012    /// Farm seed stacks in inventory (template_id, qty, display label), sorted by label.
7013    pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
7014        let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
7015        fn walk(
7016            stacks: &[flatland_protocol::ItemStack],
7017            state: &GameState,
7018            counts: &mut std::collections::HashMap<String, u32>,
7019        ) {
7020            for s in stacks {
7021                if state.stack_is_farm_seed(s) {
7022                    *counts.entry(s.template_id.clone()).or_default() += s.quantity;
7023                }
7024                walk(&s.contents, state, counts);
7025            }
7026        }
7027        walk(&self.inventory_stacks, self, &mut counts);
7028        for worn in self.worn.values() {
7029            walk(std::slice::from_ref(worn), self, &mut counts);
7030        }
7031        let mut out: Vec<_> = counts
7032            .into_iter()
7033            .map(|(template_id, quantity)| {
7034                let label = self.template_display_name(&template_id);
7035                (template_id, quantity, label)
7036            })
7037            .collect();
7038        out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
7039        out
7040    }
7041
7042    /// First farm seed template in inventory (root + nested bags).
7043    pub fn first_farm_seed_template(&self) -> Option<String> {
7044        self.farm_seed_entries()
7045            .into_iter()
7046            .next()
7047            .map(|(id, _, _)| id)
7048    }
7049
7050    pub fn clamp_plant_menu(&mut self) {
7051        let n = self.farm_seed_entries().len();
7052        if n == 0 {
7053            self.plant_menu_index = 0;
7054            self.plant_quantity = 1;
7055            return;
7056        }
7057        self.plant_menu_index = self.plant_menu_index.min(n - 1);
7058        let max_qty = self
7059            .farm_seed_entries()
7060            .get(self.plant_menu_index)
7061            .map(|(_, q, _)| *q)
7062            .unwrap_or(1)
7063            .max(1);
7064        self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
7065    }
7066
7067    pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
7068        let entries = self.farm_seed_entries();
7069        let (id, max, label) = entries.get(self.plant_menu_index)?;
7070        let qty = self.plant_quantity.min(*max).max(1);
7071        Some((id.clone(), qty, label.clone()))
7072    }
7073
7074    /// Terrain, interactables, and map objects near the player for the HUD location panel.
7075    pub fn location_context_lines(&self) -> Vec<ContextLine> {
7076        let (px, py) = self.player_position();
7077        let inside = self.effective_inside_building();
7078        let mut lines = Vec::new();
7079
7080        if let Some(kind) = self.terrain_at(px, py) {
7081            lines.push(ContextLine {
7082                on_top: true,
7083                text: format!("Terrain: {}", terrain_kind_label(kind)),
7084            });
7085        }
7086
7087        if let Some(id) = inside.as_ref() {
7088            if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
7089                lines.push(ContextLine {
7090                    on_top: true,
7091                    text: format!("Inside: {}", b.label),
7092                });
7093            }
7094        }
7095
7096        let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
7097
7098        for node in &self.resource_nodes {
7099            if node.id.starts_with("preview:") {
7100                continue;
7101            }
7102            let dist = distance(px, py, node.x, node.y);
7103            if dist > NEARBY_SCAN_M {
7104                continue;
7105            }
7106            let on_top = dist <= ON_TOP_RADIUS_M;
7107            let prefix = if on_top { "On" } else { "Near" };
7108            let name = resource_node_near_display_label(&node.label);
7109            let action = resource_node_near_action_suffix(node);
7110            nearby.push((
7111                dist,
7112                ContextLine {
7113                    on_top,
7114                    text: format!("{prefix}: {name} ({dist:.1}m){action}"),
7115                },
7116            ));
7117        }
7118
7119        for drop in &self.ground_drops {
7120            let dist = distance(px, py, drop.x, drop.y);
7121            if dist > INTERACTION_RADIUS_M {
7122                continue;
7123            }
7124            let on_top = dist <= ON_TOP_RADIUS_M;
7125            let name = self.template_display_name(&drop.template_id);
7126            let prefix = if on_top { "On" } else { "Near" };
7127            let qty = if drop.quantity > 1 {
7128                format!(" ×{}", drop.quantity)
7129            } else {
7130                String::new()
7131            };
7132            nearby.push((
7133                dist,
7134                ContextLine {
7135                    on_top,
7136                    text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
7137                },
7138            ));
7139        }
7140
7141        for c in &self.placed_containers {
7142            if !self.placed_container_in_current_space(c) {
7143                continue;
7144            }
7145            let dist = distance(px, py, c.x, c.y);
7146            if dist > CONTAINER_RANGE_M {
7147                continue;
7148            }
7149            let on_top = dist <= ON_TOP_RADIUS_M;
7150            let name = self.placed_container_public_label(c);
7151            let lock = if c.locked { " [locked]" } else { "" };
7152            let prefix = if on_top { "On" } else { "Near" };
7153            nearby.push((
7154                dist,
7155                ContextLine {
7156                    on_top,
7157                    text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
7158                },
7159            ));
7160        }
7161
7162        for npc in &self.npcs {
7163            let dist = distance(px, py, npc.x, npc.y);
7164            if dist > NEARBY_SCAN_M {
7165                continue;
7166            }
7167            let on_top = dist <= ON_TOP_RADIUS_M;
7168            let prefix = if on_top { "On" } else { "Near" };
7169            nearby.push((
7170                dist,
7171                ContextLine {
7172                    on_top,
7173                    text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
7174                },
7175            ));
7176        }
7177
7178        for door in &self.doors {
7179            let dist = distance(px, py, door.x, door.y);
7180            if dist > DOOR_INTERACTION_RADIUS_M {
7181                continue;
7182            }
7183            let building = self
7184                .buildings
7185                .iter()
7186                .find(|b| b.id == door.building_id)
7187                .map(|b| b.label.as_str())
7188                .unwrap_or(door.building_id.as_str());
7189            let player_house = self
7190                .buildings
7191                .iter()
7192                .find(|b| b.id == door.building_id)
7193                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
7194            let action = if inside.is_some() && door.portal.is_some() {
7195                if player_house {
7196                    if door.locked {
7197                        "locked — l unlock · Enter exit".to_string()
7198                    } else if door.open {
7199                        "close · Enter exit · l lock".to_string()
7200                    } else {
7201                        "open · Enter exit · l lock".to_string()
7202                    }
7203                } else {
7204                    "exit".to_string()
7205                }
7206            } else if player_house {
7207                if door.locked {
7208                    "locked — l unlock".to_string()
7209                } else if door.open {
7210                    "close · Enter go inside · l lock".to_string()
7211                } else {
7212                    "open · l lock".to_string()
7213                }
7214            } else {
7215                "enter".to_string()
7216            };
7217            nearby.push((
7218                dist,
7219                ContextLine {
7220                    on_top: dist <= ON_TOP_RADIUS_M,
7221                    text: format!("{building} door ({dist:.1}m) — f {action}"),
7222                },
7223            ));
7224        }
7225
7226        if inside.is_none() {
7227            for inter in &self.interactables {
7228                if inter.kind != "quest_board" {
7229                    continue;
7230                }
7231                let dist = distance(px, py, inter.x, inter.y);
7232                if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
7233                    continue;
7234                }
7235                let on_top = dist <= ON_TOP_RADIUS_M;
7236                let prefix = if on_top { "On" } else { "Near" };
7237                let label = if inter.label.is_empty() {
7238                    "Quest board".to_string()
7239                } else {
7240                    inter.label.clone()
7241                };
7242                nearby.push((
7243                    dist,
7244                    ContextLine {
7245                        on_top,
7246                        text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
7247                    },
7248                ));
7249            }
7250        }
7251
7252        if self.near_liquid_fill_source() {
7253            let on_water = matches!(
7254                self.terrain_at(px, py),
7255                Some(TerrainKindView::ShallowWater | TerrainKindView::DeepWater)
7256            );
7257            let well = self.buildings.iter().find(|b| {
7258                b.tags.iter().any(|t| t == "well") && {
7259                    let hw = b.width_m * 0.5;
7260                    let hd = b.depth_m * 0.5;
7261                    let nx = px.clamp(b.x - hw, b.x + hw);
7262                    let ny = py.clamp(b.y - hd, b.y + hd);
7263                    let dx = px - nx;
7264                    let dy = py - ny;
7265                    dx * dx + dy * dy <= INTERACTION_RADIUS_M * INTERACTION_RADIUS_M
7266                }
7267            });
7268            if let Some(well) = well {
7269                let name = if well.label.trim().is_empty() {
7270                    "Well"
7271                } else {
7272                    well.label.as_str()
7273                };
7274                nearby.push((
7275                    0.0,
7276                    ContextLine {
7277                        on_top: true,
7278                        text: format!("{name} — Use a vessel from inventory to fill"),
7279                    },
7280                ));
7281            } else if on_water {
7282                if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
7283                    line.text
7284                        .push_str(" — Use a vessel from inventory to fill");
7285                }
7286            } else {
7287                nearby.push((
7288                    0.0,
7289                    ContextLine {
7290                        on_top: true,
7291                        text: "Water nearby — Use a vessel from inventory to fill".into(),
7292                    },
7293                ));
7294            }
7295        }
7296
7297        if self.claim_mode.is_some() {
7298            nearby.push((
7299                0.0,
7300                ContextLine {
7301                    on_top: true,
7302                    text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
7303                        .into(),
7304                },
7305            ));
7306        } else if let Some(plot) = self.my_plot_under_player() {
7307            let name = plot_public_label(plot);
7308            let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
7309                format!("{name} — f again to sell to crown")
7310            } else {
7311                format!(
7312                    "{name} — Shift+c till · p plant · f harvest · B build · l door lock · o farm access · Shift+n rename"
7313                )
7314            };
7315            nearby.push((
7316                0.0,
7317                ContextLine {
7318                    on_top: true,
7319                    text: prompt,
7320                },
7321            ));
7322        } else if let Some(plot) = self.farmable_plot_under_player() {
7323            let name = plot_public_label(plot);
7324            let disc = if plot.farm_public {
7325                plot.public_tax_discount_bps / 100
7326            } else {
7327                plot.farm_allow
7328                    .iter()
7329                    .find(|g| Some(g.character_id) == self.character_id)
7330                    .map(|g| g.tax_discount_bps / 100)
7331                    .unwrap_or(0)
7332            };
7333            nearby.push((
7334                0.0,
7335                ContextLine {
7336                    on_top: true,
7337                    text: format!(
7338                        "{name} (farming · tax −{disc}%) — Shift+c till · p plant · f harvest"
7339                    ),
7340                },
7341            ));
7342        } else if let Some(zone) = self.free_property_zone_under_player() {
7343            let label = zone
7344                .label
7345                .as_deref()
7346                .filter(|s| !s.trim().is_empty())
7347                .unwrap_or(zone.id.as_str());
7348            nearby.push((
7349                0.0,
7350                ContextLine {
7351                    on_top: true,
7352                    text: format!("Claimable land: {label} — k buy plot"),
7353                },
7354            ));
7355        }
7356
7357        for entity in &self.entities {
7358            if entity.id == self.entity_id {
7359                continue;
7360            }
7361            let dist = distance(
7362                px,
7363                py,
7364                entity.transform.position.x,
7365                entity.transform.position.y,
7366            );
7367            if dist > NEARBY_SCAN_M {
7368                continue;
7369            }
7370            let label = if entity.label.is_empty() {
7371                format!("entity {}", entity.id)
7372            } else {
7373                entity.label.clone()
7374            };
7375            nearby.push((
7376                dist,
7377                ContextLine {
7378                    on_top: dist <= ON_TOP_RADIUS_M,
7379                    text: format!("Near: {label} ({dist:.1}m)"),
7380                },
7381            ));
7382        }
7383
7384        nearby.sort_by(|a, b| {
7385            a.0.partial_cmp(&b.0)
7386                .unwrap_or(std::cmp::Ordering::Equal)
7387                .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
7388        });
7389        lines.extend(nearby.into_iter().map(|(_, l)| l));
7390
7391        if lines.is_empty() {
7392            lines.push(ContextLine {
7393                on_top: false,
7394                text: "(nothing notable nearby)".into(),
7395            });
7396        }
7397
7398        lines
7399    }
7400}
7401
7402/// HUD line for the location / nearby panel.
7403#[derive(Debug, Clone)]
7404pub struct ContextLine {
7405    pub on_top: bool,
7406    pub text: String,
7407}
7408
7409const ON_TOP_RADIUS_M: f32 = 0.65;
7410const NEARBY_SCAN_M: f32 = 5.0;
7411
7412/// Display name for a resource node in location/nearby HUD (strips redundant growing tag).
7413pub fn resource_node_near_display_label(label: &str) -> String {
7414    label
7415        .strip_suffix(" (growing)")
7416        .unwrap_or(label)
7417        .to_string()
7418}
7419
7420fn resource_label_looks_like_raw_id(label: &str, id: &str) -> bool {
7421    let t = label.trim();
7422    if t.is_empty() || t == id {
7423        return true;
7424    }
7425    let lower = t.to_ascii_lowercase();
7426    if lower.contains("_copy") {
7427        return true;
7428    }
7429    false
7430}
7431
7432fn humanize_item_template_label(template: &str) -> String {
7433    let base = template.rsplit('/').next().unwrap_or(template).trim();
7434    if base.is_empty() {
7435        return "Resource".into();
7436    }
7437    let stripped = base
7438        .strip_prefix("crop-")
7439        .or_else(|| base.strip_prefix("crop_"))
7440        .unwrap_or(base);
7441    stripped
7442        .split(|c: char| c == '-' || c == '_')
7443        .filter(|p| !p.is_empty())
7444        .map(|p| {
7445            let mut chars = p.chars();
7446            match chars.next() {
7447                Some(c) => format!("{}{}", c.to_ascii_uppercase(), chars.as_str()),
7448                None => String::new(),
7449            }
7450        })
7451        .collect::<Vec<_>>()
7452        .join(" ")
7453}
7454
7455/// Last 4 alphanumeric chars of an id (for disambiguation in route UI).
7456pub fn resource_node_id_suffix(id: &str) -> String {
7457    let chars: Vec<char> = id
7458        .chars()
7459        .rev()
7460        .filter(|c| c.is_ascii_alphanumeric())
7461        .take(4)
7462        .collect();
7463    chars.into_iter().rev().collect()
7464}
7465
7466/// Friendly harvest/route label: prefer human label, else template; always append `(xxxx)`.
7467pub fn resource_node_route_label(node: &flatland_protocol::ResourceNodeView) -> String {
7468    resource_node_route_label_parts(&node.id, &node.label, &node.item_template)
7469}
7470
7471pub fn resource_node_route_label_parts(id: &str, label: &str, item_template: &str) -> String {
7472    let cleaned = resource_node_near_display_label(label);
7473    let friendly = if !resource_label_looks_like_raw_id(&cleaned, id) {
7474        cleaned
7475    } else if !item_template.trim().is_empty() {
7476        humanize_item_template_label(item_template)
7477    } else {
7478        id.to_string()
7479    };
7480    let suffix = resource_node_id_suffix(id);
7481    if suffix.is_empty() {
7482        friendly
7483    } else {
7484        format!("{friendly} ({suffix})")
7485    }
7486}
7487
7488/// Action / state suffix for a resource node line (`growth_progress` wins for farm crops).
7489pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
7490    use flatland_protocol::ResourceNodeState;
7491    if node.harvest_off {
7492        return " (decorative)".to_string();
7493    }
7494    if let Some(p) = node.growth_progress {
7495        if p < 1.0 - f32::EPSILON {
7496            let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
7497            return format!(" (growing, {pct}%)");
7498        }
7499        return " — f harvest".to_string();
7500    }
7501    match node.state {
7502        ResourceNodeState::Available => " — f harvest".to_string(),
7503        ResourceNodeState::Harvesting => " (being harvested)".to_string(),
7504        ResourceNodeState::Cooldown => " (depleted)".to_string(),
7505    }
7506}
7507
7508fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
7509    use flatland_protocol::TerrainKindView;
7510    match kind {
7511        TerrainKindView::Grass => "Grass",
7512        TerrainKindView::Dirt => "Dirt",
7513        TerrainKindView::Tilled => "Tilled",
7514        TerrainKindView::Desert => "Desert",
7515        TerrainKindView::Hill => "Hills",
7516        TerrainKindView::Bog => "Bog",
7517        TerrainKindView::Beach => "Beach",
7518        TerrainKindView::ShallowWater => "Shallow water",
7519        TerrainKindView::DeepWater => "Deep water",
7520        TerrainKindView::Trail => "Trail",
7521        TerrainKindView::Road => "Road",
7522        TerrainKindView::Rock => "Rock",
7523    }
7524}
7525
7526fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
7527    crate::world_zones::zone_rects_contain(rects, x, y)
7528}
7529
7530fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
7531    zone.rects
7532        .iter()
7533        .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
7534        .sum()
7535}
7536
7537fn claim_rect_fully_inside_zone(
7538    zone: &flatland_protocol::PropertyZoneView,
7539    x0: f32,
7540    y0: f32,
7541    x1: f32,
7542    y1: f32,
7543) -> bool {
7544    let mut y = y0 + 0.5;
7545    while y < y1 {
7546        let mut x = x0 + 0.5;
7547        while x < x1 {
7548            if !zone_rects_contain(&zone.rects, x, y) {
7549                return false;
7550            }
7551            x += 1.0;
7552        }
7553        y += 1.0;
7554    }
7555    true
7556}
7557
7558fn rects_overlap_half_open(
7559    ax0: f32,
7560    ay0: f32,
7561    ax1: f32,
7562    ay1: f32,
7563    bx0: f32,
7564    by0: f32,
7565    bx1: f32,
7566    by1: f32,
7567) -> bool {
7568    ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
7569}
7570
7571fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
7572    x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
7573}
7574
7575fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
7576    plot_public_label(p)
7577}
7578
7579fn plot_size_fallback_label(p: &flatland_protocol::PropertyPlotView) -> String {
7580    let w = (p.x1 - p.x0).abs();
7581    let d = (p.y1 - p.y0).abs();
7582    format!("Plot ({w:.0}×{d:.0} m)")
7583}
7584
7585/// Canonical plot name: `{owner} — {zone} — {label}`.
7586pub fn plot_public_label(p: &flatland_protocol::PropertyPlotView) -> String {
7587    let zone = p
7588        .zone_label
7589        .as_deref()
7590        .filter(|s| !s.trim().is_empty())
7591        .unwrap_or_else(|| {
7592            if p.property_zone_id.is_empty() {
7593                "Homestead"
7594            } else {
7595                p.property_zone_id.as_str()
7596            }
7597        });
7598    let label = if !p.label.trim().is_empty() {
7599        p.label.clone()
7600    } else if !p.plot_code.trim().is_empty() {
7601        p.plot_code.clone()
7602    } else {
7603        plot_size_fallback_label(p)
7604    };
7605    match p
7606        .owner_label
7607        .as_deref()
7608        .map(str::trim)
7609        .filter(|s| !s.is_empty())
7610    {
7611        Some(owner) => format!("{owner} — {zone} — {label}"),
7612        None => format!("{zone} — {label}"),
7613    }
7614}
7615
7616/// Resolve a farm-plot stop target for player-facing route UI.
7617///
7618/// Uses [`plot_public_label`] when the plot is in the observer's view; otherwise a
7619/// short constructed fallback (never the full UUID).
7620pub fn plot_stop_label(
7621    plots: &[flatland_protocol::PropertyPlotView],
7622    plot_id: uuid::Uuid,
7623) -> String {
7624    plots
7625        .iter()
7626        .find(|p| p.plot_id == plot_id)
7627        .map(plot_public_label)
7628        .unwrap_or_else(|| {
7629            let s = plot_id.to_string();
7630            format!("plot {}", s.get(..8).unwrap_or(s.as_str()))
7631        })
7632}
7633
7634/// Match `flatland_sim::economy_zones::snap_claim_rect`.
7635fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
7636    let a = x0.min(x1).floor();
7637    let b = y0.min(y1).floor();
7638    let mut c = x0.max(x1).ceil();
7639    let mut d = y0.max(y1).ceil();
7640    if (c - a) < 1.0 {
7641        c = a + 1.0;
7642    }
7643    if (d - b) < 1.0 {
7644        d = b + 1.0;
7645    }
7646    (a, b, c, d)
7647}
7648
7649fn humanize_template_id(template_id: &str) -> String {
7650    // UUID-style ids have no friendly words to title-case; avoid dumping them in UI.
7651    if looks_like_template_uuid(template_id) {
7652        return "Unknown item".into();
7653    }
7654    template_id
7655        .split('_')
7656        .map(|word| {
7657            let mut chars = word.chars();
7658            match chars.next() {
7659                None => String::new(),
7660                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
7661            }
7662        })
7663        .collect::<Vec<_>>()
7664        .join(" ")
7665}
7666
7667fn looks_like_template_uuid(template_id: &str) -> bool {
7668    let bytes = template_id.as_bytes();
7669    if bytes.len() != 36 {
7670        return false;
7671    }
7672    let is_hex = |b: u8| b.is_ascii_hexdigit();
7673    let groups = [8usize, 4, 4, 4, 12];
7674    let mut i = 0;
7675    for (gi, &len) in groups.iter().enumerate() {
7676        if gi > 0 {
7677            if bytes.get(i) != Some(&b'-') {
7678                return false;
7679            }
7680            i += 1;
7681        }
7682        for _ in 0..len {
7683            if !bytes.get(i).copied().is_some_and(is_hex) {
7684                return false;
7685            }
7686            i += 1;
7687        }
7688    }
7689    true
7690}
7691
7692/// Keep in sync with `flatland_sim::INTERACTION_RADIUS_M`.
7693const HARVEST_RANGE_M: f32 = 1.5;
7694
7695pub struct GameClient<S: PlayConnection> {
7696    session: S,
7697    seq: Seq,
7698    pub state: GameState,
7699    last_move_forward: f32,
7700    last_move_strafe: f32,
7701}
7702
7703impl<S: PlayConnection> GameClient<S> {
7704    pub fn new(session: S) -> Self {
7705        let session_id = session.session_id();
7706        let entity_id = session.entity_id();
7707        let mut client = Self {
7708            session,
7709            seq: 0,
7710            last_move_forward: 0.0,
7711            last_move_strafe: 0.0,
7712            state: GameState {
7713                session_id,
7714                entity_id,
7715                character_id: None,
7716                tick: 0,
7717                chunk_rev: 0,
7718                content_rev: 0,
7719                publish_rev: 0,
7720                entities: Vec::new(),
7721                player: None,
7722                resource_nodes: Vec::new(),
7723                ground_drops: Vec::new(),
7724                placed_containers: Vec::new(),
7725                buildings: Vec::new(),
7726                doors: Vec::new(),
7727                interior_map: None,
7728                npcs: Vec::new(),
7729                blueprints: Vec::new(),
7730                building_materials: Vec::new(),
7731                world_x0: 0.0,
7732                world_y0: 0.0,
7733                world_width_m: 0.0,
7734                world_height_m: 0.0,
7735                terrain_zones: Vec::new(),
7736                z_platforms: Vec::new(),
7737                z_transitions: Vec::new(),
7738                z_bands_outdoor_backup: None,
7739                world_clock: flatland_protocol::WorldClock::default(),
7740                inventory: std::collections::HashMap::new(),
7741                inventory_hints: std::collections::HashMap::new(),
7742                item_catalog: std::collections::HashMap::new(),
7743                logs: VecDeque::new(),
7744                intents_sent: 0,
7745                ticks_received: 0,
7746                connected: false,
7747                disconnect_reason: None,
7748                show_stats: false,
7749                hud_log_hidden: false,
7750                show_equip_menu: false,
7751                equip_menu_index: 0,
7752                show_craft_menu: false,
7753                show_plot_build_menu: false,
7754                plot_build_focus_wall: true,
7755                plot_build_wall_index: 0,
7756                plot_build_roof_index: 0,
7757                craft_menu_index: 0,
7758                craft_batch_quantity: 1,
7759                craft_tab: CraftTab::Ready,
7760                craft_filter: String::new(),
7761                craft_filter_focused: false,
7762                craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
7763                show_shop_menu: false,
7764                shop_catalog: None,
7765                bank_panel: None,
7766                bank_menu_index: 0,
7767                bank_ui_mode: BankUiMode::Menu,
7768                storage_panel: None,
7769                market_panel: None,
7770                market_menu_index: 0,
7771                market_filter: String::new(),
7772                market_filter_focused: false,
7773                market_category_filter: None,
7774                market_buy_confirm: None,
7775                market_ui_mode: MarketUiMode::Browse,
7776                storage_menu_index: 0,
7777                storage_ui_mode: StorageUiMode::Menu,
7778                shop_tab: ShopTab::default(),
7779                shop_menu_index: 0,
7780                shop_quantity: 1,
7781                shop_trade_log: VecDeque::new(),
7782                show_npc_verb_menu: false,
7783                npc_verb_target: None,
7784                npc_verb_index: 0,
7785                npc_verb_notice: None,
7786                player_verbs: crate::social::PlayerVerbState::default(),
7787                social_chat: crate::social::SocialChatState::default(),
7788                trade_ui: crate::social::TradeUiState::default(),
7789                whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
7790                show_npc_chat: false,
7791                npc_chat: None,
7792                show_inventory_menu: false,
7793                inventory_menu_index: 0,
7794                inventory_tab: InventoryTab::OnPerson,
7795                inventory_filter: String::new(),
7796                inventory_filter_focused: false,
7797                show_move_picker: false,
7798                show_rename_prompt: false,
7799                rename_plot_id: None,
7800                highlighted_plot_id: None,
7801                show_worker_rename: false,
7802                rename_buffer: String::new(),
7803                move_picker_index: 0,
7804                move_picker: None,
7805                show_grant_picker: false,
7806                grant_picker_index: 0,
7807                grant_picker: None,
7808                show_destroy_picker: false,
7809                destroy_confirm_pending: false,
7810                destroy_picker: None,
7811                combat_target: None,
7812                combat_target_label: None,
7813                ground_target: None,
7814                combat_fx: Vec::new(),
7815                ground_hazards: Vec::new(),
7816                property_zones: Vec::new(),
7817                tax_zones: Vec::new(),
7818                growth_zones: Vec::new(),
7819                biome_zones: Vec::new(),
7820                terrain_kind_nav: Vec::new(),
7821                property_plots: Vec::new(),
7822                property_plot_settings: None,
7823                claim_mode: None,
7824                relocate_mode: None,
7825                sell_plot_confirm: None,
7826                sell_plot_armed_at: None,
7827                show_plant_menu: false,
7828                plant_menu_index: 0,
7829                show_farm_access: false,
7830                farm_access_name_draft: String::new(),
7831                farm_access_discount_bps: 0,
7832                farm_access_index: 0,
7833                plant_quantity: 1,
7834                in_combat: false,
7835                auto_attack: true,
7836                combat_has_los: false,
7837                attack_cd_ticks: 0,
7838                gcd_ticks: 0,
7839                weapon_ability_id: "unarmed".into(),
7840                mainhand_template_id: None,
7841                mainhand_label: None,
7842                mainhand_instance_id: None,
7843                offhand_template_id: None,
7844                offhand_label: None,
7845                offhand_instance_id: None,
7846                mainhand_hand_slots: 1,
7847                defense: None,
7848                worn: BTreeMap::new(),
7849                carry_mass: 0.0,
7850                carry_mass_max: 0.0,
7851                encumbrance: flatland_protocol::EncumbranceState::Light,
7852                move_speed_mps: 0.0,
7853                move_speed_mult: 0.0,
7854                inventory_stacks: Vec::new(),
7855                keychain_stacks: Vec::new(),
7856                whisper_pouch_stacks: Vec::new(),
7857                combat_target_detail: None,
7858                statuses: Vec::new(),
7859                cast_progress: None,
7860                timed_channel: None,
7861                plot_build_offer: None,
7862                ability_cooldowns: Vec::new(),
7863                blocking_active: false,
7864                max_target_slots: 1,
7865                combat_slots: Vec::new(),
7866                rotation_presets: Vec::new(),
7867                known_abilities: Vec::new(),
7868                ability_meta: std::collections::HashMap::new(),
7869                ability_mastery: std::collections::HashMap::new(),
7870                hotbar: vec![None; 9],
7871                max_abilities_per_rotation: 0,
7872                show_loadout_menu: false,
7873                show_keychain_menu: false,
7874                keychain_menu_index: 0,
7875                show_rotation_editor: false,
7876                loadout_menu_index: 0,
7877                loadout_hotbar_slot: 1,
7878                loadout_ability_index: 0,
7879                loadout_focus_presets: false,
7880                rotation_editor: RotationEditorState::default(),
7881                harvest_in_progress: false,
7882                harvest_started_at: None,
7883                pending_craft_ack: None,
7884                craft_channel_blueprint_id: None,
7885                pending_worker_job_ack: None,
7886                attending_worker_instance_id: None,
7887                quest_log: Vec::new(),
7888                interactables: Vec::new(),
7889                ledger: None,
7890                career: None,
7891                character_sheet_tab: CharacterSheetTab::Character,
7892                ledger_period: LedgerPeriod::Day,
7893                show_quest_offer: false,
7894                pending_quest_offers: Vec::new(),
7895                quest_offer_index: 0,
7896                show_quest_menu: false,
7897                quest_menu_index: 0,
7898                quest_withdraw_confirm: false,
7899                hired_workers: Vec::new(),
7900                show_workers_menu: false,
7901                workers_menu_index: 0,
7902                worker_dismiss_confirmation: None,
7903                workers_menu_compact: false,
7904                worker_step_display: BTreeMap::new(),
7905                worker_error_display: BTreeMap::new(),
7906                worker_health_ring_until: BTreeMap::new(),
7907                pending_worker_hire_since: None,
7908                show_worker_give_picker: false,
7909                worker_give_picker_index: 0,
7910                worker_give_picker: None,
7911                show_worker_give_target_picker: false,
7912                worker_give_target_picker_index: 0,
7913                worker_give_target_picker: None,
7914                show_worker_take_picker: false,
7915                worker_take_picker_index: 0,
7916                worker_take_picker: None,
7917                show_worker_teach_picker: false,
7918                worker_teach_picker_index: 0,
7919                worker_teach_picker: None,
7920                worker_route_editor: None,
7921                progression_curve: None,
7922            },
7923        };
7924        client.state.apply_client_ui_prefs();
7925        client
7926    }
7927
7928    pub fn entity_id(&self) -> EntityId {
7929        self.state.entity_id
7930    }
7931
7932    pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
7933        if self.state.connected {
7934            return Ok(());
7935        }
7936
7937        loop {
7938            match self.session.next_event().await {
7939                Some(SessionEvent::Welcome {
7940                    session_id,
7941                    entity_id,
7942                    snapshot,
7943                }) => {
7944                    self.state
7945                        .restore_from_welcome(session_id, entity_id, &snapshot);
7946                    self.state.apply_client_ui_prefs();
7947                    self.state.push_log(format!(
7948                        "Connected — session {session_id}, entity {entity_id}"
7949                    ));
7950                    return Ok(());
7951                }
7952                Some(SessionEvent::Disconnected { .. }) => {
7953                    anyhow::bail!("disconnected before welcome");
7954                }
7955                Some(_) => continue,
7956                None => anyhow::bail!("session closed before welcome"),
7957            }
7958        }
7959    }
7960
7961    /// Drain all pending server events (non-blocking).
7962    pub fn drain_events(&mut self) {
7963        while let Some(event) = self.session.try_next_event() {
7964            if self.handle_event_sync(event).is_err() {
7965                break;
7966            }
7967        }
7968    }
7969
7970    /// Wait for the next server event.
7971    pub async fn next_event(&mut self) -> Option<SessionEvent> {
7972        self.session.next_event().await
7973    }
7974
7975    pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
7976        self.handle_event_sync(event)
7977    }
7978
7979    fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
7980        match event {
7981            SessionEvent::Welcome {
7982                session_id,
7983                entity_id,
7984                snapshot,
7985            } => {
7986                let resumed = self.state.connected;
7987                self.state
7988                    .restore_from_welcome(session_id, entity_id, &snapshot);
7989                if resumed {
7990                    self.state.push_log(format!(
7991                        "Session restored — session {session_id}, entity {entity_id}"
7992                    ));
7993                }
7994            }
7995            SessionEvent::ContentUpdated { snapshot } => {
7996                self.state
7997                    .apply_snapshot_fields(&snapshot, self.state.entity_id);
7998                self.state.push_log(format!(
7999                    "World updated (content rev {})",
8000                    snapshot.content_rev
8001                ));
8002            }
8003            SessionEvent::QuestCatalogUpdated(update) => {
8004                self.state.push_log(format!(
8005                    "Quest board updated (revision {}, {} new, {} retired)",
8006                    update.revision,
8007                    update.accepted.len(),
8008                    update.retired.len()
8009                ));
8010            }
8011            SessionEvent::Tick(delta) => {
8012                self.state.apply_tick_fields(&delta, self.state.entity_id);
8013                self.state.ticks_received += 1;
8014            }
8015            SessionEvent::IntentAck {
8016                entity_id,
8017                seq,
8018                tick,
8019            } => {
8020                crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
8021                if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
8022                    if *craft_seq == seq {
8023                        let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
8024                        if batches > 1 {
8025                            self.state.push_log(format!("Crafting {label} ×{batches}…"));
8026                        } else {
8027                            self.state.push_log(format!("Crafting {label}…"));
8028                        }
8029                    }
8030                }
8031                if self
8032                    .state
8033                    .pending_worker_job_ack
8034                    .as_ref()
8035                    .is_some_and(|p| p.seq == seq)
8036                {
8037                    let pending = self.state.pending_worker_job_ack.take().unwrap();
8038                    if pending.idle {
8039                        self.state.push_log(format!(
8040                            "Route cleared for {} — worker idle",
8041                            pending.worker_label
8042                        ));
8043                    } else {
8044                        self.state.push_log(format!(
8045                            "Route saved for {} — {} stop(s), job loop active",
8046                            pending.worker_label, pending.stop_count
8047                        ));
8048                    }
8049                    if self
8050                        .state
8051                        .worker_route_editor
8052                        .as_ref()
8053                        .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
8054                    {
8055                        self.close_worker_route_editor();
8056                    }
8057                }
8058            }
8059            SessionEvent::Chat(msg) => {
8060                let label = match msg.channel {
8061                    flatland_protocol::ChatChannel::Nearby => "nearby",
8062                    flatland_protocol::ChatChannel::Direct => "speak",
8063                    flatland_protocol::ChatChannel::Whisper => "whisper",
8064                    flatland_protocol::ChatChannel::WhisperStone => "stone",
8065                };
8066                let clarity = match msg.clarity {
8067                    flatland_protocol::ChatClarity::Clear => "",
8068                    flatland_protocol::ChatClarity::Partial => "~",
8069                    flatland_protocol::ChatClarity::Heavy => "…",
8070                };
8071                self.state.push_log(format!(
8072                    "[{label}{clarity}] {}: {}",
8073                    msg.from_name, msg.text
8074                ));
8075                let now_ms = std::time::SystemTime::now()
8076                    .duration_since(std::time::UNIX_EPOCH)
8077                    .map(|d| d.as_millis() as u64)
8078                    .unwrap_or(0);
8079                self.state
8080                    .social_chat
8081                    .note_speech(&msg, self.state.entity_id, now_ms);
8082                self.state
8083                    .social_chat
8084                    .push(crate::social::ChatLogEntry::from_message(
8085                        msg,
8086                        self.state.entity_id,
8087                    ));
8088            }
8089            SessionEvent::TradeOpened(panel) => {
8090                self.state.social_chat.pending_trade = None;
8091                let peer = panel.peer_name.clone();
8092                self.state.trade_ui.open(panel);
8093                self.state.social_chat.push_system(format!(
8094                    "Trade open with {peer} — p present · r ready · Esc cancel"
8095                ));
8096                self.state
8097                    .social_chat
8098                    .push_cue(crate::social::AudioCue::TradeOpened);
8099            }
8100            SessionEvent::TradeClosed { reason } => {
8101                self.state.push_log(reason.clone());
8102                self.state.social_chat.push_system(reason);
8103                self.state.trade_ui.close();
8104            }
8105            SessionEvent::HarvestResult(result) => {
8106                self.state.clear_harvest_state();
8107                crate::harvest_trace!(
8108                    entity_id = self.state.entity_id,
8109                    node_id = %result.node_id,
8110                    template = %result.item_template,
8111                    quantity = result.quantity,
8112                    client_tick = self.state.tick,
8113                    "client applied harvest result"
8114                );
8115                let msg = if result.quantity == 0 {
8116                    format!(
8117                        "Harvested {} x0 — nothing dropped (loot table rolled empty)",
8118                        result.item_template
8119                    )
8120                } else {
8121                    format!(
8122                        "Harvested {} x{} (on the ground — press P to pick up)",
8123                        result.item_template, result.quantity
8124                    )
8125                };
8126                self.state.push_log(msg);
8127            }
8128            SessionEvent::CraftResult(result) => {
8129                for stack in &result.consumed {
8130                    if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
8131                        *qty = qty.saturating_sub(stack.quantity);
8132                        if *qty == 0 {
8133                            self.state.inventory.remove(&stack.template_id);
8134                        }
8135                    }
8136                }
8137                for stack in &result.outputs {
8138                    *self
8139                        .state
8140                        .inventory
8141                        .entry(stack.template_id.clone())
8142                        .or_insert(0) += stack.quantity;
8143                }
8144                self.state.craft_record_completed(&result.blueprint_id);
8145                if let Some(output) = result.outputs.first() {
8146                    if result.batch_total > 1 {
8147                        self.state.push_log(format!(
8148                            "Crafted {} x{} ({}/{})",
8149                            output.template_id,
8150                            output.quantity,
8151                            result.batch_index,
8152                            result.batch_total
8153                        ));
8154                    } else {
8155                        self.state.push_log(format!(
8156                            "Crafted {} x{}",
8157                            output.template_id, output.quantity
8158                        ));
8159                    }
8160                } else {
8161                    self.state
8162                        .push_log(format!("Craft finished: {}", result.blueprint_id));
8163                }
8164            }
8165            SessionEvent::Death(notice) => {
8166                self.state.clear_harvest_state();
8167                self.state.push_log(notice.message.clone());
8168                self.state.push_log(format!(
8169                    "Respawned at ({:.1}, {:.1})",
8170                    notice.respawn_x, notice.respawn_y
8171                ));
8172            }
8173            SessionEvent::Interaction(notice) => {
8174                if notice.message.starts_with("Harvest failed:") {
8175                    self.state.clear_harvest_state();
8176                }
8177                if notice.message.starts_with("Can't do that:") {
8178                    self.state.pending_worker_hire_since = None;
8179                    self.state.pending_craft_ack = None;
8180                    self.state.craft_channel_blueprint_id = None;
8181                    if let Some(pending) = self.state.pending_worker_job_ack.take() {
8182                        if let Some(w) = self
8183                            .state
8184                            .hired_workers
8185                            .iter_mut()
8186                            .find(|w| w.instance_id == pending.worker_instance_id)
8187                        {
8188                            w.route = pending.prev_route;
8189                            w.mode = pending.prev_mode;
8190                            w.step_label = pending.prev_step_label;
8191                            w.last_error = pending.prev_last_error;
8192                        }
8193                        let reason = notice
8194                            .message
8195                            .strip_prefix("Can't do that:")
8196                            .unwrap_or(&notice.message)
8197                            .trim();
8198                        self.state.push_log(format!(
8199                            "Route save failed for {}: {reason}",
8200                            pending.worker_label
8201                        ));
8202                    }
8203                    let reason = notice
8204                        .message
8205                        .strip_prefix("Can't do that:")
8206                        .unwrap_or(&notice.message)
8207                        .trim();
8208                    if reason.contains("already tilled") {
8209                        if let Some(plot) = self.state.my_plot_under_player() {
8210                            self.state.sell_plot_confirm = Some(plot.plot_id);
8211                            self.state.sell_plot_armed_at = Some(Instant::now());
8212                        }
8213                    }
8214                }
8215                if notice.message.starts_with("Cast failed:") {
8216                    self.state.cast_progress = None;
8217                }
8218                if notice.message.contains("slain the") {
8219                    self.state.combat_target = None;
8220                    self.state.combat_target_label = None;
8221                }
8222                // Inbound trade request → inline Y/N in the CHAT column (no popup).
8223                if notice.message.contains("wants to trade") {
8224                    if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
8225                        let from_name = notice
8226                            .message
8227                            .split(" wants to trade")
8228                            .next()
8229                            .unwrap_or("Player")
8230                            .to_string();
8231                        self.state.social_chat.pending_trade =
8232                            Some(crate::social::PendingTradeRequest {
8233                                from_entity,
8234                                from_name: from_name.clone(),
8235                            });
8236                        self.state.social_chat.push_system(format!(
8237                            "{from_name} wants to trade — [Y] accept · [N] decline"
8238                        ));
8239                        self.state
8240                            .social_chat
8241                            .push_cue(crate::social::AudioCue::TradeOffer);
8242                    }
8243                }
8244                if notice.message.starts_with("trade request declined") {
8245                    self.state.social_chat.push_system(notice.message.clone());
8246                    self.state
8247                        .social_chat
8248                        .push_cue(crate::social::AudioCue::TradeDeclined);
8249                }
8250                // Verb dock hides the floating LOG — keep refuse lines on the panel + toast.
8251                if self.state.show_npc_verb_menu && !notice.message.trim().is_empty() {
8252                    self.state.npc_verb_notice = Some(notice.message.clone());
8253                    self.state
8254                        .social_chat
8255                        .push_cue(crate::social::AudioCue::UiError);
8256                }
8257                self.state.apply_interaction_notice(&notice);
8258                self.state.push_log(notice.message.clone());
8259            }
8260            SessionEvent::ShopOpened(catalog) => {
8261                self.state.apply_shop_catalog(catalog);
8262            }
8263            SessionEvent::BankOpened(panel) => {
8264                self.state.apply_bank_panel(panel);
8265            }
8266            SessionEvent::StorageOpened(panel) => {
8267                self.state.apply_storage_panel(panel);
8268            }
8269            SessionEvent::MarketOpened(panel) => {
8270                self.state.apply_market_panel(panel);
8271            }
8272            SessionEvent::NpcTalkOpened(opened) => {
8273                self.state.show_npc_verb_menu = false;
8274                if self.state.npc_verb_target.is_none() {
8275                    self.state.npc_verb_target = Some(opened.npc_id.clone());
8276                }
8277                let label = opened.npc_label.clone();
8278                let banner = if !opened.trade_allowed {
8279                    Some("Trade is unavailable right now.".to_string())
8280                } else {
8281                    None
8282                };
8283                self.state.show_npc_chat = true;
8284                self.state.npc_chat = Some(NpcChatState {
8285                    npc_id: opened.npc_id,
8286                    npc_label: opened.npc_label,
8287                    lines: if opened.greeting.is_empty() {
8288                        vec![]
8289                    } else {
8290                        vec![format!("{label}: {}", opened.greeting)]
8291                    },
8292                    input: String::new(),
8293                    pending: opened.greeting.is_empty(),
8294                    talk_depth: opened.talk_depth,
8295                    trade_allowed: opened.trade_allowed,
8296                    banner,
8297                    suggested_topics: opened.suggested_topics,
8298                });
8299            }
8300            SessionEvent::NpcTalkPending(_) => {
8301                if let Some(chat) = self.state.npc_chat.as_mut() {
8302                    chat.pending = true;
8303                }
8304            }
8305            SessionEvent::NpcTalkReply(reply) => {
8306                if let Some(chat) = self.state.npc_chat.as_mut() {
8307                    if chat.npc_id == reply.npc_id {
8308                        chat.pending = false;
8309                        if reply.trade_disabled {
8310                            chat.trade_allowed = false;
8311                            chat.banner = Some("Trade is unavailable right now.".to_string());
8312                        }
8313                        if reply.wind_down {
8314                            chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
8315                            if chat.banner.is_none() {
8316                                chat.banner =
8317                                    Some("They're wrapping up — keep it brief.".to_string());
8318                            }
8319                        }
8320                        chat.lines
8321                            .push(format!("{}: {}", chat.npc_label, reply.line));
8322                    }
8323                }
8324            }
8325            SessionEvent::NpcTalkClosed(closed) => {
8326                if self
8327                    .state
8328                    .npc_chat
8329                    .as_ref()
8330                    .is_some_and(|c| c.npc_id == closed.npc_id)
8331                {
8332                    self.state.show_npc_chat = false;
8333                    self.state.npc_chat = None;
8334                }
8335            }
8336            SessionEvent::NpcTalkError(err) => {
8337                self.state.push_log(format!("Talk failed: {}", err.reason));
8338                if let Some(chat) = self.state.npc_chat.as_mut() {
8339                    chat.pending = false;
8340                }
8341            }
8342            SessionEvent::UseResult(result) => {
8343                // Inventory count hint only — the Interaction notice already logs
8344                // "Consumed …" (and drives the gfx toast). Logging again here doubled toasts.
8345                if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
8346                    *qty = qty.saturating_sub(1);
8347                    if *qty == 0 {
8348                        self.state.inventory.remove(&result.template_id);
8349                    }
8350                }
8351            }
8352            SessionEvent::QuestOffer(offer) => {
8353                let title = offer.title.clone();
8354                self.state.push_quest_offer(offer);
8355                self.state.push_log(format!("Quest offered: {title}"));
8356            }
8357            SessionEvent::QuestAccepted(notice) => {
8358                self.state.remove_quest_offer(&notice.quest_id);
8359                self.state.push_log(notice.message);
8360            }
8361            SessionEvent::QuestWithdrawn(notice) => {
8362                self.state.show_quest_menu = false;
8363                self.state.quest_withdraw_confirm = false;
8364                self.state.push_log(notice.message);
8365            }
8366            SessionEvent::QuestStepCompleted(notice) => {
8367                self.state.push_log(notice.message);
8368            }
8369            SessionEvent::QuestCompleted(notice) => {
8370                self.state.push_log(notice.message);
8371            }
8372            SessionEvent::Disconnected { reason } => {
8373                self.state.clear_harvest_state();
8374                self.state.connected = false;
8375                self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
8376                if let Some(r) = &self.state.disconnect_reason {
8377                    self.state.push_log(format!("Disconnected: {r}"));
8378                } else {
8379                    self.state.push_log("Disconnected from server");
8380                }
8381            }
8382        }
8383        Ok(())
8384    }
8385
8386    pub fn is_connected(&self) -> bool {
8387        self.state.connected
8388    }
8389
8390    pub fn close_overlays(&mut self) {
8391        self.state.show_stats = false;
8392        self.state.show_craft_menu = false;
8393        self.state.show_plot_build_menu = false;
8394        self.state.show_shop_menu = false;
8395        self.state.shop_catalog = None;
8396        self.state.show_npc_verb_menu = false;
8397        self.state.npc_verb_target = None;
8398        self.state.show_npc_chat = false;
8399        self.state.npc_chat = None;
8400        self.state.show_inventory_menu = false;
8401        self.state.show_loadout_menu = false;
8402        self.state.show_rotation_editor = false;
8403        self.state.rotation_editor.reset();
8404        self.state.show_rename_prompt = false;
8405        self.state.show_worker_rename = false;
8406        self.state.rename_buffer.clear();
8407        self.state.show_move_picker = false;
8408        self.state.move_picker = None;
8409        self.state.show_destroy_picker = false;
8410        self.state.destroy_confirm_pending = false;
8411        self.state.destroy_picker = None;
8412        self.state.show_quest_offer = false;
8413        self.state.clear_quest_offers();
8414        self.state.show_quest_menu = false;
8415        self.state.quest_withdraw_confirm = false;
8416        self.state.show_workers_menu = false;
8417        self.close_worker_give_picker();
8418        self.close_worker_give_target_picker();
8419        self.close_worker_take_picker();
8420        self.close_worker_teach_picker();
8421        self.state.worker_route_editor = None;
8422        self.state.claim_mode = None;
8423        self.state.relocate_mode = None;
8424        self.state.sell_plot_confirm = None;
8425        self.state.sell_plot_armed_at = None;
8426        self.close_farm_access_panel();
8427        if self.state.show_plant_menu {
8428            self.close_plant_menu();
8429        }
8430    }
8431
8432    /// Esc / back — pop one UI layer instead of closing every overlay at once.
8433    pub fn back_on_esc(&mut self) -> bool {
8434        if self.state.social_chat.composer_open() {
8435            self.state.social_chat.close_composer();
8436            return true;
8437        }
8438        if self.state.player_verbs.open {
8439            self.state.player_verbs.close();
8440            return true;
8441        }
8442        if self.state.whisper_pouch_ui.open {
8443            self.state.whisper_pouch_ui.open = false;
8444            return true;
8445        }
8446        if self.state.trade_ui.panel.is_some() {
8447            // async cancel — caller should prefer trade_cancel; close UI optimistically
8448            self.state.trade_ui.close();
8449            return true;
8450        }
8451        if self.state.show_rename_prompt {
8452            self.cancel_rename_prompt();
8453            return true;
8454        }
8455        if self.state.show_worker_rename {
8456            self.cancel_worker_rename();
8457            return true;
8458        }
8459        if self.state.show_destroy_picker {
8460            if self.state.destroy_confirm_pending {
8461                self.cancel_destroy_confirm();
8462            } else {
8463                self.close_destroy_picker();
8464            }
8465            return true;
8466        }
8467        if self.state.claim_mode.is_some() {
8468            self.cancel_claim_mode();
8469            return true;
8470        }
8471        if self.state.relocate_mode.is_some() {
8472            self.cancel_relocate_mode();
8473            return true;
8474        }
8475        if self.state.show_plant_menu {
8476            self.close_plant_menu();
8477            return true;
8478        }
8479        if self.state.show_farm_access {
8480            self.close_farm_access_panel();
8481            return true;
8482        }
8483        if self.state.sell_plot_confirm.is_some() {
8484            self.state.sell_plot_confirm = None;
8485            self.state.sell_plot_armed_at = None;
8486            self.state.push_log("Sell cancelled");
8487            return true;
8488        }
8489        if self.state.show_move_picker {
8490            self.close_move_picker();
8491            return true;
8492        }
8493        if self.state.show_rotation_editor {
8494            match self.state.rotation_editor.mode {
8495                RotationEditorMode::List => {
8496                    self.state.show_rotation_editor = false;
8497                    self.state.rotation_editor.reset();
8498                }
8499                RotationEditorMode::EditLabel => {
8500                    self.state.rotation_editor.label_buffer.clear();
8501                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8502                }
8503                RotationEditorMode::PickAbility => {
8504                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8505                }
8506                RotationEditorMode::EditSequence => {
8507                    self.state.rotation_editor.draft = None;
8508                    self.state.rotation_editor.mode = RotationEditorMode::List;
8509                }
8510            }
8511            return true;
8512        }
8513        if self.state.show_inventory_menu {
8514            self.close_inventory_menu();
8515            return true;
8516        }
8517        if self.state.show_craft_menu {
8518            self.close_craft_menu();
8519            return true;
8520        }
8521        if self.state.show_plot_build_menu {
8522            self.close_plot_build_menu();
8523            return true;
8524        }
8525        if self.state.show_keychain_menu {
8526            self.close_keychain_menu();
8527            return true;
8528        }
8529        if self.state.show_quest_offer {
8530            self.quest_offer_decline();
8531            return true;
8532        }
8533        if self.state.show_shop_menu {
8534            // Caller must await shop close (npc_interaction_back / play-loop).
8535            return false;
8536        }
8537        if self.state.bank_panel.is_some() {
8538            return false;
8539        }
8540        if self.state.storage_panel.is_some() {
8541            return false;
8542        }
8543        if self.state.market_panel.is_some() {
8544            return false;
8545        }
8546        if self.state.show_npc_chat {
8547            // play.rs calls npc_interaction_back().await on Esc
8548            return false;
8549        }
8550        if self.state.show_npc_verb_menu {
8551            self.state.show_npc_verb_menu = false;
8552            self.state.npc_verb_target = None;
8553            self.state.npc_verb_notice = None;
8554            return true;
8555        }
8556        if self.state.show_quest_menu {
8557            if self.state.quest_withdraw_confirm {
8558                self.state.quest_withdraw_confirm = false;
8559            } else {
8560                self.state.show_quest_menu = false;
8561            }
8562            return true;
8563        }
8564        if self.state.worker_route_editor.is_some() {
8565            // Pop one sheet level; only close the editor at the root stop list.
8566            if self.re_at_root_sheet() {
8567                let reopen = self.state.attending_worker_instance_id.clone();
8568                self.close_worker_route_editor();
8569                if let Some(id) = reopen {
8570                    if let Some(idx) = self
8571                        .state
8572                        .hired_workers
8573                        .iter()
8574                        .position(|w| w.instance_id == id)
8575                    {
8576                        self.state.workers_menu_index = idx;
8577                        self.state.show_workers_menu = true;
8578                    }
8579                }
8580            } else {
8581                self.re_sheet_back();
8582            }
8583            return true;
8584        }
8585        if self.state.show_worker_give_picker {
8586            self.close_worker_give_picker();
8587            return true;
8588        }
8589        if self.state.show_worker_give_target_picker {
8590            self.close_worker_give_target_picker();
8591            return true;
8592        }
8593        if self.state.show_worker_take_picker {
8594            self.close_worker_take_picker();
8595            return true;
8596        }
8597        if self.state.show_worker_teach_picker {
8598            self.close_worker_teach_picker();
8599            return true;
8600        }
8601        if self.state.show_workers_menu {
8602            self.close_workers_menu_ui();
8603            return true;
8604        }
8605        if self.state.show_loadout_menu {
8606            self.state.show_loadout_menu = false;
8607            return true;
8608        }
8609        if self.state.show_stats {
8610            self.state.show_stats = false;
8611            return true;
8612        }
8613        if self.state.show_equip_menu {
8614            self.state.show_equip_menu = false;
8615            return true;
8616        }
8617        false
8618    }
8619
8620    pub fn toggle_stats(&mut self) {
8621        self.state.show_stats = !self.state.show_stats;
8622        if self.state.show_stats {
8623            self.state.character_sheet_tab = CharacterSheetTab::Character;
8624            self.state.show_craft_menu = false;
8625            self.state.show_shop_menu = false;
8626            self.state.shop_catalog = None;
8627            self.state.show_inventory_menu = false;
8628            self.state.show_equip_menu = false;
8629        }
8630    }
8631
8632    pub fn toggle_equip_menu(&mut self) {
8633        self.state.show_equip_menu = !self.state.show_equip_menu;
8634        if self.state.show_equip_menu {
8635            self.state.show_stats = false;
8636            self.state.show_craft_menu = false;
8637            self.state.show_shop_menu = false;
8638            self.state.shop_catalog = None;
8639            self.state.show_inventory_menu = false;
8640            self.state.show_loadout_menu = false;
8641        }
8642    }
8643
8644    pub fn cycle_character_sheet_tab(&mut self) {
8645        if self.state.show_stats {
8646            self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
8647        }
8648    }
8649
8650    pub fn set_ledger_period_digit(&mut self, c: char) {
8651        if self.state.show_stats {
8652            if let Some(p) = LedgerPeriod::from_digit(c) {
8653                self.state.ledger_period = p;
8654                self.state.character_sheet_tab = CharacterSheetTab::Ledger;
8655            }
8656        }
8657    }
8658
8659    pub fn cycle_ledger_period(&mut self) {
8660        if self.state.show_stats && self.state.character_sheet_tab == CharacterSheetTab::Ledger {
8661            self.state.ledger_period = self.state.ledger_period.cycle();
8662        }
8663    }
8664
8665    pub fn open_inventory_menu(&mut self) {
8666        self.state.show_inventory_menu = true;
8667        self.state.show_craft_menu = false;
8668        self.state.show_shop_menu = false;
8669        self.state.shop_catalog = None;
8670        self.state.show_stats = false;
8671        self.state.show_move_picker = false;
8672        self.state.move_picker = None;
8673        self.state.show_destroy_picker = false;
8674        self.state.destroy_confirm_pending = false;
8675        self.state.destroy_picker = None;
8676        self.state.show_rename_prompt = false;
8677        self.state.rename_plot_id = None;
8678        self.state.rename_buffer.clear();
8679        self.state.inventory_filter_focused = false;
8680        self.state.clamp_inventory_indices();
8681    }
8682
8683    pub fn close_inventory_menu(&mut self) {
8684        self.state.show_inventory_menu = false;
8685        self.state.show_move_picker = false;
8686        self.state.move_picker = None;
8687        self.close_grant_picker();
8688        self.state.show_destroy_picker = false;
8689        self.state.destroy_confirm_pending = false;
8690        self.state.destroy_picker = None;
8691        self.state.show_rename_prompt = false;
8692        self.state.rename_plot_id = None;
8693        self.state.rename_buffer.clear();
8694        self.state.inventory_filter_focused = false;
8695    }
8696
8697    pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
8698        let Some(row) = self.state.inventory_selected_row() else {
8699            anyhow::bail!("inventory empty");
8700        };
8701        if GameState::is_property_deed_template(&row.stack.template_id) {
8702            let Some(plot_id) = GameState::deed_plot_id(&row.stack) else {
8703                anyhow::bail!("deed has no plot id");
8704            };
8705            let label = self
8706                .state
8707                .property_plots
8708                .iter()
8709                .find(|p| p.plot_id == plot_id)
8710                .map(|p| {
8711                    if p.label.trim().is_empty() {
8712                        p.plot_code.clone()
8713                    } else {
8714                        p.label.clone()
8715                    }
8716                })
8717                .unwrap_or_else(|| {
8718                    row.stack
8719                        .display_name
8720                        .clone()
8721                        .unwrap_or_else(|| "plot".into())
8722                });
8723            self.state.rename_buffer = label;
8724            self.state.rename_plot_id = Some(plot_id);
8725            self.state.highlighted_plot_id = Some(plot_id);
8726            self.state.show_rename_prompt = true;
8727            self.state.show_worker_rename = false;
8728            self.state.show_move_picker = false;
8729            self.state.show_destroy_picker = false;
8730            self.state.destroy_confirm_pending = false;
8731            return Ok(());
8732        }
8733        if !self.state.row_is_renameable_container(&row) {
8734            anyhow::bail!("only storage containers or deeds can be renamed");
8735        }
8736        let current = row
8737            .stack
8738            .display_name
8739            .clone()
8740            .unwrap_or_else(|| row.stack.template_id.clone());
8741        self.state.rename_buffer = current;
8742        self.state.rename_plot_id = None;
8743        self.state.show_rename_prompt = true;
8744        self.state.show_worker_rename = false;
8745        self.state.show_move_picker = false;
8746        self.state.show_destroy_picker = false;
8747        self.state.destroy_confirm_pending = false;
8748        Ok(())
8749    }
8750
8751    /// Rename the owned plot under the player's feet (`n` in world).
8752    pub fn open_plot_rename_under_player(&mut self) -> anyhow::Result<()> {
8753        let Some(plot) = self.state.my_plot_under_player().cloned() else {
8754            anyhow::bail!("stand on your plot to rename it");
8755        };
8756        let label = if plot.label.trim().is_empty() {
8757            plot.plot_code.clone()
8758        } else {
8759            plot.label.clone()
8760        };
8761        self.state.rename_buffer = label;
8762        self.state.rename_plot_id = Some(plot.plot_id);
8763        self.state.highlighted_plot_id = Some(plot.plot_id);
8764        self.state.show_rename_prompt = true;
8765        self.state.show_worker_rename = false;
8766        Ok(())
8767    }
8768
8769    pub fn cancel_rename_prompt(&mut self) {
8770        self.state.show_rename_prompt = false;
8771        self.state.rename_plot_id = None;
8772        self.state.rename_buffer.clear();
8773    }
8774
8775    pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
8776        let name = self.state.rename_buffer.trim().to_string();
8777        if name.is_empty() {
8778            anyhow::bail!("name cannot be empty");
8779        }
8780        if let Some(plot_id) = self.state.rename_plot_id {
8781            if name.chars().count() > 48 {
8782                anyhow::bail!("label must be 1–48 characters");
8783            }
8784            self.seq += 1;
8785            self.session
8786                .submit_intent(Intent::RenamePropertyPlot {
8787                    entity_id: self.state.entity_id,
8788                    plot_id,
8789                    label: name,
8790                    seq: self.seq,
8791                })
8792                .await?;
8793            self.state.intents_sent += 1;
8794            self.state.show_rename_prompt = false;
8795            self.state.rename_plot_id = None;
8796            self.state.rename_buffer.clear();
8797            return Ok(());
8798        }
8799        if name.chars().count() > 32 {
8800            anyhow::bail!("name must be 1–32 characters");
8801        }
8802        let Some(row) = self.state.inventory_selected_row() else {
8803            anyhow::bail!("inventory empty");
8804        };
8805        let Some(instance_id) = row.stack.item_instance_id else {
8806            anyhow::bail!("item has no instance id");
8807        };
8808        self.seq += 1;
8809        self.session
8810            .submit_intent(Intent::RenameContainer {
8811                entity_id: self.state.entity_id,
8812                item_instance_id: instance_id,
8813                location: row.from.clone(),
8814                name,
8815                seq: self.seq,
8816            })
8817            .await?;
8818        self.state.intents_sent += 1;
8819        self.state.show_rename_prompt = false;
8820        self.state.rename_buffer.clear();
8821        Ok(())
8822    }
8823
8824    pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
8825        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
8826            anyhow::bail!("no worker selected");
8827        };
8828        self.state.rename_buffer = worker.label.clone();
8829        self.state.show_worker_rename = true;
8830        self.state.show_rename_prompt = false;
8831        Ok(())
8832    }
8833
8834    pub fn cancel_worker_rename(&mut self) {
8835        self.state.show_worker_rename = false;
8836        self.state.rename_buffer.clear();
8837    }
8838
8839    pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
8840        let name = self.state.rename_buffer.trim().to_string();
8841        if name.is_empty() {
8842            anyhow::bail!("name cannot be empty");
8843        }
8844        if name.chars().count() > 32 {
8845            anyhow::bail!("name must be 1–32 characters");
8846        }
8847        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
8848            anyhow::bail!("no worker selected");
8849        };
8850        let worker_instance_id = worker.instance_id.clone();
8851        self.seq += 1;
8852        self.session
8853            .submit_intent(Intent::RenameHiredWorker {
8854                entity_id: self.state.entity_id,
8855                worker_instance_id: worker_instance_id.clone(),
8856                name: name.clone(),
8857                seq: self.seq,
8858            })
8859            .await?;
8860        self.state.intents_sent += 1;
8861        if let Some(w) = self
8862            .state
8863            .hired_workers
8864            .iter_mut()
8865            .find(|w| w.instance_id == worker_instance_id)
8866        {
8867            w.label = name.clone();
8868        }
8869        if let Some(ed) = self.state.worker_route_editor.as_mut() {
8870            if ed.worker_instance_id == worker_instance_id {
8871                ed.worker_label = name.clone();
8872            }
8873        }
8874        self.state.show_worker_rename = false;
8875        self.state.rename_buffer.clear();
8876        self.state.push_log(format!("Renamed worker to \"{name}\""));
8877        Ok(())
8878    }
8879
8880    pub fn toggle_inventory_menu(&mut self) {
8881        if self.state.show_inventory_menu {
8882            self.close_inventory_menu();
8883        } else {
8884            self.open_inventory_menu();
8885        }
8886    }
8887
8888    /// ↑/↓ in the inventory browser, or within the "move to…" / grant picker when open.
8889    pub fn inventory_menu_move(&mut self, delta: i32) {
8890        if self.state.show_grant_picker {
8891            let Some(picker) = self.state.grant_picker.as_ref() else {
8892                return;
8893            };
8894            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8895            let filter = picker.filter.clone();
8896            let n = labels.len();
8897            if n == 0 {
8898                return;
8899            }
8900            self.state.grant_picker_index =
8901                step_filtered_index(self.state.grant_picker_index, delta, n, |i| {
8902                    list_label_matches(&labels[i], &filter)
8903                });
8904            return;
8905        }
8906        if self.state.show_move_picker {
8907            let Some(picker) = self.state.move_picker.as_ref() else {
8908                return;
8909            };
8910            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8911            let filter = picker.filter.clone();
8912            let n = labels.len();
8913            if n == 0 {
8914                return;
8915            }
8916            self.state.move_picker_index =
8917                step_filtered_index(self.state.move_picker_index, delta, n, |i| {
8918                    list_label_matches(&labels[i], &filter)
8919                });
8920            self.state.clamp_move_picker_quantity();
8921            return;
8922        }
8923        let n = self.state.inventory_selectable_rows().len();
8924        if n == 0 {
8925            return;
8926        }
8927        let idx = self.state.inventory_menu_index as i32;
8928        self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8929    }
8930
8931    /// PageUp / PageDown (±1 page) for inventory / move / grant lists.
8932    pub fn inventory_menu_page(&mut self, pages: i32) {
8933        if self.state.show_grant_picker {
8934            let Some(picker) = self.state.grant_picker.as_ref() else {
8935                return;
8936            };
8937            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8938            let filter = picker.filter.clone();
8939            let n = labels.len();
8940            self.state.grant_picker_index =
8941                page_filtered_index(self.state.grant_picker_index, pages, n, |i| {
8942                    list_label_matches(&labels[i], &filter)
8943                });
8944            return;
8945        }
8946        if self.state.show_move_picker {
8947            let Some(picker) = self.state.move_picker.as_ref() else {
8948                return;
8949            };
8950            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8951            let filter = picker.filter.clone();
8952            let n = labels.len();
8953            self.state.move_picker_index =
8954                page_filtered_index(self.state.move_picker_index, pages, n, |i| {
8955                    list_label_matches(&labels[i], &filter)
8956                });
8957            self.state.clamp_move_picker_quantity();
8958            return;
8959        }
8960        let n = self.state.inventory_selectable_rows().len();
8961        self.state.inventory_menu_index =
8962            page_list_index(self.state.inventory_menu_index, pages, n);
8963    }
8964
8965    pub fn cycle_inventory_tab(&mut self, forward: bool) {
8966        if self.state.show_move_picker
8967            || self.state.show_grant_picker
8968            || self.state.show_destroy_picker
8969            || self.state.show_rename_prompt
8970            || self.state.inventory_filter_focused
8971        {
8972            return;
8973        }
8974        self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
8975        self.state.inventory_menu_index = 0;
8976        self.state.clamp_inventory_indices();
8977    }
8978
8979    pub fn focus_inventory_filter(&mut self) {
8980        if self.state.show_grant_picker {
8981            if let Some(p) = self.state.grant_picker.as_mut() {
8982                p.filter_focused = true;
8983            }
8984            return;
8985        }
8986        if self.state.show_move_picker {
8987            if let Some(p) = self.state.move_picker.as_mut() {
8988                p.filter_focused = true;
8989            }
8990            return;
8991        }
8992        self.state.inventory_filter_focused = true;
8993    }
8994
8995    pub fn set_inventory_filter(&mut self, filter: String) {
8996        self.state.inventory_filter = filter;
8997        self.state.inventory_menu_index = 0;
8998        self.state.clamp_inventory_indices();
8999    }
9000
9001    pub fn append_inventory_filter_char(&mut self, ch: char) {
9002        if !is_list_filter_char(ch) {
9003            return;
9004        }
9005        if self.state.show_grant_picker {
9006            if let Some(p) = self.state.grant_picker.as_mut() {
9007                if p.filter_focused {
9008                    p.filter.push(ch);
9009                    self.state.grant_picker_index = 0;
9010                }
9011            }
9012            return;
9013        }
9014        if self.state.show_move_picker {
9015            if let Some(p) = self.state.move_picker.as_mut() {
9016                if p.filter_focused {
9017                    p.filter.push(ch);
9018                    self.state.move_picker_index = 0;
9019                    self.state.clamp_move_picker_quantity();
9020                }
9021            }
9022            return;
9023        }
9024        if !self.state.inventory_filter_focused {
9025            return;
9026        }
9027        self.state.inventory_filter.push(ch);
9028        self.state.inventory_menu_index = 0;
9029        self.state.clamp_inventory_indices();
9030    }
9031
9032    pub fn inventory_filter_backspace(&mut self) {
9033        if self.state.show_grant_picker {
9034            if let Some(p) = self.state.grant_picker.as_mut() {
9035                if p.filter_focused {
9036                    p.filter.pop();
9037                    self.state.grant_picker_index = 0;
9038                }
9039            }
9040            return;
9041        }
9042        if self.state.show_move_picker {
9043            if let Some(p) = self.state.move_picker.as_mut() {
9044                if p.filter_focused {
9045                    p.filter.pop();
9046                    self.state.move_picker_index = 0;
9047                    self.state.clamp_move_picker_quantity();
9048                }
9049            }
9050            return;
9051        }
9052        if !self.state.inventory_filter_focused {
9053            return;
9054        }
9055        self.state.inventory_filter.pop();
9056        self.state.inventory_menu_index = 0;
9057        self.state.clamp_inventory_indices();
9058    }
9059
9060    /// Esc while filter focused: clear filter then blur. Returns true if handled.
9061    pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
9062        if self.state.show_grant_picker {
9063            if let Some(p) = self.state.grant_picker.as_mut() {
9064                if p.filter_focused {
9065                    if !p.filter.is_empty() {
9066                        p.filter.clear();
9067                        self.state.grant_picker_index = 0;
9068                    } else {
9069                        p.filter_focused = false;
9070                    }
9071                    return true;
9072                }
9073                if !p.filter.is_empty() {
9074                    p.filter.clear();
9075                    self.state.grant_picker_index = 0;
9076                    return true;
9077                }
9078            }
9079            return false;
9080        }
9081        if self.state.show_move_picker {
9082            if let Some(p) = self.state.move_picker.as_mut() {
9083                if p.filter_focused {
9084                    if !p.filter.is_empty() {
9085                        p.filter.clear();
9086                        self.state.move_picker_index = 0;
9087                        self.state.clamp_move_picker_quantity();
9088                    } else {
9089                        p.filter_focused = false;
9090                    }
9091                    return true;
9092                }
9093                if !p.filter.is_empty() {
9094                    p.filter.clear();
9095                    self.state.move_picker_index = 0;
9096                    self.state.clamp_move_picker_quantity();
9097                    return true;
9098                }
9099            }
9100            return false;
9101        }
9102        if self.state.inventory_filter_focused {
9103            if !self.state.inventory_filter.is_empty() {
9104                self.state.inventory_filter.clear();
9105                self.state.inventory_menu_index = 0;
9106                self.state.clamp_inventory_indices();
9107            } else {
9108                self.state.inventory_filter_focused = false;
9109            }
9110            return true;
9111        }
9112        if !self.state.inventory_filter.is_empty() {
9113            self.state.inventory_filter.clear();
9114            self.state.inventory_menu_index = 0;
9115            self.state.clamp_inventory_indices();
9116            return true;
9117        }
9118        false
9119    }
9120
9121    pub fn craft_menu_page(&mut self, pages: i32) {
9122        let n = self.state.craft_filtered_indices().len();
9123        self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
9124        self.state.clamp_craft_batch_quantity();
9125    }
9126
9127    pub fn shop_menu_page(&mut self, pages: i32) {
9128        let n = self.state.shop_list_len();
9129        self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
9130        self.state.clamp_shop_quantity();
9131    }
9132
9133    pub fn workers_menu_page(&mut self, pages: i32) {
9134        let n = self.state.hired_workers.len();
9135        self.state.workers_menu_index = page_list_index(self.state.workers_menu_index, pages, n);
9136    }
9137
9138    /// Enter: unequip a worn bag, equip a weapon, wear/place a loose bag or
9139    /// chest — or fall back to the "move to…" destination picker (including
9140    /// loose consumables; pick "Use" in that list or press `e` to eat/drink).
9141    /// Placed chest shells open a pick-up destination picker (`l` still locks).
9142    pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
9143        if self.state.show_destroy_picker {
9144            if self.state.destroy_confirm_pending {
9145                return self.confirm_destroy_item().await;
9146            }
9147            return self.request_destroy_confirm();
9148        }
9149        if self.state.show_grant_picker {
9150            return self.confirm_grant_picker().await;
9151        }
9152        if self.state.show_move_picker {
9153            return self.confirm_move_picker().await;
9154        }
9155        let Some(row) = self.state.inventory_selected_row() else {
9156            anyhow::bail!("inventory empty");
9157        };
9158        if row.is_equip_shell {
9159            let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
9160                anyhow::bail!("not a worn item");
9161            };
9162            return self.equip_worn(slot, None).await;
9163        }
9164        if row.is_chest_shell {
9165            return self.open_chest_pickup_picker();
9166        }
9167        let template_id = row.stack.template_id.clone();
9168        let instance_id = row.stack.item_instance_id;
9169        let category = self.state.inventory_item_category(&template_id);
9170        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9171
9172        if category == Some("weapon") {
9173            return self.equip_mainhand(Some(template_id)).await;
9174        }
9175        if category == Some("lodging") && on_person {
9176            if let Some(inst) = instance_id {
9177                return self.place_container(inst).await;
9178            }
9179        }
9180        // Placeable workshop tools (kiln, furnace) — Enter places them like chests.
9181        if on_person {
9182            if let Some(inst) = instance_id {
9183                if row.stack.world_placeable == Some(true) {
9184                    return self.place_container(inst).await;
9185                }
9186            }
9187        }
9188        if (category == Some("container") || category == Some("armor")) && on_person {
9189            if let Some(inst) = instance_id {
9190                let world_placeable =
9191                    row.stack.world_placeable == Some(true) || template_id.contains("chest");
9192                if world_placeable {
9193                    return self.place_container(inst).await;
9194                }
9195                // Pouches no longer equip directly — they clip onto a worn belt's loops
9196                // instead, so fall through to the move picker (offers "belt loop" when a
9197                // belt is worn). Backpacks/belts/armor equip straight to their body slot.
9198                if let Some(slot) = guess_body_slot(&template_id) {
9199                    return self.equip_worn(slot, Some(inst)).await;
9200                }
9201            }
9202        }
9203        // Anything else (materials, consumables, pouches, items nested in a bag/chest,
9204        // weapons you'd rather stash than wield, ...) — offer explicit places to move it
9205        // instead of guessing.
9206        self.open_move_picker()
9207    }
9208
9209    /// `e`: eat/drink a consumable, or open grant-target picker for grant oils/scrolls.
9210    pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
9211        let Some(row) = self.state.inventory_selected_row() else {
9212            anyhow::bail!("inventory empty");
9213        };
9214        if row.from != flatland_protocol::InventoryLocation::Root {
9215            anyhow::bail!("select a consumable on your person");
9216        }
9217        if GameState::stack_is_item_grant(&row.stack) {
9218            return self.open_grant_target_picker();
9219        }
9220        if GameState::is_property_deed_template(&row.stack.template_id) {
9221            return self.open_move_picker();
9222        }
9223        let category = self.state.inventory_item_category(&row.stack.template_id);
9224        if category != Some("consumable") && !GameState::stack_is_serving(&row.stack) {
9225            anyhow::bail!("selected item is not usable");
9226        }
9227        self.use_item(&row.stack.template_id).await
9228    }
9229
9230    /// Open picker: apply selected grant item onto inventory / worn gear.
9231    pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
9232        let Some(row) = self.state.inventory_selected_row() else {
9233            anyhow::bail!("inventory empty");
9234        };
9235        if row.from != flatland_protocol::InventoryLocation::Root {
9236            anyhow::bail!("select a grant item on your person");
9237        }
9238        if !GameState::stack_is_item_grant(&row.stack) {
9239            anyhow::bail!("selected item does not grant onto gear");
9240        }
9241        let Some(grant_instance_id) = row.stack.item_instance_id else {
9242            anyhow::bail!("grant has no instance id");
9243        };
9244        let effect_id = GameState::grant_effect_id(&row.stack)
9245            .unwrap_or("?")
9246            .to_string();
9247        let mode = GameState::grant_mode(&row.stack).to_string();
9248        let options = self.state.grant_target_options(&row.stack);
9249        if options.is_empty() {
9250            anyhow::bail!("no valid gear to apply {effect_id} to");
9251        }
9252        let grant_label = row
9253            .stack
9254            .display_name
9255            .clone()
9256            .unwrap_or_else(|| row.stack.template_id.clone());
9257        self.state.show_grant_picker = true;
9258        self.state.grant_picker_index = 0;
9259        self.state.grant_picker = Some(GrantTargetPicker {
9260            grant_instance_id,
9261            grant_label,
9262            effect_id,
9263            mode,
9264            options,
9265            filter: String::new(),
9266            filter_focused: false,
9267        });
9268        Ok(())
9269    }
9270
9271    pub fn close_grant_picker(&mut self) {
9272        self.state.show_grant_picker = false;
9273        self.state.grant_picker = None;
9274        self.state.grant_picker_index = 0;
9275    }
9276
9277    pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
9278        let Some(picker) = self.state.grant_picker.clone() else {
9279            self.close_grant_picker();
9280            return Ok(());
9281        };
9282        let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
9283            self.close_grant_picker();
9284            return Ok(());
9285        };
9286        self.close_grant_picker();
9287        self.use_grant(picker.grant_instance_id, opt.target_instance_id)
9288            .await?;
9289        self.state
9290            .push_log(format!("Applying {} onto {}…", picker.effect_id, opt.label));
9291        Ok(())
9292    }
9293
9294    /// `m`: always open the "move to…" picker for the selected item, even for
9295    /// weapons/wearables that Enter would otherwise equip/wear directly.
9296    /// Placed chests open the pick-up destination picker instead.
9297    pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
9298        let Some(row) = self.state.inventory_selected_row() else {
9299            anyhow::bail!("inventory empty");
9300        };
9301        if row.is_equip_shell {
9302            anyhow::bail!("this is a worn bag — press Enter to unequip it");
9303        }
9304        if row.is_chest_shell {
9305            return self.open_chest_pickup_picker();
9306        }
9307        let Some(instance_id) = row.stack.item_instance_id else {
9308            anyhow::bail!("item has no instance id");
9309        };
9310        let mut options = self.state.move_destinations_for(
9311            &row.from,
9312            row.from_parent_instance_id,
9313            row.stack.item_instance_id,
9314            &row.stack.template_id,
9315        );
9316        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9317        let category = self.state.inventory_item_category(&row.stack.template_id);
9318        if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
9319            if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
9320                options.insert(
9321                    0,
9322                    MoveOption {
9323                        label: "Sell plot to crown…".into(),
9324                        kind: MoveOptionKind::SellPlotToCrown { plot_id },
9325                    },
9326                );
9327            }
9328        }
9329        if on_person && category == Some("consumable") {
9330            if GameState::stack_is_item_grant(&row.stack) {
9331                options.insert(
9332                    0,
9333                    MoveOption {
9334                        label: "Apply onto gear…".into(),
9335                        kind: MoveOptionKind::GrantApply,
9336                    },
9337                );
9338            } else {
9339                let study = GameState::stack_is_blueprint_scroll(&row.stack);
9340                options.insert(
9341                    0,
9342                    MoveOption {
9343                        label: if study {
9344                            "Study".into()
9345                        } else {
9346                            "Use (eat / drink)".into()
9347                        },
9348                        kind: MoveOptionKind::Use,
9349                    },
9350                );
9351            }
9352        } else if on_person && GameState::stack_is_serving(&row.stack) {
9353            let label = if GameState::stack_is_food_serving(&row.stack) {
9354                "Use (eat)"
9355            } else {
9356                "Use (fill / drink)"
9357            };
9358            options.insert(
9359                0,
9360                MoveOption {
9361                    label: label.into(),
9362                    kind: MoveOptionKind::Use,
9363                },
9364            );
9365        }
9366        let item_label = row
9367            .stack
9368            .display_name
9369            .clone()
9370            .unwrap_or_else(|| row.stack.template_id.clone());
9371        // Default to 1 so withdrawing from storage is partial unless the player
9372        // presses `a` for max fit (full stack when the destination allows it).
9373        let initial_qty = if row.stack.quantity > 1 {
9374            1
9375        } else {
9376            row.stack.quantity
9377        };
9378        self.state.move_picker = Some(MovePicker {
9379            item_instance_id: instance_id,
9380            from: row.from,
9381            item_label,
9382            template_id: row.stack.template_id.clone(),
9383            stack_quantity: row.stack.quantity,
9384            quantity: initial_qty.max(1),
9385            options,
9386            filter: String::new(),
9387            filter_focused: false,
9388        });
9389        self.state.move_picker_index = 0;
9390        self.state.show_move_picker = true;
9391        self.state.show_destroy_picker = false;
9392        self.state.destroy_confirm_pending = false;
9393        self.state.destroy_picker = None;
9394        self.state.clamp_move_picker_quantity();
9395        Ok(())
9396    }
9397
9398    /// Enter/`m` on a placed chest shell: pick destinations to take it into inventory.
9399    pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
9400        let Some(row) = self.state.inventory_selected_row() else {
9401            anyhow::bail!("inventory empty");
9402        };
9403        if !row.is_chest_shell {
9404            anyhow::bail!("not a placed chest");
9405        }
9406        let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
9407            anyhow::bail!("not a placed chest");
9408        };
9409        let Some(instance_id) = row.stack.item_instance_id else {
9410            anyhow::bail!("chest has no instance id");
9411        };
9412        let chest = self
9413            .state
9414            .placed_containers
9415            .iter()
9416            .find(|c| c.id == *container_id)
9417            .cloned()
9418            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
9419        let (px, py) = self.state.player_position();
9420        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
9421            anyhow::bail!("too far from {}", chest.display_name);
9422        }
9423        if chest.locked && !chest.accessible {
9424            anyhow::bail!(
9425                "need the matching key for {} before picking it up",
9426                chest.display_name
9427            );
9428        }
9429        let options = self.state.chest_pickup_destinations(container_id);
9430        let item_label = row
9431            .stack
9432            .display_name
9433            .clone()
9434            .unwrap_or_else(|| row.stack.template_id.clone());
9435        self.state.move_picker = Some(MovePicker {
9436            item_instance_id: instance_id,
9437            from: row.from.clone(),
9438            item_label,
9439            template_id: row.stack.template_id.clone(),
9440            stack_quantity: 1,
9441            quantity: 1,
9442            options,
9443            filter: String::new(),
9444            filter_focused: false,
9445        });
9446        self.state.move_picker_index = 0;
9447        self.state.show_move_picker = true;
9448        self.state.show_destroy_picker = false;
9449        self.state.destroy_confirm_pending = false;
9450        self.state.destroy_picker = None;
9451        Ok(())
9452    }
9453
9454    pub fn close_move_picker(&mut self) {
9455        self.state.show_move_picker = false;
9456        self.state.move_picker = None;
9457    }
9458
9459    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
9460        self.state.move_picker_adjust_quantity(delta);
9461    }
9462
9463    pub fn move_picker_set_quantity_max(&mut self) {
9464        self.state.move_picker_set_quantity_max();
9465    }
9466
9467    pub fn move_picker_set_quantity_min(&mut self) {
9468        self.state.move_picker_set_quantity_min();
9469    }
9470
9471    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
9472        self.state.destroy_picker_adjust_quantity(delta);
9473    }
9474
9475    pub fn destroy_picker_set_quantity_max(&mut self) {
9476        self.state.destroy_picker_set_quantity_max();
9477    }
9478
9479    pub fn destroy_picker_set_quantity_min(&mut self) {
9480        self.state.destroy_picker_set_quantity_min();
9481    }
9482
9483    async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
9484        let Some(picker) = self.state.move_picker.clone() else {
9485            self.close_move_picker();
9486            return Ok(());
9487        };
9488        let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
9489            self.close_move_picker();
9490            return Ok(());
9491        };
9492        match option.kind {
9493            MoveOptionKind::Cancel => {
9494                self.close_move_picker();
9495            }
9496            MoveOptionKind::Use => {
9497                self.close_move_picker();
9498                self.use_item(&picker.template_id).await?;
9499            }
9500            MoveOptionKind::GrantApply => {
9501                self.close_move_picker();
9502                self.open_grant_target_picker()?;
9503            }
9504            MoveOptionKind::SellPlotToCrown { plot_id } => {
9505                self.close_move_picker();
9506                self.confirm_sell_plot_to_crown(plot_id).await?;
9507            }
9508            MoveOptionKind::RelocatePlaced { container_id } => {
9509                self.close_move_picker();
9510                self.state.show_inventory_menu = false;
9511                self.begin_relocate_container(&container_id)?;
9512            }
9513            MoveOptionKind::Drop => {
9514                self.close_move_picker();
9515                if self
9516                    .state
9517                    .hand_equipped_instance_ids()
9518                    .contains(&picker.item_instance_id)
9519                {
9520                    anyhow::bail!("unequip that item first");
9521                }
9522                if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
9523                    if self.state.deed_bound(&stack) {
9524                        anyhow::bail!(
9525                            "cannot drop a property deed — store it or trade it to another player"
9526                        );
9527                    }
9528                    if self.state.key_drop_blocked(&stack) {
9529                        anyhow::bail!("cannot drop the key while its chest is locked");
9530                    }
9531                }
9532                self.drop_item(picker.item_instance_id, picker.from).await?;
9533                self.state
9534                    .push_log(format!("Dropped {}", picker.item_label));
9535            }
9536            MoveOptionKind::PickupPlaced {
9537                container_id,
9538                nest_location,
9539                nest_parent_instance_id,
9540            } => {
9541                self.close_move_picker();
9542                self.pickup_container(container_id.clone()).await?;
9543                let nest_into_bag = nest_parent_instance_id.is_some()
9544                    || !matches!(nest_location, flatland_protocol::InventoryLocation::Root);
9545                if nest_into_bag {
9546                    self.move_item(
9547                        picker.item_instance_id,
9548                        flatland_protocol::InventoryLocation::Root,
9549                        nest_location,
9550                        nest_parent_instance_id,
9551                        None,
9552                    )
9553                    .await?;
9554                    self.state
9555                        .push_log(format!("Picked up {} into bag", picker.item_label));
9556                } else {
9557                    self.state
9558                        .push_log(format!("Picked up {}", picker.item_label));
9559                }
9560            }
9561            MoveOptionKind::Move {
9562                location,
9563                parent_instance_id,
9564            } => {
9565                self.close_move_picker();
9566                let qty = if picker.quantity >= picker.stack_quantity {
9567                    None
9568                } else {
9569                    Some(picker.quantity)
9570                };
9571                self.move_item(
9572                    picker.item_instance_id,
9573                    picker.from,
9574                    location,
9575                    parent_instance_id,
9576                    qty,
9577                )
9578                .await?;
9579                let moved = qty.unwrap_or(picker.stack_quantity);
9580                if moved >= picker.stack_quantity {
9581                    self.state.push_log(format!("Moved {}", picker.item_label));
9582                } else {
9583                    self.state.push_log(format!(
9584                        "Moved {} ×{} of {}",
9585                        picker.item_label, moved, picker.stack_quantity
9586                    ));
9587                }
9588            }
9589        }
9590        Ok(())
9591    }
9592
9593    /// `d`: place furniture (kiln, chest, bed) at your feet, or drop other items
9594    /// as ground loot. Placeable tools must not become loot piles — companions
9595    /// would vacuum them and they'd look "gone".
9596    pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
9597        let Some(row) = self.state.inventory_selected_row() else {
9598            anyhow::bail!("inventory empty");
9599        };
9600        if row.is_equip_shell {
9601            anyhow::bail!("unequip the bag first (Enter), then drop from your person");
9602        }
9603        if row.is_chest_shell {
9604            anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
9605        }
9606        let Some(inst) = row.stack.item_instance_id else {
9607            anyhow::bail!("item has no instance id");
9608        };
9609        if self.state.hand_equipped_instance_ids().contains(&inst) {
9610            anyhow::bail!("unequip that item first");
9611        }
9612        if self.state.deed_bound(&row.stack) {
9613            anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
9614        }
9615        if self.state.key_drop_blocked(&row.stack) {
9616            anyhow::bail!("cannot drop the key while its chest is locked");
9617        }
9618        let label = row
9619            .stack
9620            .display_name
9621            .clone()
9622            .unwrap_or_else(|| row.stack.template_id.clone());
9623        let placeable = row.stack.world_placeable == Some(true)
9624            || row.from == flatland_protocol::InventoryLocation::Root
9625                && matches!(
9626                    self.state.inventory_item_category(&row.stack.template_id).as_deref(),
9627                    Some("lodging")
9628                );
9629        if placeable && row.from == flatland_protocol::InventoryLocation::Root {
9630            self.place_container(inst).await?;
9631            self.state.push_log(format!("Placed {label}"));
9632            return Ok(());
9633        }
9634        self.drop_item(inst, row.from).await?;
9635        self.state.push_log(format!("Dropped {label}"));
9636        Ok(())
9637    }
9638
9639    pub async fn drop_item(
9640        &mut self,
9641        item_instance_id: uuid::Uuid,
9642        from: flatland_protocol::InventoryLocation,
9643    ) -> anyhow::Result<()> {
9644        self.seq += 1;
9645        self.session
9646            .submit_intent(Intent::DropItem {
9647                entity_id: self.state.entity_id,
9648                item_instance_id,
9649                from,
9650                seq: self.seq,
9651            })
9652            .await?;
9653        self.state.intents_sent += 1;
9654        Ok(())
9655    }
9656
9657    /// `x`: open permanent-delete picker for the selected item (quantity + confirm).
9658    pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
9659        let Some(row) = self.state.inventory_selected_row() else {
9660            anyhow::bail!("inventory empty");
9661        };
9662        if row.is_equip_shell {
9663            anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
9664        }
9665        if row.is_chest_shell {
9666            anyhow::bail!("can't destroy a placed chest from the inventory list");
9667        }
9668        let Some(instance_id) = row.stack.item_instance_id else {
9669            anyhow::bail!("item has no instance id");
9670        };
9671        if self
9672            .state
9673            .hand_equipped_instance_ids()
9674            .contains(&instance_id)
9675        {
9676            anyhow::bail!("unequip that item first");
9677        }
9678        if self.state.deed_bound(&row.stack) {
9679            anyhow::bail!(
9680                "cannot destroy a property deed — store it or trade it to another player"
9681            );
9682        }
9683        if self.state.key_drop_blocked(&row.stack) {
9684            anyhow::bail!("cannot destroy the key while its chest is locked");
9685        }
9686        let item_label = row
9687            .stack
9688            .display_name
9689            .clone()
9690            .unwrap_or_else(|| row.stack.template_id.clone());
9691        self.state.destroy_picker = Some(DestroyPicker {
9692            item_instance_id: instance_id,
9693            from: row.from,
9694            item_label,
9695            stack_quantity: row.stack.quantity,
9696            quantity: row.stack.quantity,
9697        });
9698        self.state.destroy_confirm_pending = false;
9699        self.state.show_destroy_picker = true;
9700        self.state.show_move_picker = false;
9701        self.state.move_picker = None;
9702        Ok(())
9703    }
9704
9705    pub fn close_destroy_picker(&mut self) {
9706        self.state.show_destroy_picker = false;
9707        self.state.destroy_confirm_pending = false;
9708        self.state.destroy_picker = None;
9709    }
9710
9711    pub fn cancel_destroy_confirm(&mut self) {
9712        self.state.destroy_confirm_pending = false;
9713    }
9714
9715    pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
9716        if self.state.destroy_picker.is_none() {
9717            self.close_destroy_picker();
9718            return Ok(());
9719        }
9720        self.state.destroy_confirm_pending = true;
9721        Ok(())
9722    }
9723
9724    pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
9725        let Some(picker) = self.state.destroy_picker.clone() else {
9726            self.close_destroy_picker();
9727            return Ok(());
9728        };
9729        let qty = if picker.quantity >= picker.stack_quantity {
9730            None
9731        } else {
9732            Some(picker.quantity)
9733        };
9734        self.destroy_item(picker.item_instance_id, picker.from, qty)
9735            .await?;
9736        let destroyed = qty.unwrap_or(picker.stack_quantity);
9737        if destroyed >= picker.stack_quantity {
9738            self.state
9739                .push_log(format!("Destroyed {}", picker.item_label));
9740        } else {
9741            self.state.push_log(format!(
9742                "Destroyed {} ×{} of {}",
9743                picker.item_label, destroyed, picker.stack_quantity
9744            ));
9745        }
9746        self.close_destroy_picker();
9747        Ok(())
9748    }
9749
9750    pub async fn destroy_item(
9751        &mut self,
9752        item_instance_id: uuid::Uuid,
9753        from: flatland_protocol::InventoryLocation,
9754        quantity: Option<u32>,
9755    ) -> anyhow::Result<()> {
9756        self.seq += 1;
9757        self.session
9758            .submit_intent(Intent::DestroyItem {
9759                entity_id: self.state.entity_id,
9760                item_instance_id,
9761                from,
9762                quantity,
9763                seq: self.seq,
9764            })
9765            .await?;
9766        self.state.intents_sent += 1;
9767        Ok(())
9768    }
9769
9770    /// `l`: lock/unlock a placed chest — selected chest in inventory UI, else nearest.
9771    pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
9772        if let Some(row) = self.state.inventory_selected_row() {
9773            if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
9774                return self.toggle_placed_chest_lock(container_id).await;
9775            }
9776        }
9777        self.toggle_nearby_chest_lock().await
9778    }
9779
9780    pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
9781        let chest = self
9782            .state
9783            .placed_containers
9784            .iter()
9785            .find(|c| c.id == container_id)
9786            .cloned()
9787            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
9788        let (px, py) = self.state.player_position();
9789        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
9790            anyhow::bail!("too far from {}", chest.display_name);
9791        }
9792        if !chest.accessible && chest.locked {
9793            anyhow::bail!(
9794                "need the matching key for {} (each crafted chest has its own key)",
9795                chest.display_name
9796            );
9797        }
9798        let lock = !chest.locked;
9799        self.set_container_locked(
9800            flatland_protocol::InventoryLocation::Placed {
9801                container_id: chest.id.clone(),
9802            },
9803            lock,
9804        )
9805        .await?;
9806        self.state.push_log(if lock {
9807            format!("Locked {}", chest.display_name)
9808        } else {
9809            format!("Unlocked {}", chest.display_name)
9810        });
9811        Ok(())
9812    }
9813
9814    /// `l` outside inventory: lock/unlock the nearest placed chest (within `CONTAINER_RANGE_M`).
9815    pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
9816        let chest = self
9817            .state
9818            .nearest_placed_container(CONTAINER_RANGE_M)
9819            .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
9820        self.toggle_placed_chest_lock(&chest.id).await
9821    }
9822
9823    pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
9824        self.equip_mainhand(None).await
9825    }
9826
9827    pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
9828        if !self.state.is_alive() {
9829            anyhow::bail!("you are dead");
9830        }
9831        self.seq += 1;
9832        self.session
9833            .submit_intent(Intent::EquipOffhand {
9834                entity_id: self.state.entity_id,
9835                template_id,
9836                instance_id: None,
9837                seq: self.seq,
9838            })
9839            .await?;
9840        self.state.intents_sent += 1;
9841        Ok(())
9842    }
9843
9844    pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
9845        self.equip_offhand(None).await
9846    }
9847
9848    pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
9849        let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
9850        for slot in slots {
9851            self.equip_worn(slot, None).await?;
9852        }
9853        Ok(())
9854    }
9855
9856    pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
9857        let (px, py) = self.state.player_position();
9858        let in_range: Vec<_> = self
9859            .state
9860            .placed_containers
9861            .iter()
9862            .filter(|c| self.state.placed_container_in_current_space(c))
9863            .filter(|c| (c.x - px).hypot(c.y - py) <= 2.0)
9864            .collect();
9865        let nearest_free = in_range
9866            .iter()
9867            .copied()
9868            .filter(|c| !self.state.lodging_is_occupied(&c.id))
9869            .min_by(|a, b| {
9870                let da = (a.x - px).hypot(a.y - py);
9871                let db = (b.x - px).hypot(b.y - py);
9872                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
9873            })
9874            .cloned();
9875        if let Some(chest) = nearest_free {
9876            return self.pickup_container(chest.id).await;
9877        }
9878        if in_range
9879            .iter()
9880            .any(|c| self.state.lodging_is_occupied(&c.id))
9881        {
9882            anyhow::bail!("dismiss or reassign workers before picking up lodging");
9883        }
9884        if in_range.is_empty() {
9885            anyhow::bail!("no chest nearby");
9886        }
9887        anyhow::bail!("too far from chest");
9888    }
9889
9890    pub async fn equip_worn(
9891        &mut self,
9892        slot: BodySlot,
9893        instance_id: Option<uuid::Uuid>,
9894    ) -> anyhow::Result<()> {
9895        self.seq += 1;
9896        self.session
9897            .submit_intent(Intent::EquipWorn {
9898                entity_id: self.state.entity_id,
9899                slot,
9900                instance_id,
9901                seq: self.seq,
9902            })
9903            .await?;
9904        self.state.intents_sent += 1;
9905        Ok(())
9906    }
9907
9908    pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
9909        self.seq += 1;
9910        self.session
9911            .submit_intent(Intent::PlaceContainer {
9912                entity_id: self.state.entity_id,
9913                item_instance_id,
9914                seq: self.seq,
9915            })
9916            .await?;
9917        self.state.intents_sent += 1;
9918        Ok(())
9919    }
9920
9921    pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
9922        self.seq += 1;
9923        self.session
9924            .submit_intent(Intent::PickupContainer {
9925                entity_id: self.state.entity_id,
9926                container_id,
9927                seq: self.seq,
9928            })
9929            .await?;
9930        self.state.intents_sent += 1;
9931        Ok(())
9932    }
9933
9934    pub async fn move_item(
9935        &mut self,
9936        item_instance_id: uuid::Uuid,
9937        from: flatland_protocol::InventoryLocation,
9938        to: flatland_protocol::InventoryLocation,
9939        to_parent_instance_id: Option<uuid::Uuid>,
9940        quantity: Option<u32>,
9941    ) -> anyhow::Result<()> {
9942        self.seq += 1;
9943        self.session
9944            .submit_intent(Intent::MoveItem {
9945                entity_id: self.state.entity_id,
9946                item_instance_id,
9947                from,
9948                to,
9949                to_parent_instance_id,
9950                quantity,
9951                seq: self.seq,
9952            })
9953            .await?;
9954        self.state.intents_sent += 1;
9955        Ok(())
9956    }
9957
9958    pub async fn set_container_locked(
9959        &mut self,
9960        location: flatland_protocol::InventoryLocation,
9961        locked: bool,
9962    ) -> anyhow::Result<()> {
9963        self.seq += 1;
9964        self.session
9965            .submit_intent(Intent::SetContainerLocked {
9966                entity_id: self.state.entity_id,
9967                location,
9968                locked,
9969                seq: self.seq,
9970            })
9971            .await?;
9972        self.state.intents_sent += 1;
9973        Ok(())
9974    }
9975
9976    pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
9977        if !self.state.is_alive() {
9978            anyhow::bail!("you are dead");
9979        }
9980        self.seq += 1;
9981        self.session
9982            .submit_intent(Intent::Use {
9983                entity_id: self.state.entity_id,
9984                template_id: template_id.to_string(),
9985                seq: self.seq,
9986            })
9987            .await?;
9988        self.state.intents_sent += 1;
9989        Ok(())
9990    }
9991
9992    /// Bind a grant consumable onto a specific item instance (unique gear status).
9993    pub async fn use_grant(
9994        &mut self,
9995        grant_instance_id: uuid::Uuid,
9996        target_instance_id: uuid::Uuid,
9997    ) -> anyhow::Result<()> {
9998        if !self.state.is_alive() {
9999            anyhow::bail!("you are dead");
10000        }
10001        self.seq += 1;
10002        self.session
10003            .submit_intent(Intent::UseGrant {
10004                entity_id: self.state.entity_id,
10005                grant_instance_id,
10006                target_instance_id,
10007                seq: self.seq,
10008            })
10009            .await?;
10010        self.state.intents_sent += 1;
10011        Ok(())
10012    }
10013
10014    pub fn open_craft_menu(&mut self) {
10015        self.state.show_craft_menu = true;
10016        self.state.show_shop_menu = false;
10017        self.state.shop_catalog = None;
10018        self.state.show_stats = false;
10019        self.state.show_inventory_menu = false;
10020        self.state.reload_craft_prefs();
10021        self.state.craft_tab = CraftTab::Ready;
10022        self.state.craft_filter.clear();
10023        self.state.craft_filter_focused = false;
10024        self.state.craft_menu_index = 0;
10025        self.state.clamp_craft_menu_index();
10026        self.state.craft_batch_quantity = 1;
10027        self.state.clamp_craft_batch_quantity();
10028    }
10029
10030    pub fn close_craft_menu(&mut self) {
10031        self.state.show_craft_menu = false;
10032        self.state.craft_filter_focused = false;
10033    }
10034
10035    pub fn toggle_keychain_menu(&mut self) {
10036        if self.state.show_keychain_menu {
10037            self.close_keychain_menu();
10038        } else {
10039            self.state.show_keychain_menu = true;
10040            self.state.show_craft_menu = false;
10041            self.state.show_shop_menu = false;
10042            self.state.show_inventory_menu = false;
10043            let n = self.state.keychain_entries().len();
10044            if n == 0 {
10045                self.state.keychain_menu_index = 0;
10046            } else {
10047                self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
10048            }
10049        }
10050    }
10051
10052    pub fn close_keychain_menu(&mut self) {
10053        self.state.show_keychain_menu = false;
10054    }
10055
10056    pub fn keychain_menu_move(&mut self, delta: i32) {
10057        let n = self.state.keychain_entries().len();
10058        if n == 0 {
10059            self.state.keychain_menu_index = 0;
10060            return;
10061        }
10062        let idx = self.state.keychain_menu_index as i32 + delta;
10063        self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
10064    }
10065
10066    pub fn keychain_menu_page(&mut self, pages: i32) {
10067        let n = self.state.keychain_entries().len();
10068        self.state.keychain_menu_index = page_list_index(self.state.keychain_menu_index, pages, n);
10069    }
10070
10071    pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
10072        if !self.state.is_alive() {
10073            anyhow::bail!("you are dead");
10074        }
10075        let entries = self.state.keychain_entries();
10076        let Some(entry) = entries.get(self.state.keychain_menu_index) else {
10077            anyhow::bail!("nothing selected");
10078        };
10079        let Some(instance_id) = entry.stack.item_instance_id else {
10080            anyhow::bail!("key has no instance id");
10081        };
10082        if entry.stowed {
10083            self.move_item(
10084                instance_id,
10085                flatland_protocol::InventoryLocation::Keychain,
10086                flatland_protocol::InventoryLocation::Root,
10087                None,
10088                Some(1),
10089            )
10090            .await
10091        } else {
10092            self.move_item(
10093                instance_id,
10094                flatland_protocol::InventoryLocation::Root,
10095                flatland_protocol::InventoryLocation::Keychain,
10096                None,
10097                Some(1),
10098            )
10099            .await
10100        }
10101    }
10102
10103    pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
10104        let npc_id = self.state.shop_catalog.as_ref().map(|c| c.npc_id.clone());
10105        self.state.show_shop_menu = false;
10106        self.state.shop_catalog = None;
10107        self.state.clear_shop_trade_log();
10108        if let Some(npc_id) = npc_id {
10109            self.seq += 1;
10110            self.session
10111                .submit_intent(Intent::ShopClose {
10112                    entity_id: self.state.entity_id,
10113                    npc_id,
10114                    seq: self.seq,
10115                })
10116                .await?;
10117            self.state.intents_sent += 1;
10118        }
10119        Ok(())
10120    }
10121
10122    pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
10123        let Some(panel) = self.state.bank_panel.clone() else {
10124            return Ok(());
10125        };
10126        self.seq += 1;
10127        self.session
10128            .submit_intent(Intent::BankDeposit {
10129                entity_id: self.state.entity_id,
10130                npc_id: panel.npc_id,
10131                amount_copper,
10132                seq: self.seq,
10133            })
10134            .await?;
10135        self.state.intents_sent += 1;
10136        Ok(())
10137    }
10138
10139    pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
10140        let Some(panel) = self.state.bank_panel.clone() else {
10141            return Ok(());
10142        };
10143        self.seq += 1;
10144        self.session
10145            .submit_intent(Intent::BankWithdraw {
10146                entity_id: self.state.entity_id,
10147                npc_id: panel.npc_id,
10148                amount_copper,
10149                seq: self.seq,
10150            })
10151            .await?;
10152        self.state.intents_sent += 1;
10153        Ok(())
10154    }
10155
10156    pub async fn bank_transfer(
10157        &mut self,
10158        to_character_id: Option<uuid::Uuid>,
10159        to_name: String,
10160        amount_copper: u64,
10161    ) -> anyhow::Result<()> {
10162        let Some(panel) = self.state.bank_panel.clone() else {
10163            return Ok(());
10164        };
10165        self.seq += 1;
10166        self.session
10167            .submit_intent(Intent::BankTransfer {
10168                entity_id: self.state.entity_id,
10169                npc_id: panel.npc_id,
10170                to_character_id,
10171                to_name,
10172                amount_copper,
10173                seq: self.seq,
10174            })
10175            .await?;
10176        self.state.intents_sent += 1;
10177        Ok(())
10178    }
10179
10180    pub fn bank_menu_move(&mut self, delta: i32) {
10181        let n = self.state.bank_menu_options().len();
10182        if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
10183            return;
10184        }
10185        let idx = self.state.bank_menu_index as i32;
10186        self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10187    }
10188
10189    pub fn storage_menu_move(&mut self, delta: i32) {
10190        let n = self.state.storage_menu_options().len();
10191        if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
10192            return;
10193        }
10194        let idx = self.state.storage_menu_index as i32;
10195        self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10196    }
10197
10198    pub fn storage_pick_move(&mut self, delta: i32) {
10199        let n = match &self.state.storage_ui_mode {
10200            StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
10201            StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
10202                self.state.storage_vault_options().len()
10203            }
10204            StorageUiMode::Menu
10205            | StorageUiMode::StoreAmount { .. }
10206            | StorageUiMode::TakeAmount { .. }
10207            | StorageUiMode::ShipAmount { .. } => 0,
10208        };
10209        if n == 0 {
10210            return;
10211        }
10212        match &mut self.state.storage_ui_mode {
10213            StorageUiMode::StorePick { index }
10214            | StorageUiMode::TakePick { index }
10215            | StorageUiMode::ShipPick { index, .. } => {
10216                *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10217            }
10218            StorageUiMode::Menu
10219            | StorageUiMode::StoreAmount { .. }
10220            | StorageUiMode::TakeAmount { .. }
10221            | StorageUiMode::ShipAmount { .. } => {}
10222        }
10223    }
10224
10225    pub fn storage_ui_back(&mut self) {
10226        self.state.storage_ui_mode = match &self.state.storage_ui_mode {
10227            StorageUiMode::StoreAmount { pick_index, .. } => {
10228                StorageUiMode::StorePick { index: *pick_index }
10229            }
10230            StorageUiMode::TakeAmount { pick_index, .. } => {
10231                StorageUiMode::TakePick { index: *pick_index }
10232            }
10233            StorageUiMode::ShipAmount {
10234                dest_building_id,
10235                dest_label,
10236                pick_index,
10237                ..
10238            } => StorageUiMode::ShipPick {
10239                dest_building_id: dest_building_id.clone(),
10240                dest_label: dest_label.clone(),
10241                index: *pick_index,
10242            },
10243            StorageUiMode::StorePick { .. }
10244            | StorageUiMode::TakePick { .. }
10245            | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
10246            StorageUiMode::Menu => StorageUiMode::Menu,
10247        };
10248    }
10249
10250    pub fn storage_amount_append_char(&mut self, c: char) {
10251        match &mut self.state.storage_ui_mode {
10252            StorageUiMode::StoreAmount { input, .. }
10253            | StorageUiMode::TakeAmount { input, .. }
10254            | StorageUiMode::ShipAmount { input, .. } => {
10255                if c.is_ascii_digit() && input.len() < 8 {
10256                    input.push(c);
10257                }
10258            }
10259            _ => {}
10260        }
10261    }
10262
10263    pub fn storage_amount_backspace(&mut self) {
10264        match &mut self.state.storage_ui_mode {
10265            StorageUiMode::StoreAmount { input, .. }
10266            | StorageUiMode::TakeAmount { input, .. }
10267            | StorageUiMode::ShipAmount { input, .. } => {
10268                input.pop();
10269            }
10270            _ => {}
10271        }
10272    }
10273
10274    pub fn storage_ui_typing(&self) -> bool {
10275        matches!(
10276            self.state.storage_ui_mode,
10277            StorageUiMode::StoreAmount { .. }
10278                | StorageUiMode::TakeAmount { .. }
10279                | StorageUiMode::ShipAmount { .. }
10280        )
10281    }
10282
10283    pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
10284        match self.state.storage_ui_mode.clone() {
10285            StorageUiMode::Menu => {
10286                let index = self.state.storage_menu_index;
10287                match index {
10288                    0 => {
10289                        let opts = self.state.storage_store_options();
10290                        if opts.is_empty() {
10291                            self.state.push_log("Nothing loose to store.");
10292                            return Ok(());
10293                        }
10294                        self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
10295                    }
10296                    1 => {
10297                        let opts = self.state.storage_vault_options();
10298                        if opts.is_empty() {
10299                            self.state.push_log("Vault is empty.");
10300                            return Ok(());
10301                        }
10302                        self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
10303                    }
10304                    n => {
10305                        let dest = self
10306                            .state
10307                            .storage_panel
10308                            .as_ref()
10309                            .and_then(|p| p.ship_destinations.get(n - 2))
10310                            .cloned();
10311                        let Some(dest) = dest else {
10312                            return Ok(());
10313                        };
10314                        let opts = self.state.storage_vault_options();
10315                        if opts.is_empty() {
10316                            self.state.push_log("Vault is empty — nothing to ship.");
10317                            return Ok(());
10318                        }
10319                        self.state.storage_ui_mode = StorageUiMode::ShipPick {
10320                            dest_building_id: dest.building_id,
10321                            dest_label: dest.label,
10322                            index: 0,
10323                        };
10324                    }
10325                }
10326            }
10327            StorageUiMode::StorePick { index } => {
10328                let opts = self.state.storage_store_options();
10329                let Some(opt) = opts.get(index) else {
10330                    self.state.push_log("Nothing loose to store.");
10331                    self.state.storage_ui_mode = StorageUiMode::Menu;
10332                    return Ok(());
10333                };
10334                self.state.storage_ui_mode = StorageUiMode::StoreAmount {
10335                    pick_index: index,
10336                    item_instance_id: opt.item_instance_id,
10337                    label: opt.label.clone(),
10338                    max_qty: opt.quantity.max(1),
10339                    input: String::new(),
10340                };
10341            }
10342            StorageUiMode::TakePick { index } => {
10343                let opts = self.state.storage_vault_options();
10344                let Some(opt) = opts.get(index) else {
10345                    self.state.push_log("Vault is empty.");
10346                    self.state.storage_ui_mode = StorageUiMode::Menu;
10347                    return Ok(());
10348                };
10349                self.state.storage_ui_mode = StorageUiMode::TakeAmount {
10350                    pick_index: index,
10351                    item_instance_id: opt.item_instance_id,
10352                    label: opt.label.clone(),
10353                    max_qty: opt.quantity.max(1),
10354                    input: String::new(),
10355                };
10356            }
10357            StorageUiMode::ShipPick {
10358                dest_building_id,
10359                dest_label,
10360                index,
10361            } => {
10362                let opts = self.state.storage_vault_options();
10363                let Some(opt) = opts.get(index) else {
10364                    self.state.push_log("Vault is empty — nothing to ship.");
10365                    self.state.storage_ui_mode = StorageUiMode::Menu;
10366                    return Ok(());
10367                };
10368                self.state.storage_ui_mode = StorageUiMode::ShipAmount {
10369                    dest_building_id,
10370                    dest_label,
10371                    pick_index: index,
10372                    item_instance_id: opt.item_instance_id,
10373                    label: opt.label.clone(),
10374                    max_qty: opt.quantity.max(1),
10375                    input: String::new(),
10376                };
10377            }
10378            StorageUiMode::StoreAmount {
10379                item_instance_id,
10380                max_qty,
10381                input,
10382                ..
10383            } => {
10384                let Some(qty) = parse_storage_quantity(&input) else {
10385                    self.state.push_log("Enter a quantity (blank or 0 = all).");
10386                    return Ok(());
10387                };
10388                let qty = qty.map(|n| n.min(max_qty).max(1));
10389                self.storage_store(item_instance_id, qty).await?;
10390                self.state.storage_ui_mode = StorageUiMode::Menu;
10391            }
10392            StorageUiMode::TakeAmount {
10393                item_instance_id,
10394                max_qty,
10395                input,
10396                ..
10397            } => {
10398                let Some(qty) = parse_storage_quantity(&input) else {
10399                    self.state.push_log("Enter a quantity (blank or 0 = all).");
10400                    return Ok(());
10401                };
10402                let qty = qty.map(|n| n.min(max_qty).max(1));
10403                self.storage_take(item_instance_id, qty).await?;
10404                self.state.storage_ui_mode = StorageUiMode::Menu;
10405            }
10406            StorageUiMode::ShipAmount {
10407                dest_building_id,
10408                item_instance_id,
10409                max_qty,
10410                input,
10411                ..
10412            } => {
10413                let Some(qty) = parse_storage_quantity(&input) else {
10414                    self.state.push_log("Enter a quantity (blank or 0 = all).");
10415                    return Ok(());
10416                };
10417                let qty = qty.map(|n| n.min(max_qty).max(1));
10418                self.storage_ship(dest_building_id, item_instance_id, qty)
10419                    .await?;
10420                self.state.storage_ui_mode = StorageUiMode::Menu;
10421            }
10422        }
10423        Ok(())
10424    }
10425
10426    pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
10427        match self.state.bank_ui_mode.clone() {
10428            BankUiMode::Menu => {
10429                let choice = self
10430                    .state
10431                    .bank_menu_options()
10432                    .get(self.state.bank_menu_index)
10433                    .copied()
10434                    .unwrap_or("Deposit…");
10435                match choice {
10436                    "Withdraw…" => {
10437                        self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
10438                            input: String::new(),
10439                        };
10440                    }
10441                    "Deposit all" => self.bank_deposit(0).await?,
10442                    "Withdraw all" => self.bank_withdraw(0).await?,
10443                    "Transfer…" => {
10444                        self.state.bank_ui_mode = BankUiMode::TransferName {
10445                            input: String::new(),
10446                        };
10447                    }
10448                    _ => {
10449                        self.state.bank_ui_mode = BankUiMode::DepositAmount {
10450                            input: String::new(),
10451                        };
10452                    }
10453                }
10454            }
10455            BankUiMode::DepositAmount { input } => {
10456                let Some(amount) = parse_bank_copper_amount(&input) else {
10457                    self.state
10458                        .push_log("Enter a copper amount (blank or 0 = everything on person).");
10459                    return Ok(());
10460                };
10461                self.bank_deposit(amount).await?;
10462                self.state.bank_ui_mode = BankUiMode::Menu;
10463            }
10464            BankUiMode::WithdrawAmount { input } => {
10465                let Some(amount) = parse_bank_copper_amount(&input) else {
10466                    self.state
10467                        .push_log("Enter a copper amount (blank or 0 = full ledger).");
10468                    return Ok(());
10469                };
10470                self.bank_withdraw(amount).await?;
10471                self.state.bank_ui_mode = BankUiMode::Menu;
10472            }
10473            BankUiMode::TransferName { input } => {
10474                let name = input.trim().to_string();
10475                if name.is_empty() {
10476                    self.state.push_log("Enter the recipient character name.");
10477                    return Ok(());
10478                }
10479                self.state.bank_ui_mode = BankUiMode::TransferAmount {
10480                    to_name: name,
10481                    input: String::new(),
10482                };
10483            }
10484            BankUiMode::TransferAmount { to_name, input } => {
10485                let amount: u64 = match input.trim().parse() {
10486                    Ok(v) if v > 0 => v,
10487                    _ => {
10488                        self.state
10489                            .push_log("Enter a positive copper amount to transfer.");
10490                        return Ok(());
10491                    }
10492                };
10493                self.bank_transfer(None, to_name, amount).await?;
10494                self.state.bank_ui_mode = BankUiMode::Menu;
10495            }
10496        }
10497        Ok(())
10498    }
10499
10500    pub fn bank_transfer_back(&mut self) {
10501        match &self.state.bank_ui_mode {
10502            BankUiMode::TransferAmount { to_name, .. } => {
10503                self.state.bank_ui_mode = BankUiMode::TransferName {
10504                    input: to_name.clone(),
10505                };
10506            }
10507            BankUiMode::TransferName { .. }
10508            | BankUiMode::DepositAmount { .. }
10509            | BankUiMode::WithdrawAmount { .. } => {
10510                self.state.bank_ui_mode = BankUiMode::Menu;
10511            }
10512            BankUiMode::Menu => {}
10513        }
10514    }
10515
10516    pub fn bank_transfer_append_char(&mut self, c: char) {
10517        match &mut self.state.bank_ui_mode {
10518            BankUiMode::TransferName { input } => {
10519                if input.len() < 32 && !c.is_control() {
10520                    input.push(c);
10521                }
10522            }
10523            BankUiMode::DepositAmount { input }
10524            | BankUiMode::WithdrawAmount { input }
10525            | BankUiMode::TransferAmount { input, .. } => {
10526                if c.is_ascii_digit() && input.len() < 12 {
10527                    input.push(c);
10528                }
10529            }
10530            BankUiMode::Menu => {}
10531        }
10532    }
10533
10534    pub fn bank_transfer_backspace(&mut self) {
10535        match &mut self.state.bank_ui_mode {
10536            BankUiMode::TransferName { input }
10537            | BankUiMode::DepositAmount { input }
10538            | BankUiMode::WithdrawAmount { input }
10539            | BankUiMode::TransferAmount { input, .. } => {
10540                input.pop();
10541            }
10542            BankUiMode::Menu => {}
10543        }
10544    }
10545
10546    pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
10547        let npc_id = self.state.bank_panel.as_ref().map(|p| p.npc_id.clone());
10548        self.state.clear_bank_panel();
10549        if let Some(npc_id) = npc_id {
10550            self.seq += 1;
10551            self.session
10552                .submit_intent(Intent::BankClose {
10553                    entity_id: self.state.entity_id,
10554                    npc_id,
10555                    seq: self.seq,
10556                })
10557                .await?;
10558            self.state.intents_sent += 1;
10559        }
10560        Ok(())
10561    }
10562
10563    pub async fn storage_store(
10564        &mut self,
10565        item_instance_id: uuid::Uuid,
10566        quantity: Option<u32>,
10567    ) -> anyhow::Result<()> {
10568        let Some(panel) = self.state.storage_panel.clone() else {
10569            return Ok(());
10570        };
10571        self.seq += 1;
10572        self.session
10573            .submit_intent(Intent::StorageStore {
10574                entity_id: self.state.entity_id,
10575                npc_id: panel.npc_id,
10576                item_instance_id,
10577                quantity,
10578                seq: self.seq,
10579            })
10580            .await?;
10581        self.state.intents_sent += 1;
10582        Ok(())
10583    }
10584
10585    pub async fn storage_take(
10586        &mut self,
10587        item_instance_id: uuid::Uuid,
10588        quantity: Option<u32>,
10589    ) -> anyhow::Result<()> {
10590        let Some(panel) = self.state.storage_panel.clone() else {
10591            return Ok(());
10592        };
10593        self.seq += 1;
10594        self.session
10595            .submit_intent(Intent::StorageTake {
10596                entity_id: self.state.entity_id,
10597                npc_id: panel.npc_id,
10598                item_instance_id,
10599                quantity,
10600                seq: self.seq,
10601            })
10602            .await?;
10603        self.state.intents_sent += 1;
10604        Ok(())
10605    }
10606
10607    pub async fn storage_ship(
10608        &mut self,
10609        dest_building_id: String,
10610        item_instance_id: uuid::Uuid,
10611        quantity: Option<u32>,
10612    ) -> anyhow::Result<()> {
10613        let Some(panel) = self.state.storage_panel.clone() else {
10614            return Ok(());
10615        };
10616        self.seq += 1;
10617        self.session
10618            .submit_intent(Intent::StorageShip {
10619                entity_id: self.state.entity_id,
10620                npc_id: panel.npc_id,
10621                dest_building_id,
10622                item_instance_id,
10623                quantity,
10624                seq: self.seq,
10625            })
10626            .await?;
10627        self.state.intents_sent += 1;
10628        Ok(())
10629    }
10630
10631    pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
10632        let npc_id = self.state.storage_panel.as_ref().map(|p| p.npc_id.clone());
10633        self.state.clear_storage_panel();
10634        if let Some(npc_id) = npc_id {
10635            self.seq += 1;
10636            self.session
10637                .submit_intent(Intent::StorageClose {
10638                    entity_id: self.state.entity_id,
10639                    npc_id,
10640                    seq: self.seq,
10641                })
10642                .await?;
10643            self.state.intents_sent += 1;
10644        }
10645        Ok(())
10646    }
10647
10648    pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
10649        let npc_id = self.state.market_panel.as_ref().map(|p| p.npc_id.clone());
10650        self.state.clear_market_panel();
10651        if let Some(npc_id) = npc_id {
10652            self.seq += 1;
10653            self.session
10654                .submit_intent(Intent::MarketClose {
10655                    entity_id: self.state.entity_id,
10656                    npc_id,
10657                    seq: self.seq,
10658                })
10659                .await?;
10660            self.state.intents_sent += 1;
10661        }
10662        Ok(())
10663    }
10664
10665    pub fn market_move_selection(&mut self, delta: i32) {
10666        let indices = self.state.market_filtered_listing_indices();
10667        let n = indices.len();
10668        if n == 0 {
10669            self.state.market_menu_index = 0;
10670            return;
10671        }
10672        let cur = self.state.market_menu_index as i32;
10673        self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
10674    }
10675
10676    pub fn market_page_selection(&mut self, pages: i32) {
10677        let indices = self.state.market_filtered_listing_indices();
10678        let n = indices.len();
10679        if n == 0 {
10680            self.state.market_menu_index = 0;
10681            return;
10682        }
10683        self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
10684    }
10685
10686    pub fn market_list_page(&mut self, pages: i32) {
10687        match &self.state.market_ui_mode {
10688            MarketUiMode::ListSource { index } => {
10689                let n = self.state.market_list_source_options().len();
10690                if n == 0 {
10691                    return;
10692                }
10693                let next = page_list_index(*index, pages, n);
10694                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
10695            }
10696            MarketUiMode::ListPricingMode { index, .. } => {
10697                let next = page_list_index(*index, pages, 2);
10698                if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
10699                {
10700                    *index = next;
10701                }
10702            }
10703            MarketUiMode::ListPick { source, index } => {
10704                let opts = self.state.market_list_item_options(source);
10705                let n = opts.len();
10706                if n == 0 {
10707                    return;
10708                }
10709                let next = page_list_index(*index, pages, n);
10710                self.state.market_ui_mode = MarketUiMode::ListPick {
10711                    source: source.clone(),
10712                    index: next,
10713                };
10714            }
10715            _ => {}
10716        }
10717    }
10718
10719    pub fn market_cycle_category(&mut self, delta: i32) {
10720        let groups = self.state.market_available_category_groups();
10721        // All + groups
10722        let mut labels: Vec<Option<&'static str>> = vec![None];
10723        labels.extend(groups.into_iter().map(Some));
10724        let n = labels.len() as i32;
10725        let cur = labels
10726            .iter()
10727            .position(|g| *g == self.state.market_category_filter)
10728            .unwrap_or(0) as i32;
10729        let next = (cur + delta).rem_euclid(n) as usize;
10730        self.state.market_category_filter = labels[next];
10731        self.state.market_menu_index = 0;
10732        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10733            let source = source.clone();
10734            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10735        }
10736    }
10737
10738    pub fn focus_market_filter(&mut self) {
10739        self.state.market_filter_focused = true;
10740    }
10741
10742    pub fn append_market_filter_char(&mut self, ch: char) {
10743        if !self.state.market_filter_focused {
10744            return;
10745        }
10746        if !is_list_filter_char(ch) {
10747            return;
10748        }
10749        if self.state.market_filter.len() < 48 {
10750            self.state.market_filter.push(ch);
10751            self.state.market_menu_index = 0;
10752            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10753                let source = source.clone();
10754                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10755            }
10756        }
10757    }
10758
10759    pub fn market_filter_backspace(&mut self) {
10760        if !self.state.market_filter_focused {
10761            return;
10762        }
10763        self.state.market_filter.pop();
10764        self.state.market_menu_index = 0;
10765        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10766            let source = source.clone();
10767            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10768        }
10769    }
10770
10771    /// Clears filter text, blurs search, or returns false if already idle.
10772    pub fn clear_or_blur_market_filter(&mut self) -> bool {
10773        if self.state.market_filter_focused {
10774            if !self.state.market_filter.is_empty() {
10775                self.state.market_filter.clear();
10776                self.state.market_menu_index = 0;
10777                if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10778                    let source = source.clone();
10779                    self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10780                }
10781                return true;
10782            }
10783            self.state.market_filter_focused = false;
10784            return true;
10785        }
10786        if !self.state.market_filter.is_empty() {
10787            self.state.market_filter.clear();
10788            self.state.market_menu_index = 0;
10789            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10790                let source = source.clone();
10791                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10792            }
10793            return true;
10794        }
10795        false
10796    }
10797
10798    pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
10799        if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
10800            return self.market_confirm_buy(listing_id, qty).await;
10801        }
10802        let Some(panel) = self.state.market_panel.clone() else {
10803            return Ok(());
10804        };
10805        let indices = self.state.market_filtered_listing_indices();
10806        let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
10807            return Ok(());
10808        };
10809        let Some(listing) = panel.listings.get(raw_idx) else {
10810            return Ok(());
10811        };
10812        if listing.mine {
10813            self.seq += 1;
10814            self.session
10815                .submit_intent(Intent::MarketDelist {
10816                    entity_id: self.state.entity_id,
10817                    npc_id: panel.npc_id.clone(),
10818                    listing_id: listing.listing_id,
10819                    dest: flatland_protocol::GoodsLocation::Person,
10820                    seq: self.seq,
10821                })
10822                .await?;
10823            self.state.intents_sent += 1;
10824            return Ok(());
10825        }
10826        if listing.npc_price {
10827            self.state
10828                .push_log("NPC-price listings are bought by merchants only.");
10829            return Ok(());
10830        }
10831        let qty = 1u32.min(listing.quantity).max(1);
10832        let line = listing.unit_price_copper.saturating_mul(qty as u64);
10833        self.state.market_buy_confirm = Some((
10834            listing.listing_id,
10835            qty,
10836            listing.unit_price_copper,
10837            line,
10838            listing.display_name.clone(),
10839        ));
10840        Ok(())
10841    }
10842
10843    pub async fn market_confirm_buy(
10844        &mut self,
10845        listing_id: uuid::Uuid,
10846        quantity: u32,
10847    ) -> anyhow::Result<()> {
10848        let Some(panel) = self.state.market_panel.clone() else {
10849            self.state.market_buy_confirm = None;
10850            return Ok(());
10851        };
10852        self.state.market_buy_confirm = None;
10853        self.seq += 1;
10854        self.session
10855            .submit_intent(Intent::MarketBuy {
10856                entity_id: self.state.entity_id,
10857                npc_id: panel.npc_id,
10858                listing_id,
10859                quantity,
10860                dest: flatland_protocol::GoodsLocation::Person,
10861                seq: self.seq,
10862            })
10863            .await?;
10864        self.state.intents_sent += 1;
10865        Ok(())
10866    }
10867
10868    /// Begin the list wizard (`l` in market browse).
10869    pub fn market_begin_list(&mut self) {
10870        if self.state.market_panel.is_none() {
10871            return;
10872        }
10873        let sources = self.state.market_list_source_options();
10874        if sources.is_empty() {
10875            self.state.push_log("Nothing to list from.");
10876            return;
10877        }
10878        // One source (person only) → skip straight to item pick when it has stacks.
10879        if sources.len() == 1 {
10880            let (source, _) = sources[0].clone();
10881            let opts = self.state.market_list_item_options(&source);
10882            if opts.is_empty() {
10883                self.state.push_log("Nothing loose to list.");
10884                return;
10885            }
10886            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10887            self.state.market_buy_confirm = None;
10888            return;
10889        }
10890        self.state.market_buy_confirm = None;
10891        self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
10892    }
10893
10894    pub fn market_ui_back(&mut self) {
10895        self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
10896            MarketUiMode::Browse => MarketUiMode::Browse,
10897            MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
10898            MarketUiMode::ListPick { .. } => {
10899                if self.state.market_list_source_options().len() <= 1 {
10900                    MarketUiMode::Browse
10901                } else {
10902                    MarketUiMode::ListSource { index: 0 }
10903                }
10904            }
10905            MarketUiMode::ListAmount {
10906                source, pick_index, ..
10907            } => MarketUiMode::ListPick {
10908                source,
10909                index: pick_index,
10910            },
10911            MarketUiMode::ListPricingMode {
10912                source,
10913                item_instance_id,
10914                template_id,
10915                label,
10916                max_qty,
10917                quantity,
10918                pick_index,
10919                ..
10920            } => {
10921                let input = quantity.map(|q| q.to_string()).unwrap_or_default();
10922                MarketUiMode::ListAmount {
10923                    source,
10924                    pick_index,
10925                    item_instance_id,
10926                    template_id,
10927                    label,
10928                    max_qty,
10929                    input,
10930                }
10931            }
10932            MarketUiMode::ListPrice {
10933                source,
10934                pick_index,
10935                item_instance_id,
10936                template_id,
10937                label,
10938                max_qty,
10939                quantity,
10940                ..
10941            } => MarketUiMode::ListPricingMode {
10942                source,
10943                pick_index,
10944                item_instance_id,
10945                template_id,
10946                label,
10947                quantity,
10948                max_qty,
10949                index: 1,
10950            },
10951        };
10952    }
10953
10954    pub fn market_list_move(&mut self, delta: i32) {
10955        match &self.state.market_ui_mode {
10956            MarketUiMode::ListSource { index } => {
10957                let n = self.state.market_list_source_options().len();
10958                if n == 0 {
10959                    return;
10960                }
10961                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10962                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
10963            }
10964            MarketUiMode::ListPricingMode { index, .. } => {
10965                let next = (*index as i32 + delta).rem_euclid(2) as usize;
10966                if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
10967                {
10968                    *index = next;
10969                }
10970            }
10971            MarketUiMode::ListPick { source, index } => {
10972                let opts = self.state.market_list_item_options(source);
10973                let n = opts.len();
10974                if n == 0 {
10975                    return;
10976                }
10977                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10978                self.state.market_ui_mode = MarketUiMode::ListPick {
10979                    source: source.clone(),
10980                    index: next,
10981                };
10982            }
10983            _ => {}
10984        }
10985    }
10986
10987    pub fn market_list_amount_append_char(&mut self, c: char) {
10988        if !c.is_ascii_digit() {
10989            return;
10990        }
10991        match &mut self.state.market_ui_mode {
10992            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
10993                if input.len() < 12 {
10994                    input.push(c);
10995                }
10996            }
10997            _ => {}
10998        }
10999    }
11000
11001    pub fn market_list_amount_backspace(&mut self) {
11002        match &mut self.state.market_ui_mode {
11003            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
11004                input.pop();
11005            }
11006            _ => {}
11007        }
11008    }
11009
11010    pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
11011        match self.state.market_ui_mode.clone() {
11012            MarketUiMode::Browse => Ok(()),
11013            MarketUiMode::ListSource { index } => {
11014                let sources = self.state.market_list_source_options();
11015                let Some((source, _)) = sources.get(index).cloned() else {
11016                    return Ok(());
11017                };
11018                let opts = self.state.market_list_item_options(&source);
11019                if opts.is_empty() {
11020                    self.state.push_log("Nothing to list from that source.");
11021                    return Ok(());
11022                }
11023                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11024                Ok(())
11025            }
11026            MarketUiMode::ListPick { source, index } => {
11027                let opts = self.state.market_list_item_options(&source);
11028                let Some(opt) = opts.get(index) else {
11029                    self.state.push_log("Nothing to list.");
11030                    self.state.market_ui_mode = MarketUiMode::Browse;
11031                    return Ok(());
11032                };
11033                self.state.market_ui_mode = MarketUiMode::ListAmount {
11034                    source,
11035                    pick_index: index,
11036                    item_instance_id: opt.item_instance_id,
11037                    template_id: opt.template_id.clone(),
11038                    label: opt.label.clone(),
11039                    max_qty: opt.quantity.max(1),
11040                    input: String::new(),
11041                };
11042                Ok(())
11043            }
11044            MarketUiMode::ListAmount {
11045                source,
11046                pick_index,
11047                item_instance_id,
11048                template_id,
11049                label,
11050                max_qty,
11051                input,
11052                ..
11053            } => {
11054                let Some(qty_opt) = parse_storage_quantity(&input) else {
11055                    self.state.push_log("Enter a quantity (blank = all).");
11056                    return Ok(());
11057                };
11058                if let Some(q) = qty_opt {
11059                    if q > max_qty {
11060                        self.state.push_log(format!("Only {max_qty} available."));
11061                        return Ok(());
11062                    }
11063                }
11064                self.state.market_ui_mode = MarketUiMode::ListPricingMode {
11065                    source,
11066                    pick_index,
11067                    item_instance_id,
11068                    template_id,
11069                    label,
11070                    quantity: qty_opt,
11071                    max_qty,
11072                    index: 0,
11073                };
11074                Ok(())
11075            }
11076            MarketUiMode::ListPricingMode {
11077                source,
11078                pick_index,
11079                item_instance_id,
11080                template_id,
11081                label,
11082                quantity,
11083                max_qty,
11084                index,
11085            } => {
11086                if index == 0 {
11087                    if self
11088                        .state
11089                        .npc_market_dump_unit_estimate(&template_id)
11090                        .is_none()
11091                    {
11092                        self.state
11093                            .push_log("That item has no NPC value — use a fixed price instead.");
11094                        return Ok(());
11095                    }
11096                    return self
11097                        .submit_market_list_intent(
11098                            source,
11099                            item_instance_id,
11100                            quantity,
11101                            0,
11102                            true,
11103                            &label,
11104                        )
11105                        .await;
11106                }
11107                self.state.market_ui_mode = MarketUiMode::ListPrice {
11108                    source,
11109                    pick_index,
11110                    item_instance_id,
11111                    template_id,
11112                    label,
11113                    quantity,
11114                    max_qty,
11115                    input: String::new(),
11116                };
11117                Ok(())
11118            }
11119            MarketUiMode::ListPrice {
11120                source,
11121                item_instance_id,
11122                label,
11123                quantity,
11124                input,
11125                ..
11126            } => {
11127                let price = input.trim().parse::<u64>().unwrap_or(0);
11128                if price == 0 {
11129                    self.state
11130                        .push_log("Enter a unit price of at least 1 copper.");
11131                    return Ok(());
11132                }
11133                self.submit_market_list_intent(
11134                    source,
11135                    item_instance_id,
11136                    quantity,
11137                    price,
11138                    false,
11139                    &label,
11140                )
11141                .await
11142            }
11143        }
11144    }
11145
11146    async fn submit_market_list_intent(
11147        &mut self,
11148        source: MarketListSourceKind,
11149        item_instance_id: uuid::Uuid,
11150        quantity: Option<u32>,
11151        unit_price_copper: u64,
11152        npc_price: bool,
11153        label: &str,
11154    ) -> anyhow::Result<()> {
11155        let Some(panel) = self.state.market_panel.clone() else {
11156            self.state.market_ui_mode = MarketUiMode::Browse;
11157            return Ok(());
11158        };
11159        let goods = match source {
11160            MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
11161            MarketListSourceKind::TownStorage { building_id } => {
11162                flatland_protocol::GoodsLocation::TownStorage { building_id }
11163            }
11164        };
11165        self.seq += 1;
11166        self.session
11167            .submit_intent(Intent::MarketList {
11168                entity_id: self.state.entity_id,
11169                npc_id: panel.npc_id,
11170                source: goods,
11171                item_instance_id,
11172                quantity,
11173                unit_price_copper,
11174                npc_price,
11175                seq: self.seq,
11176            })
11177            .await?;
11178        self.state.intents_sent += 1;
11179        if npc_price {
11180            self.state
11181                .push_log(format!("Listing {label} at NPC price…"));
11182        } else {
11183            self.state
11184                .push_log(format!("Listing {label} @ {unit_price_copper} cp…"));
11185        }
11186        self.state.market_ui_mode = MarketUiMode::Browse;
11187        Ok(())
11188    }
11189
11190    /// Close shop and return to the Talk/Trade verb menu when inside an NPC session.
11191    pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
11192        let return_to_verbs = self.state.npc_verb_target.is_some();
11193        self.close_shop_menu().await?;
11194        if return_to_verbs {
11195            self.state.show_npc_verb_menu = true;
11196            self.state.npc_verb_notice = None;
11197        }
11198        Ok(())
11199    }
11200
11201    pub fn shop_tab_toggle(&mut self) {
11202        self.state.shop_tab = match self.state.shop_tab {
11203            ShopTab::Buy => ShopTab::Sell,
11204            ShopTab::Sell => ShopTab::Buy,
11205        };
11206        self.state.shop_menu_index = 0;
11207        if self.state.shop_tab == ShopTab::Sell {
11208            self.state.shop_quantity_set_max();
11209        }
11210        self.state.clamp_shop_selection();
11211    }
11212
11213    pub fn shop_menu_move(&mut self, delta: i32) {
11214        self.state.shop_menu_move(delta);
11215    }
11216
11217    pub fn shop_quantity_adjust(&mut self, delta: i32) {
11218        self.state.shop_quantity_adjust(delta);
11219    }
11220
11221    pub fn shop_quantity_set_max(&mut self) {
11222        self.state.shop_quantity_set_max();
11223    }
11224
11225    pub fn shop_quantity_set_min(&mut self) {
11226        self.state.shop_quantity_set_min();
11227    }
11228
11229    pub fn toggle_quest_menu(&mut self) {
11230        self.state.show_quest_menu = !self.state.show_quest_menu;
11231        if self.state.show_quest_menu {
11232            self.state.quest_menu_index = 0;
11233            self.state.quest_withdraw_confirm = false;
11234            self.state.show_workers_menu = false;
11235        }
11236    }
11237
11238    pub fn toggle_workers_menu(&mut self) {
11239        if self.state.show_workers_menu {
11240            self.close_workers_menu_ui();
11241        } else {
11242            self.state.show_workers_menu = true;
11243            // Keep prior selection/scroll focus; only clamp if the roster shrank.
11244            if self.state.workers_menu_index >= self.state.hired_workers.len() {
11245                self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11246            }
11247            self.state.show_quest_menu = false;
11248            self.close_worker_give_picker();
11249            self.close_worker_give_target_picker();
11250            self.close_worker_take_picker();
11251            self.close_worker_teach_picker();
11252            self.cancel_worker_rename();
11253        }
11254    }
11255
11256    /// Close workers UI layers without network (caller should release attend).
11257    pub fn close_workers_menu_ui(&mut self) {
11258        self.state.show_workers_menu = false;
11259        self.cancel_worker_dismissal();
11260        self.close_worker_give_picker();
11261        self.close_worker_give_target_picker();
11262        self.close_worker_take_picker();
11263        self.close_worker_teach_picker();
11264        self.cancel_worker_rename();
11265    }
11266
11267    /// Open the workers menu focused on `instance_id` and pause that worker's job.
11268    pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
11269        let Some(idx) = self
11270            .state
11271            .hired_workers
11272            .iter()
11273            .position(|w| w.instance_id == instance_id)
11274        else {
11275            anyhow::bail!("worker not found");
11276        };
11277        let label = self.state.hired_workers[idx].label.clone();
11278        self.state.show_workers_menu = true;
11279        self.state.workers_menu_index = idx;
11280        self.state.show_quest_menu = false;
11281        self.close_worker_give_picker();
11282        self.close_worker_give_target_picker();
11283        self.close_worker_take_picker();
11284        self.close_worker_teach_picker();
11285        self.cancel_worker_rename();
11286        self.set_worker_attending(instance_id, true).await?;
11287        self.state
11288            .push_log(format!("Managing {label} — job paused while menu is open"));
11289        Ok(())
11290    }
11291
11292    /// Close workers menu and release any attend pause.
11293    pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
11294        self.close_workers_menu_ui();
11295        self.release_worker_attend().await
11296    }
11297
11298    async fn set_worker_attending(
11299        &mut self,
11300        instance_id: &str,
11301        attending: bool,
11302    ) -> anyhow::Result<()> {
11303        if attending {
11304            if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11305                return Ok(());
11306            }
11307            // Switch target: release previous first.
11308            if let Some(prev) = self.state.attending_worker_instance_id.clone() {
11309                if prev != instance_id {
11310                    self.send_attend_hired_worker(&prev, false).await?;
11311                }
11312            }
11313            self.send_attend_hired_worker(instance_id, true).await?;
11314            self.state.attending_worker_instance_id = Some(instance_id.to_string());
11315        } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11316            self.send_attend_hired_worker(instance_id, false).await?;
11317            self.state.attending_worker_instance_id = None;
11318        }
11319        Ok(())
11320    }
11321
11322    pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
11323        let Some(id) = self.state.attending_worker_instance_id.take() else {
11324            return Ok(());
11325        };
11326        self.send_attend_hired_worker(&id, false).await
11327    }
11328
11329    async fn send_attend_hired_worker(
11330        &mut self,
11331        worker_instance_id: &str,
11332        attending: bool,
11333    ) -> anyhow::Result<()> {
11334        self.seq += 1;
11335        self.session
11336            .submit_intent(Intent::AttendHiredWorker {
11337                entity_id: self.state.entity_id,
11338                worker_instance_id: worker_instance_id.to_string(),
11339                attending,
11340                seq: self.seq,
11341            })
11342            .await?;
11343        self.state.intents_sent += 1;
11344        Ok(())
11345    }
11346
11347    pub fn workers_menu_move(&mut self, delta: i32) {
11348        let n = self.state.hired_workers.len();
11349        if n == 0 {
11350            return;
11351        }
11352        let idx = self.state.workers_menu_index as i32;
11353        self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
11354    }
11355
11356    pub fn toggle_workers_menu_compact(&mut self) {
11357        self.state.workers_menu_compact = !self.state.workers_menu_compact;
11358        let mut cfg = crate::client_config::ClientConfig::load();
11359        let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
11360    }
11361
11362    pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
11363        let Some(worker) = self
11364            .state
11365            .hired_workers
11366            .get(self.state.workers_menu_index)
11367            .cloned()
11368        else {
11369            anyhow::bail!("no worker selected");
11370        };
11371        self.dismiss_worker_by_id(&worker.instance_id, &worker.label)
11372            .await
11373    }
11374
11375    /// Open a confirmation dialog before permanently dismissing the selected worker.
11376    pub fn request_worker_dismissal(&mut self) -> anyhow::Result<()> {
11377        let Some(worker) = self
11378            .state
11379            .hired_workers
11380            .get(self.state.workers_menu_index)
11381            .cloned()
11382        else {
11383            anyhow::bail!("no worker selected");
11384        };
11385        self.state.worker_dismiss_confirmation = Some(WorkerDismissConfirmation {
11386            worker_instance_id: worker.instance_id,
11387            worker_label: worker.label,
11388        });
11389        Ok(())
11390    }
11391
11392    pub fn cancel_worker_dismissal(&mut self) {
11393        self.state.worker_dismiss_confirmation = None;
11394    }
11395
11396    pub async fn confirm_worker_dismissal(&mut self) -> anyhow::Result<()> {
11397        let Some(confirm) = self.state.worker_dismiss_confirmation.clone() else {
11398            return Ok(());
11399        };
11400        self.dismiss_worker_by_id(&confirm.worker_instance_id, &confirm.worker_label)
11401            .await?;
11402        self.cancel_worker_dismissal();
11403        Ok(())
11404    }
11405
11406    async fn dismiss_worker_by_id(
11407        &mut self,
11408        worker_instance_id: &str,
11409        worker_label: &str,
11410    ) -> anyhow::Result<()> {
11411        self.seq += 1;
11412        self.session
11413            .submit_intent(Intent::DismissWorker {
11414                entity_id: self.state.entity_id,
11415                worker_instance_id: worker_instance_id.to_string(),
11416                seq: self.seq,
11417            })
11418            .await?;
11419        self.state.intents_sent += 1;
11420        self.state
11421            .hired_workers
11422            .retain(|w| w.instance_id != worker_instance_id);
11423        if self.state.workers_menu_index >= self.state.hired_workers.len() {
11424            self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11425        }
11426        self.state.push_log(format!("Dismissed {worker_label}"));
11427        Ok(())
11428    }
11429
11430    pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
11431        let Some(worker) = self
11432            .state
11433            .hired_workers
11434            .get(self.state.workers_menu_index)
11435            .cloned()
11436        else {
11437            anyhow::bail!("no worker selected");
11438        };
11439        let mode = match worker.mode {
11440            flatland_protocol::WorkerModeView::Companion => "defender",
11441            flatland_protocol::WorkerModeView::Defender => "job_loop",
11442            flatland_protocol::WorkerModeView::JobLoop => "idle",
11443            flatland_protocol::WorkerModeView::Idle => "companion",
11444        };
11445        self.seq += 1;
11446        self.session
11447            .submit_intent(Intent::SetWorkerMode {
11448                entity_id: self.state.entity_id,
11449                worker_instance_id: worker.instance_id,
11450                mode: mode.into(),
11451                seq: self.seq,
11452            })
11453            .await?;
11454        self.state.intents_sent += 1;
11455        Ok(())
11456    }
11457
11458    pub async fn workers_deliver_selected_to_storage(&mut self) -> anyhow::Result<()> {
11459        let Some(worker) = self
11460            .state
11461            .hired_workers
11462            .get(self.state.workers_menu_index)
11463            .cloned()
11464        else {
11465            anyhow::bail!("no worker selected");
11466        };
11467        if !matches!(worker.mode, flatland_protocol::WorkerModeView::Companion) {
11468            anyhow::bail!("switch the worker to companion mode first");
11469        }
11470        if worker.step_label.starts_with("delivering to ")
11471            || worker.step_label == "returning to you"
11472        {
11473            anyhow::bail!("worker is already delivering to storage");
11474        }
11475        self.seq += 1;
11476        self.session
11477            .submit_intent(Intent::DeliverWorkerToNearestStorage {
11478                entity_id: self.state.entity_id,
11479                worker_instance_id: worker.instance_id.clone(),
11480                seq: self.seq,
11481            })
11482            .await?;
11483        self.state.intents_sent += 1;
11484        self.state.push_log(format!(
11485            "{} is delivering carried items to storage",
11486            worker.label
11487        ));
11488        Ok(())
11489    }
11490
11491    pub async fn workers_cancel_delivery_selected(&mut self) -> anyhow::Result<()> {
11492        let Some(worker) = self
11493            .state
11494            .hired_workers
11495            .get(self.state.workers_menu_index)
11496            .cloned()
11497        else {
11498            anyhow::bail!("no worker selected");
11499        };
11500        if !(worker.step_label.starts_with("delivering to ")
11501            || worker.step_label == "returning to you")
11502        {
11503            anyhow::bail!("worker has no active delivery");
11504        }
11505        self.seq += 1;
11506        self.session
11507            .submit_intent(Intent::CancelWorkerDelivery {
11508                entity_id: self.state.entity_id,
11509                worker_instance_id: worker.instance_id,
11510                seq: self.seq,
11511            })
11512            .await?;
11513        self.state.intents_sent += 1;
11514        self.state
11515            .push_log(format!("Canceled delivery for {}", worker.label));
11516        Ok(())
11517    }
11518
11519    pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
11520        if self.state.hired_workers.is_empty() {
11521            return self.hire_worker_laborer().await;
11522        }
11523        self.workers_toggle_mode_selected().await
11524    }
11525
11526    /// Open a nearby-worker picker for the selected inventory stack (inventory `g`).
11527    /// Always shows a chooser so you can pick Bruce vs Cookie when several are close.
11528    pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11529        let row = self
11530            .state
11531            .inventory_selected_row()
11532            .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
11533            .clone();
11534        if row.from != flatland_protocol::InventoryLocation::Root {
11535            anyhow::bail!("select a carried item to give");
11536        }
11537        let Some(instance_id) = row.stack.item_instance_id else {
11538            anyhow::bail!("that stack can't be given");
11539        };
11540        let options = self.nearby_worker_give_targets();
11541        if options.is_empty() {
11542            anyhow::bail!(
11543                "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
11544            );
11545        }
11546        let item_label = row
11547            .stack
11548            .display_name
11549            .as_deref()
11550            .unwrap_or(&row.stack.template_id)
11551            .to_string();
11552        self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
11553            item_instance_id: instance_id,
11554            item_label,
11555            quantity: None,
11556            options,
11557        });
11558        self.state.worker_give_target_picker_index = 0;
11559        self.state.show_worker_give_target_picker = true;
11560        // Let the target picker own keys (inventory would otherwise swallow ↑↓/Enter).
11561        self.state.show_inventory_menu = false;
11562        Ok(())
11563    }
11564
11565    /// Hired workers within give/take range, nearest first.
11566    pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
11567        let (px, py, _) = self.state.player_position_with_z();
11568        let mut options: Vec<WorkerGiveTargetOption> = self
11569            .state
11570            .hired_workers
11571            .iter()
11572            .filter_map(|w| {
11573                let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
11574                if dist > WORKER_GIVE_RANGE_M {
11575                    return None;
11576                }
11577                Some(WorkerGiveTargetOption {
11578                    instance_id: w.instance_id.clone(),
11579                    label: w.label.clone(),
11580                    distance_m: dist,
11581                })
11582            })
11583            .collect();
11584        options.sort_by(|a, b| {
11585            a.distance_m
11586                .partial_cmp(&b.distance_m)
11587                .unwrap_or(std::cmp::Ordering::Equal)
11588        });
11589        options
11590    }
11591
11592    pub fn close_worker_give_target_picker(&mut self) {
11593        self.state.show_worker_give_target_picker = false;
11594        self.state.worker_give_target_picker = None;
11595        self.state.worker_give_target_picker_index = 0;
11596    }
11597
11598    pub fn worker_give_target_picker_move(&mut self, delta: i32) {
11599        let Some(picker) = &self.state.worker_give_target_picker else {
11600            return;
11601        };
11602        let n = picker.options.len();
11603        if n == 0 {
11604            return;
11605        }
11606        let idx = self.state.worker_give_target_picker_index as i32;
11607        self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11608    }
11609
11610    pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11611        let Some(picker) = self.state.worker_give_target_picker.clone() else {
11612            anyhow::bail!("give target picker not open");
11613        };
11614        let Some(opt) = picker
11615            .options
11616            .get(self.state.worker_give_target_picker_index)
11617            .cloned()
11618        else {
11619            anyhow::bail!("no worker selected");
11620        };
11621        let Some(worker) = self
11622            .state
11623            .hired_workers
11624            .iter()
11625            .find(|w| w.instance_id == opt.instance_id)
11626            .cloned()
11627        else {
11628            self.close_worker_give_target_picker();
11629            anyhow::bail!("worker no longer hired");
11630        };
11631        self.give_item_to_worker(
11632            &worker.instance_id,
11633            &worker.label,
11634            worker.x,
11635            worker.y,
11636            picker.item_instance_id,
11637            &picker.item_label,
11638            picker.quantity,
11639        )
11640        .await?;
11641        self.close_worker_give_target_picker();
11642        Ok(())
11643    }
11644
11645    /// Give the selected inventory stack to a hired worker (opens nearby-worker picker).
11646    pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
11647        self.open_worker_give_target_picker()
11648    }
11649
11650    /// Open the workers-menu give picker for the selected worker (must be in range).
11651    pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
11652        let Some(worker) = self
11653            .state
11654            .hired_workers
11655            .get(self.state.workers_menu_index)
11656            .cloned()
11657        else {
11658            anyhow::bail!("select a hired worker first");
11659        };
11660        let (px, py, _) = self.state.player_position_with_z();
11661        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11662        if dist > WORKER_GIVE_RANGE_M {
11663            anyhow::bail!(
11664                "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
11665                worker.label
11666            );
11667        }
11668        let options = self.state.giveable_inventory_options();
11669        if options.is_empty() {
11670            anyhow::bail!("nothing in inventory to give");
11671        }
11672        self.state.worker_give_picker = Some(WorkerGivePicker {
11673            worker_instance_id: worker.instance_id,
11674            worker_label: worker.label,
11675            options,
11676        });
11677        self.state.worker_give_picker_index = 0;
11678        self.state.show_worker_give_picker = true;
11679        Ok(())
11680    }
11681
11682    pub fn close_worker_give_picker(&mut self) {
11683        self.state.show_worker_give_picker = false;
11684        self.state.worker_give_picker = None;
11685        self.state.worker_give_picker_index = 0;
11686    }
11687
11688    pub fn worker_give_picker_move(&mut self, delta: i32) {
11689        let Some(picker) = &self.state.worker_give_picker else {
11690            return;
11691        };
11692        let n = picker.options.len();
11693        if n == 0 {
11694            return;
11695        }
11696        let idx = self.state.worker_give_picker_index as i32;
11697        self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11698    }
11699
11700    /// Confirm the selected row in the workers-menu give picker.
11701    pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
11702        let Some(picker) = self.state.worker_give_picker.clone() else {
11703            anyhow::bail!("give picker not open");
11704        };
11705        let Some(opt) = picker
11706            .options
11707            .get(self.state.worker_give_picker_index)
11708            .cloned()
11709        else {
11710            anyhow::bail!("no item selected");
11711        };
11712        let Some(worker) = self
11713            .state
11714            .hired_workers
11715            .iter()
11716            .find(|w| w.instance_id == picker.worker_instance_id)
11717            .cloned()
11718        else {
11719            self.close_worker_give_picker();
11720            anyhow::bail!("worker no longer hired");
11721        };
11722        self.give_item_to_worker(
11723            &worker.instance_id,
11724            &worker.label,
11725            worker.x,
11726            worker.y,
11727            opt.item_instance_id,
11728            &opt.label,
11729            None,
11730        )
11731        .await?;
11732        // Refresh options (stack may be gone / reduced) or close when empty.
11733        let options = self.state.giveable_inventory_options();
11734        if options.is_empty() {
11735            self.close_worker_give_picker();
11736        } else {
11737            self.state.worker_give_picker = Some(WorkerGivePicker {
11738                worker_instance_id: picker.worker_instance_id,
11739                worker_label: picker.worker_label,
11740                options,
11741            });
11742            if self.state.worker_give_picker_index
11743                >= self
11744                    .state
11745                    .worker_give_picker
11746                    .as_ref()
11747                    .map(|p| p.options.len())
11748                    .unwrap_or(0)
11749            {
11750                self.state.worker_give_picker_index = self
11751                    .state
11752                    .worker_give_picker
11753                    .as_ref()
11754                    .map(|p| p.options.len().saturating_sub(1))
11755                    .unwrap_or(0);
11756            }
11757        }
11758        Ok(())
11759    }
11760
11761    /// Open the workers-menu teach picker for the selected worker (must be in range).
11762    pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
11763        let Some(worker) = self
11764            .state
11765            .hired_workers
11766            .get(self.state.workers_menu_index)
11767            .cloned()
11768        else {
11769            anyhow::bail!("select a hired worker first");
11770        };
11771        let (px, py, _) = self.state.player_position_with_z();
11772        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11773        if dist > WORKER_GIVE_RANGE_M {
11774            anyhow::bail!(
11775                "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
11776                worker.label
11777            );
11778        }
11779        let options = self.state.teachable_blueprint_options(&worker);
11780        if options.is_empty() {
11781            anyhow::bail!("no recipes you know that {} still needs", worker.label);
11782        }
11783        self.state.worker_teach_picker = Some(WorkerTeachPicker {
11784            worker_instance_id: worker.instance_id,
11785            worker_label: worker.label,
11786            worker_level: worker.level,
11787            options,
11788        });
11789        self.state.worker_teach_picker_index = 0;
11790        self.state.show_worker_teach_picker = true;
11791        Ok(())
11792    }
11793
11794    pub fn close_worker_teach_picker(&mut self) {
11795        self.state.show_worker_teach_picker = false;
11796        self.state.worker_teach_picker = None;
11797        self.state.worker_teach_picker_index = 0;
11798    }
11799
11800    pub fn worker_teach_picker_move(&mut self, delta: i32) {
11801        let Some(picker) = &self.state.worker_teach_picker else {
11802            return;
11803        };
11804        let n = picker.options.len();
11805        if n == 0 {
11806            return;
11807        }
11808        let idx = self.state.worker_teach_picker_index as i32;
11809        self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11810    }
11811
11812    pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
11813        let Some(picker) = self.state.worker_teach_picker.clone() else {
11814            anyhow::bail!("teach picker not open");
11815        };
11816        let Some(opt) = picker
11817            .options
11818            .get(self.state.worker_teach_picker_index)
11819            .cloned()
11820        else {
11821            anyhow::bail!("nothing selected");
11822        };
11823        if !opt.level_ok {
11824            anyhow::bail!(
11825                "{} needs level {} (is level {})",
11826                picker.worker_label,
11827                opt.min_level,
11828                opt.worker_level
11829            );
11830        }
11831        if !opt.can_afford {
11832            anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
11833        }
11834        let Some(worker) = self
11835            .state
11836            .hired_workers
11837            .iter()
11838            .find(|w| w.instance_id == picker.worker_instance_id)
11839            .cloned()
11840        else {
11841            anyhow::bail!("worker gone");
11842        };
11843        let (px, py, _) = self.state.player_position_with_z();
11844        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11845        if dist > WORKER_GIVE_RANGE_M {
11846            anyhow::bail!("worker {} too far — stand next to them", worker.label);
11847        }
11848        self.seq += 1;
11849        self.session
11850            .submit_intent(Intent::TeachWorkerBlueprint {
11851                entity_id: self.state.entity_id,
11852                worker_instance_id: picker.worker_instance_id.clone(),
11853                blueprint_id: opt.blueprint_id.clone(),
11854                seq: self.seq,
11855            })
11856            .await?;
11857        self.state.intents_sent += 1;
11858        self.state.push_log(format!(
11859            "Teaching {} to {} ({} cp)",
11860            opt.label, picker.worker_label, opt.cost_copper
11861        ));
11862        self.close_worker_teach_picker();
11863        Ok(())
11864    }
11865
11866    async fn give_item_to_worker(
11867        &mut self,
11868        worker_instance_id: &str,
11869        worker_label: &str,
11870        worker_x: f32,
11871        worker_y: f32,
11872        item_instance_id: uuid::Uuid,
11873        item_label: &str,
11874        quantity: Option<u32>,
11875    ) -> anyhow::Result<()> {
11876        let (px, py, _) = self.state.player_position_with_z();
11877        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
11878        if dist > WORKER_GIVE_RANGE_M {
11879            anyhow::bail!("worker {worker_label} too far — stand next to them");
11880        }
11881        self.seq += 1;
11882        self.session
11883            .submit_intent(Intent::GiveWorkerItem {
11884                entity_id: self.state.entity_id,
11885                worker_instance_id: worker_instance_id.to_string(),
11886                item_instance_id,
11887                quantity,
11888                seq: self.seq,
11889            })
11890            .await?;
11891        self.state.intents_sent += 1;
11892        self.state
11893            .remove_carried_instance(item_instance_id, quantity);
11894        self.state
11895            .push_log(format!("Gave {item_label} to {worker_label}"));
11896        Ok(())
11897    }
11898
11899    /// Equip one item from the player's inventory on the selected worker.
11900    /// The server validates the requested hand/body slot and replaces the
11901    /// previous item into the worker's pack.
11902    pub async fn equip_item_on_worker(
11903        &mut self,
11904        worker_instance_id: &str,
11905        item_instance_id: uuid::Uuid,
11906        slot: &str,
11907    ) -> anyhow::Result<()> {
11908        let Some(worker) = self
11909            .state
11910            .hired_workers
11911            .iter()
11912            .find(|worker| worker.instance_id == worker_instance_id)
11913            .cloned()
11914        else {
11915            anyhow::bail!("worker not found");
11916        };
11917        let (px, py, _) = self.state.player_position_with_z();
11918        if (worker.x - px).hypot(worker.y - py) > WORKER_GIVE_RANGE_M {
11919            anyhow::bail!("worker {} too far — stand next to them", worker.label);
11920        }
11921        self.seq += 1;
11922        self.session
11923            .submit_intent(Intent::EquipWorkerItem {
11924                entity_id: self.state.entity_id,
11925                worker_instance_id: worker.instance_id.clone(),
11926                item_instance_id,
11927                slot: slot.to_string(),
11928                seq: self.seq,
11929            })
11930            .await?;
11931        self.state.intents_sent += 1;
11932        self.state
11933            .push_log(format!("Equipped {slot} on {}", worker.label));
11934        Ok(())
11935    }
11936
11937    /// Open the workers-menu take picker for the selected worker (must be in range).
11938    pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
11939        let Some(worker) = self
11940            .state
11941            .hired_workers
11942            .get(self.state.workers_menu_index)
11943            .cloned()
11944        else {
11945            anyhow::bail!("select a hired worker first");
11946        };
11947        let (px, py, _) = self.state.player_position_with_z();
11948        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11949        if dist > WORKER_GIVE_RANGE_M {
11950            anyhow::bail!(
11951                "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
11952                worker.label
11953            );
11954        }
11955        let options = Self::worker_inventory_options(&worker);
11956        if options.is_empty() {
11957            anyhow::bail!("{} isn't carrying anything", worker.label);
11958        }
11959        let initial_qty = options
11960            .first()
11961            .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
11962            .unwrap_or(1);
11963        self.state.worker_take_picker = Some(WorkerTakePicker {
11964            worker_instance_id: worker.instance_id,
11965            worker_label: worker.label,
11966            options,
11967            quantity: initial_qty,
11968        });
11969        self.state.worker_take_picker_index = 0;
11970        self.state.show_worker_take_picker = true;
11971        Ok(())
11972    }
11973
11974    fn worker_inventory_options(
11975        worker: &flatland_protocol::HiredWorkerView,
11976    ) -> Vec<WorkerGiveOption> {
11977        worker
11978            .inventory
11979            .iter()
11980            .filter_map(|stack| {
11981                let item_instance_id = stack.item_instance_id?;
11982                let label = stack
11983                    .display_name
11984                    .clone()
11985                    .unwrap_or_else(|| stack.template_id.clone());
11986                let label = if stack.quantity > 1 {
11987                    format!("{label} ×{}", stack.quantity)
11988                } else {
11989                    label
11990                };
11991                Some(WorkerGiveOption {
11992                    item_instance_id,
11993                    label,
11994                    quantity: stack.quantity,
11995                    template_id: stack.template_id.clone(),
11996                })
11997            })
11998            .collect()
11999    }
12000
12001    pub fn close_worker_take_picker(&mut self) {
12002        self.state.show_worker_take_picker = false;
12003        self.state.worker_take_picker = None;
12004        self.state.worker_take_picker_index = 0;
12005    }
12006
12007    pub fn worker_take_picker_move(&mut self, delta: i32) {
12008        let Some(picker) = &self.state.worker_take_picker else {
12009            return;
12010        };
12011        let n = picker.options.len();
12012        if n == 0 {
12013            return;
12014        }
12015        let idx = self.state.worker_take_picker_index as i32;
12016        self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
12017        self.clamp_worker_take_quantity();
12018    }
12019
12020    pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
12021        let Some(picker) = &mut self.state.worker_take_picker else {
12022            return;
12023        };
12024        let max = picker
12025            .options
12026            .get(self.state.worker_take_picker_index)
12027            .map(|o| o.quantity.max(1))
12028            .unwrap_or(1);
12029        let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
12030        picker.quantity = next as u32;
12031    }
12032
12033    pub fn worker_take_picker_set_quantity_max(&mut self) {
12034        let Some(picker) = &mut self.state.worker_take_picker else {
12035            return;
12036        };
12037        let max = picker
12038            .options
12039            .get(self.state.worker_take_picker_index)
12040            .map(|o| o.quantity.max(1))
12041            .unwrap_or(1);
12042        picker.quantity = max;
12043    }
12044
12045    pub fn worker_take_picker_set_quantity_min(&mut self) {
12046        let Some(picker) = &mut self.state.worker_take_picker else {
12047            return;
12048        };
12049        picker.quantity = 1;
12050        self.clamp_worker_take_quantity();
12051    }
12052
12053    fn clamp_worker_take_quantity(&mut self) {
12054        let Some(picker) = &mut self.state.worker_take_picker else {
12055            return;
12056        };
12057        let max = picker
12058            .options
12059            .get(self.state.worker_take_picker_index)
12060            .map(|o| o.quantity.max(1))
12061            .unwrap_or(1);
12062        if picker.quantity == 0 || picker.quantity > max {
12063            picker.quantity = if max > 1 { 1 } else { max };
12064        }
12065    }
12066
12067    pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
12068        let Some(picker) = self.state.worker_take_picker.clone() else {
12069            anyhow::bail!("take picker not open");
12070        };
12071        let Some(opt) = picker
12072            .options
12073            .get(self.state.worker_take_picker_index)
12074            .cloned()
12075        else {
12076            anyhow::bail!("no item selected");
12077        };
12078        let Some(worker) = self
12079            .state
12080            .hired_workers
12081            .iter()
12082            .find(|w| w.instance_id == picker.worker_instance_id)
12083            .cloned()
12084        else {
12085            self.close_worker_take_picker();
12086            anyhow::bail!("worker no longer hired");
12087        };
12088        let qty = picker.quantity.clamp(1, opt.quantity.max(1));
12089        let intent_qty = if qty >= opt.quantity { None } else { Some(qty) };
12090        self.take_item_from_worker(
12091            &worker.instance_id,
12092            &worker.label,
12093            worker.x,
12094            worker.y,
12095            opt.item_instance_id,
12096            &opt.label,
12097            intent_qty,
12098        )
12099        .await?;
12100        // Leave the picker open — server Interaction / hired-workers sync refresh
12101        // options. Do not optimistic-strip stacks (rejects used to look like success).
12102        Ok(())
12103    }
12104
12105    async fn take_item_from_worker(
12106        &mut self,
12107        worker_instance_id: &str,
12108        worker_label: &str,
12109        worker_x: f32,
12110        worker_y: f32,
12111        item_instance_id: uuid::Uuid,
12112        item_label: &str,
12113        quantity: Option<u32>,
12114    ) -> anyhow::Result<()> {
12115        let (px, py, _) = self.state.player_position_with_z();
12116        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
12117        if dist > WORKER_GIVE_RANGE_M {
12118            anyhow::bail!("worker {worker_label} too far — stand next to them");
12119        }
12120        self.seq += 1;
12121        self.session
12122            .submit_intent(Intent::TakeWorkerItem {
12123                entity_id: self.state.entity_id,
12124                worker_instance_id: worker_instance_id.to_string(),
12125                item_instance_id,
12126                quantity,
12127                seq: self.seq,
12128            })
12129            .await?;
12130        self.state.intents_sent += 1;
12131        let qty_note = quantity.map(|q| format!(" ×{q}")).unwrap_or_default();
12132        self.state.push_log(format!(
12133            "Taking {item_label}{qty_note} from {worker_label}…"
12134        ));
12135        Ok(())
12136    }
12137
12138    pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
12139        if let Some(since) = self.state.pending_worker_hire_since {
12140            if since.elapsed() < WORKER_HIRE_PENDING_TIMEOUT {
12141                anyhow::bail!("hire request still pending — wait for the worker roster update");
12142            }
12143            self.state.pending_worker_hire_since = None;
12144        }
12145        if !self.state.has_worker_lodging() {
12146            anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
12147        }
12148        self.seq += 1;
12149        self.session
12150            .submit_intent(Intent::HireWorker {
12151                entity_id: self.state.entity_id,
12152                def_id: "worker_laborer".into(),
12153                wage_copper_per_interval: 8,
12154                lodging_container_id: None,
12155                job_yaml: None,
12156                seq: self.seq,
12157            })
12158            .await?;
12159        self.state.intents_sent += 1;
12160        self.state.pending_worker_hire_since = Some(Instant::now());
12161        Ok(())
12162    }
12163
12164    pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
12165        let Some(worker) = self
12166            .state
12167            .hired_workers
12168            .get(self.state.workers_menu_index)
12169            .cloned()
12170        else {
12171            anyhow::bail!("select a hired worker first");
12172        };
12173        let lodging = worker.lodging_container_id.clone().or_else(|| {
12174            crate::worker_route_editor::owned_lodging_container_ids(
12175                &self.state.placed_containers,
12176                self.state.character_id,
12177            )
12178            .into_iter()
12179            .next()
12180            .map(|(id, _)| id)
12181        });
12182        let label = worker.label.clone();
12183        let editor = if let Some(route) = &worker.route {
12184            crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
12185                worker.instance_id,
12186                worker.label,
12187                route,
12188                lodging,
12189            )
12190        } else {
12191            crate::worker_route_editor::WorkerRouteEditorState::new(
12192                worker.instance_id,
12193                worker.label,
12194                lodging,
12195            )
12196        };
12197        self.state.worker_route_editor = Some(editor);
12198        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12199            if let Some(collapsed) =
12200                crate::client_config::ClientConfig::load().worker_route_panel_collapsed
12201            {
12202                ed.panel_collapsed = collapsed;
12203            }
12204        }
12205        self.state.show_workers_menu = false;
12206        self.state.push_log(format!(
12207            "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
12208        ));
12209        Ok(())
12210    }
12211
12212    pub fn close_worker_route_editor(&mut self) {
12213        self.state.worker_route_editor = None;
12214    }
12215
12216    pub fn worker_route_editor_toggle_panel(&mut self) {
12217        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12218            ed.toggle_panel_collapsed();
12219            let collapsed = ed.panel_collapsed;
12220            let mut cfg = crate::client_config::ClientConfig::load();
12221            let _ = cfg.save_worker_route_panel_collapsed(collapsed);
12222        }
12223    }
12224
12225    pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
12226        let n = {
12227            let Some(ed) = self.state.worker_route_editor.as_mut() else {
12228                return;
12229            };
12230            ed.append_waypoint(x, y, z);
12231            ed.stop_count()
12232        };
12233        self.state
12234            .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
12235    }
12236
12237    // ---- picker candidate snapshots ----------------------------------
12238
12239    fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
12240        let (px, py, _) = self.state.player_position_with_z();
12241        let inside = self.state.effective_inside_building();
12242        crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
12243            &self.state.placed_containers,
12244            &self.state.buildings,
12245            self.state.character_id,
12246            px,
12247            py,
12248            &self.state.hired_workers,
12249            inside.as_deref(),
12250        )
12251    }
12252
12253    fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
12254        self.state.route_editor_node_candidates()
12255    }
12256
12257    fn re_open_harvest_picker(&mut self, index: usize, picked: std::collections::BTreeSet<String>) {
12258        use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
12259        let nodes = self.state.route_editor_node_candidates();
12260        let index = if nodes.is_empty() {
12261            ROUTE_PICKER_DONE_ROW
12262        } else {
12263            index.max(1).min(nodes.len())
12264        };
12265        self.re_open_sheet(S::HarvestPicker {
12266            index,
12267            picked,
12268            nodes,
12269        });
12270    }
12271
12272    fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
12273        let (px, py, _) = self.state.player_position_with_z();
12274        let templates = self.re_template_candidates();
12275        crate::worker_route_editor::trade_npc_candidates(
12276            &self.state.npcs,
12277            px,
12278            py,
12279            &templates,
12280        )
12281    }
12282
12283    fn re_template_candidates(&self) -> Vec<String> {
12284        let mut extra = Vec::new();
12285        if let Some(ed) = self.state.worker_route_editor.as_ref() {
12286            for stop in &ed.stops {
12287                match stop {
12288                    crate::worker_route_editor::WorkerRouteStop::DepositAt {
12289                        filter: Some(filter),
12290                        ..
12291                    } => extra.extend(filter.iter().cloned()),
12292                    crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. }
12293                    | crate::worker_route_editor::WorkerRouteStop::ListOnMarket {
12294                        template,
12295                        ..
12296                    } => {
12297                        extra.push(template.clone());
12298                    }
12299                    crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
12300                        if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint)
12301                        {
12302                            extra.push(bp.output.clone());
12303                            for input in &bp.inputs {
12304                                extra.push(input.template_id.clone());
12305                            }
12306                        }
12307                    }
12308                    crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
12309                        for it in items {
12310                            extra.push(it.template.clone());
12311                        }
12312                    }
12313                    _ => {}
12314                }
12315            }
12316            // Worker-known recipe outputs even if the player hasn't learned them yet.
12317            if let Some(worker) = self
12318                .state
12319                .hired_workers
12320                .iter()
12321                .find(|w| w.instance_id == ed.worker_instance_id)
12322            {
12323                for recipe in &worker.known_blueprint_ids {
12324                    if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
12325                        extra.push(bp.output.clone());
12326                    }
12327                }
12328                for stack in &worker.inventory {
12329                    if !stack.template_id.is_empty() && stack.quantity > 0 {
12330                        extra.push(stack.template_id.clone());
12331                    }
12332                }
12333            }
12334        }
12335        crate::worker_route_editor::route_item_template_candidates(
12336            &self.state.placed_containers,
12337            self.state.character_id,
12338            &self.state.inventory,
12339            &self.state.blueprints,
12340            &self.state.resource_nodes,
12341            &extra,
12342            Some(&self.state.item_catalog),
12343        )
12344    }
12345
12346    fn re_blueprint_ids(&self) -> Vec<String> {
12347        let worker_known: Option<&[String]> = self
12348            .state
12349            .worker_route_editor
12350            .as_ref()
12351            .and_then(|ed| {
12352                self.state
12353                    .hired_workers
12354                    .iter()
12355                    .find(|w| w.instance_id == ed.worker_instance_id)
12356            })
12357            .map(|w| w.known_blueprint_ids.as_slice());
12358        crate::worker_route_editor::worker_craft_blueprint_ids(&self.state.blueprints, worker_known)
12359    }
12360
12361    fn re_bed_candidates(&self) -> Vec<(String, String)> {
12362        crate::worker_route_editor::owned_lodging_container_ids(
12363            &self.state.placed_containers,
12364            self.state.character_id,
12365        )
12366    }
12367
12368    fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
12369        self.state
12370            .placed_containers
12371            .iter()
12372            .find(|c| c.id == container_id)
12373            .map(|c| c.contents.clone())
12374            .unwrap_or_default()
12375    }
12376
12377    // ---- sheet navigation (`plans/33`) --------------------------------
12378
12379    fn re_sheet_supports_filter(&self) -> bool {
12380        use crate::worker_route_editor::RouteEditorSheet as S;
12381        self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12382            matches!(
12383                ed.sheet,
12384                S::HarvestPicker { .. }
12385                    | S::SellItem { .. }
12386                    | S::MarketListItem { .. }
12387                    | S::DepositFilter { .. }
12388                    | S::WithdrawItems { .. }
12389                    | S::WithdrawContainers { .. }
12390                    | S::DepositContainers { .. }
12391                    | S::SellNpcs { .. }
12392                    | S::CraftBlueprint { .. }
12393                    | S::BedPicker { .. }
12394            )
12395        })
12396    }
12397
12398    /// Whether a sheet row is shown under the current `/` filter.
12399    pub fn re_sheet_row_visible(&self, row: usize) -> bool {
12400        use crate::worker_route_editor::{
12401            harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
12402            ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
12403        };
12404        let Some(ed) = self.state.worker_route_editor.as_ref() else {
12405            return false;
12406        };
12407        let filter = &ed.sheet_filter;
12408        match &ed.sheet {
12409            S::HarvestPicker { nodes, .. } => harvest_picker_row_matches(nodes, row, filter),
12410            S::SellItem { templates, .. } | S::MarketListItem { templates, .. } => {
12411                if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
12412                    return true;
12413                }
12414                let slot = row.saturating_sub(2);
12415                templates.get(slot).is_some_and(|t| {
12416                    let label = self.state.template_display_name(t);
12417                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12418                })
12419            }
12420            S::DepositFilter { rows, .. } => {
12421                if row >= rows.len() {
12422                    return true;
12423                }
12424                rows.get(row).is_some_and(|(t, _)| {
12425                    let label = self.state.template_display_name(t);
12426                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12427                })
12428            }
12429            S::WithdrawItems { lines, .. } => {
12430                if row >= lines.len() {
12431                    return true;
12432                }
12433                lines.get(row).is_some_and(|l| {
12434                    let label = self.state.template_display_name(&l.template);
12435                    list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
12436                })
12437            }
12438            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12439                self.re_container_candidates().get(row).is_some_and(|c| {
12440                    list_filter_row_matches(
12441                        filter,
12442                        Some(c.dist),
12443                        &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
12444                    )
12445                })
12446            }
12447            S::SellNpcs { .. } => {
12448                if row == 0 {
12449                    return true;
12450                }
12451                self.re_npc_candidates().get(row - 1).is_some_and(|n| {
12452                    list_filter_row_matches(
12453                        filter,
12454                        Some(n.dist),
12455                        &[n.label.as_str(), n.id.as_str()],
12456                    )
12457                })
12458            }
12459            S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
12460                let label = self
12461                    .state
12462                    .blueprints
12463                    .iter()
12464                    .find(|b| &b.id == id)
12465                    .map(|b| {
12466                        if b.label.is_empty() {
12467                            id.as_str()
12468                        } else {
12469                            b.label.as_str()
12470                        }
12471                    })
12472                    .unwrap_or(id.as_str());
12473                list_filter_row_matches(filter, None, &[id.as_str(), label])
12474            }),
12475            S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
12476                list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
12477            }),
12478            _ => true,
12479        }
12480    }
12481
12482    fn re_sheet_clamp_index(&mut self) {
12483        let count = self.re_sheet_row_count();
12484        if count == 0 {
12485            return;
12486        }
12487        let cur = self.re_sheet_index();
12488        if self.re_sheet_row_visible(cur) {
12489            return;
12490        }
12491        for offset in 1..count {
12492            if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
12493                self.re_sheet_set_index(cur + offset);
12494                return;
12495            }
12496            if cur >= offset && self.re_sheet_row_visible(cur - offset) {
12497                self.re_sheet_set_index(cur - offset);
12498                return;
12499            }
12500        }
12501    }
12502
12503    fn re_sheet_set_index(&mut self, index: usize) {
12504        use crate::worker_route_editor::RouteEditorSheet as S;
12505        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12506            return;
12507        };
12508        match &mut ed.sheet {
12509            S::AddMenu { index: slot }
12510            | S::WaypointMenu { index: slot }
12511            | S::HarvestPicker { index: slot, .. }
12512            | S::WithdrawContainers { index: slot }
12513            | S::DepositContainers { index: slot }
12514            | S::SellNpcs { index: slot }
12515            | S::CraftBlueprint { index: slot }
12516            | S::BedPicker { index: slot }
12517            | S::FarmPlotPicker { index: slot, .. }
12518            | S::FarmPlantSeed { index: slot, .. }
12519            | S::WithdrawItems { index: slot, .. }
12520            | S::DepositFilter { index: slot, .. }
12521            | S::SellItem { index: slot, .. } | S::MarketListItem { index: slot, .. } => *slot = index,
12522            _ => {}
12523        }
12524    }
12525
12526    pub fn re_focus_sheet_filter(&mut self) {
12527        if !self.re_sheet_supports_filter() {
12528            return;
12529        }
12530        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12531            ed.sheet_filter_focused = true;
12532        }
12533    }
12534
12535    pub fn re_blur_sheet_filter_keep_text(&mut self) {
12536        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12537            return;
12538        };
12539        if !ed.sheet_filter_focused {
12540            return;
12541        }
12542        ed.sheet_filter_focused = false;
12543        self.re_sheet_clamp_index();
12544    }
12545
12546    pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
12547        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12548            return false;
12549        };
12550        if ed.sheet_filter_focused {
12551            ed.sheet_filter_focused = false;
12552            self.re_sheet_clamp_index();
12553            return true;
12554        }
12555        if !ed.sheet_filter.is_empty() {
12556            ed.sheet_filter.clear();
12557            self.re_sheet_clamp_index();
12558            return true;
12559        }
12560        false
12561    }
12562
12563    pub fn re_append_sheet_filter_char(&mut self, ch: char) {
12564        if ch.is_control() {
12565            return;
12566        }
12567        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12568            return;
12569        };
12570        if !ed.sheet_filter_focused {
12571            return;
12572        }
12573        ed.sheet_filter.push(ch);
12574        self.re_sheet_set_index(0);
12575        self.re_sheet_clamp_index();
12576    }
12577
12578    pub fn re_sheet_filter_backspace(&mut self) {
12579        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12580            return;
12581        };
12582        if !ed.sheet_filter_focused {
12583            return;
12584        }
12585        ed.sheet_filter.pop();
12586        self.re_sheet_set_index(0);
12587        self.re_sheet_clamp_index();
12588    }
12589
12590    /// Row count of the current sheet (for cursor wrapping).
12591    pub fn re_sheet_row_count(&self) -> usize {
12592        use crate::worker_route_editor::{
12593            harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
12594        };
12595        let Some(ed) = self.state.worker_route_editor.as_ref() else {
12596            return 0;
12597        };
12598        match &ed.sheet {
12599            S::Stops => ed.stops.len(),
12600            S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
12601            S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
12602            S::WaypointMapPick => 0,
12603            S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
12604            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12605                self.re_container_candidates().len()
12606            }
12607            S::WithdrawItems { lines, .. } => lines.len() + 1, // + Done row
12608            S::DepositFilter { rows, .. } => rows.len() + 1,   // + Done row
12609            S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, // + auto row
12610            S::SellItem { templates, .. } | S::MarketListItem { templates, .. } => {
12611                sell_item_picker_row_count(templates.len())
12612            }
12613            S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
12614            S::WaitEntry { .. } => 1,
12615            S::BedPicker { .. } => self.re_bed_candidates().len(),
12616            S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
12617            S::FarmPlantSeed { seeds, .. } => seeds.len(),
12618        }
12619    }
12620
12621    /// Current sheet cursor index (0 for sheets without one).
12622    pub fn re_sheet_index(&self) -> usize {
12623        use crate::worker_route_editor::RouteEditorSheet as S;
12624        let Some(ed) = self.state.worker_route_editor.as_ref() else {
12625            return 0;
12626        };
12627        match &ed.sheet {
12628            S::AddMenu { index }
12629            | S::WaypointMenu { index }
12630            | S::HarvestPicker { index, .. }
12631            | S::WithdrawContainers { index }
12632            | S::DepositContainers { index }
12633            | S::SellNpcs { index }
12634            | S::CraftBlueprint { index }
12635            | S::BedPicker { index }
12636            | S::FarmPlotPicker { index, .. }
12637            | S::FarmPlantSeed { index, .. }
12638            | S::WithdrawItems { index, .. }
12639            | S::DepositFilter { index, .. }
12640            | S::SellItem { index, .. } | S::MarketListItem { index, .. } => *index,
12641            _ => 0,
12642        }
12643    }
12644
12645    /// Move the current sheet's cursor, wrapping within its rows.
12646    pub fn re_sheet_move(&mut self, delta: i32) {
12647        let count = self.re_sheet_row_count();
12648        if count == 0 {
12649            return;
12650        }
12651        let cur = self.re_sheet_index();
12652        let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
12653        self.re_sheet_set_index(next);
12654    }
12655
12656    pub fn re_sheet_page(&mut self, pages: i32) {
12657        let count = self.re_sheet_row_count();
12658        if count == 0 {
12659            return;
12660        }
12661        let cur = self.re_sheet_index();
12662        let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
12663        self.re_sheet_set_index(next);
12664    }
12665
12666    /// `[`/`]` on a sheet: adjust quantity (withdraw lines, wait ticks).
12667    pub fn re_sheet_adjust(&mut self, delta: i32) {
12668        use crate::worker_route_editor::RouteEditorSheet as S;
12669        let index = self.re_sheet_index();
12670        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12671            return;
12672        };
12673        match &mut ed.sheet {
12674            S::WithdrawItems { lines, .. } => {
12675                if let Some(line) = lines.get_mut(index) {
12676                    line.adjust_qty(delta);
12677                }
12678            }
12679            S::WaitEntry { ticks } => {
12680                *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
12681            }
12682            _ => {}
12683        }
12684    }
12685
12686    pub fn re_sheet_back(&mut self) {
12687        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12688            return;
12689        };
12690        use crate::worker_route_editor::RouteEditorSheet as S;
12691        let was_editing = ed.editing_index.is_some();
12692        let from_top_picker = matches!(
12693            ed.sheet,
12694            S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
12695        );
12696        ed.sheet_back();
12697        if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
12698            // Chest retarget commits on pick; Esc here just ends the edit sheet.
12699            self.state
12700                .push_log("Route: left edit sheet — press s to save current stops".to_string());
12701        }
12702    }
12703
12704    /// True when the editor is at its root sheet (Esc should close it).
12705    pub fn re_at_root_sheet(&self) -> bool {
12706        self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12707            matches!(
12708                ed.sheet,
12709                crate::worker_route_editor::RouteEditorSheet::Stops
12710            )
12711        })
12712    }
12713
12714    pub fn re_open_add_menu(&mut self) {
12715        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12716            ed.open_add_menu();
12717        }
12718    }
12719
12720    pub fn re_open_bed_picker(&mut self) {
12721        let beds = self.re_bed_candidates();
12722        if beds.is_empty() {
12723            self.state
12724                .push_log("Route: place a camp bed first".to_string());
12725            return;
12726        }
12727        let current = self
12728            .state
12729            .worker_route_editor
12730            .as_ref()
12731            .and_then(|ed| ed.lodging_container_id.clone());
12732        let index = current
12733            .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
12734            .unwrap_or(0);
12735        self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
12736    }
12737
12738    fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
12739        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12740            ed.open_sheet(sheet);
12741        }
12742    }
12743
12744    /// Confirm a sheet's stop and log the outcome.
12745    fn re_confirm_stop(&mut self, stop: crate::worker_route_editor::WorkerRouteStop, what: String) {
12746        let appended = self
12747            .state
12748            .worker_route_editor
12749            .as_mut()
12750            .is_some_and(|ed| ed.confirm_stop(stop));
12751        if appended {
12752            self.state.push_log(format!("Route: + {what}"));
12753        } else {
12754            self.state
12755                .push_log(format!("Route: {what} already in route — selected it"));
12756        }
12757    }
12758
12759    fn re_open_withdraw_items(&mut self, container_id: String) {
12760        use crate::worker_route_editor::{
12761            RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
12762        };
12763        let contents = self.re_container_contents(&container_id);
12764        // When editing, keep the stop's item picks even if the player retargets
12765        // to a different (possibly empty) chest — otherwise Done has nothing to
12766        // confirm and the edit appears to "revert".
12767        let existing = self
12768            .state
12769            .worker_route_editor
12770            .as_ref()
12771            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12772            .and_then(|stop| match stop {
12773                WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
12774                _ => None,
12775            })
12776            .unwrap_or_default();
12777        let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
12778        // Commit the new chest immediately while editing so Esc-before-Done still
12779        // keeps the retarget (items update only when Done is pressed).
12780        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12781            let _ = ed.retarget_withdraw_container(container_id.clone());
12782        }
12783        self.re_open_sheet(S::WithdrawItems {
12784            container_id,
12785            lines,
12786            index: 0,
12787        });
12788    }
12789
12790    fn re_withdraw_items_activate(&mut self, index: usize) {
12791        use crate::worker_route_editor::{
12792            RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
12793        };
12794        enum Outcome {
12795            Cycled,
12796            Confirmed(String),
12797            Empty,
12798        }
12799        let outcome = {
12800            let Some(ed) = self.state.worker_route_editor.as_mut() else {
12801                return;
12802            };
12803            let S::WithdrawItems {
12804                container_id,
12805                lines,
12806                index: sheet_index,
12807            } = &mut ed.sheet
12808            else {
12809                return;
12810            };
12811            *sheet_index = index;
12812            if index < lines.len() {
12813                lines[index].cycle();
12814                Outcome::Cycled
12815            } else {
12816                let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
12817                if items.is_empty() {
12818                    Outcome::Empty
12819                } else {
12820                    let stop = WorkerRouteStop::WithdrawFrom {
12821                        container_id: container_id.clone(),
12822                        items,
12823                    };
12824                    let summary = stop.summary();
12825                    ed.confirm_stop(stop);
12826                    Outcome::Confirmed(summary)
12827                }
12828            }
12829        };
12830        match outcome {
12831            Outcome::Cycled => {}
12832            Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
12833            Outcome::Empty => self.state.push_log(
12834                "Route: pick at least one item (Space/Enter toggles All/qty)".to_string(),
12835            ),
12836        }
12837    }
12838
12839    fn re_open_deposit_filter(&mut self, container_id: String) {
12840        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12841        // Preserve filter when retargeting the deposit chest while editing.
12842        let existing_filter = self
12843            .state
12844            .worker_route_editor
12845            .as_ref()
12846            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12847            .and_then(|stop| match stop {
12848                WorkerRouteStop::DepositAt { filter, .. } => {
12849                    Some(filter.clone().unwrap_or_default())
12850                }
12851                _ => None,
12852            });
12853        let mut candidates = self.re_template_candidates();
12854        if let Some(ref chosen) = existing_filter {
12855            for t in chosen {
12856                if !candidates.iter().any(|c| c == t) {
12857                    candidates.push(t.clone());
12858                }
12859            }
12860            candidates.sort();
12861            candidates.dedup();
12862        }
12863        let rows: Vec<(String, bool)> = match existing_filter {
12864            Some(chosen) => candidates
12865                .iter()
12866                .map(|t| (t.clone(), chosen.contains(t)))
12867                .collect(),
12868            None => candidates.into_iter().map(|t| (t, false)).collect(),
12869        };
12870        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12871            let _ = ed.retarget_deposit_container(container_id.clone());
12872        }
12873        self.re_open_sheet(S::DepositFilter {
12874            container_id,
12875            rows,
12876            index: 0,
12877        });
12878    }
12879
12880    fn re_deposit_filter_activate(&mut self, index: usize) {
12881        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12882        let mut confirmed: Option<String> = None;
12883        {
12884            let Some(ed) = self.state.worker_route_editor.as_mut() else {
12885                return;
12886            };
12887            let S::DepositFilter {
12888                container_id,
12889                rows,
12890                index: sheet_index,
12891            } = &mut ed.sheet
12892            else {
12893                return;
12894            };
12895            *sheet_index = index;
12896            if index < rows.len() {
12897                rows[index].1 = !rows[index].1;
12898            } else {
12899                // "Done" row.
12900                let chosen: Vec<String> = rows
12901                    .iter()
12902                    .filter(|(_, on)| *on)
12903                    .map(|(t, _)| t.clone())
12904                    .collect();
12905                let filter = if chosen.is_empty() {
12906                    None
12907                } else {
12908                    Some(chosen)
12909                };
12910                let stop = WorkerRouteStop::DepositAt {
12911                    container_id: container_id.clone(),
12912                    filter,
12913                };
12914                confirmed = Some(stop.summary());
12915                ed.confirm_stop(stop);
12916            }
12917        }
12918        if let Some(what) = confirmed {
12919            self.state.push_log(format!("Route: + {what}"));
12920        }
12921    }
12922
12923    fn re_open_sell_item(&mut self, npc_id: Option<String>) {
12924        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12925        // Prefill when editing an existing sell stop.
12926        let (pre_npc, pre_template, pre_all) = self
12927            .state
12928            .worker_route_editor
12929            .as_ref()
12930            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12931            .and_then(|stop| match stop {
12932                WorkerRouteStop::TradeWith {
12933                    npc_id,
12934                    template,
12935                    sell_all,
12936                } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
12937                _ => None,
12938            })
12939            .unwrap_or((None, None, true));
12940        let npc_id = npc_id.or(pre_npc);
12941        let mut templates = crate::worker_route_editor::sellable_route_item_template_candidates(
12942            &self.re_template_candidates(),
12943            &self.state.npcs,
12944            npc_id.as_deref(),
12945        );
12946        // Keep an existing invalid selection visible long enough to remove it
12947        // when editing a route after NPC buy lists change.
12948        if let Some(template) = pre_template.as_ref() {
12949            if !templates.iter().any(|candidate| candidate == template) {
12950                templates.push(template.clone());
12951                templates.sort();
12952            }
12953        }
12954        if templates.is_empty() {
12955            let msg = crate::worker_route_editor::sell_merchant_empty_reason(
12956                npc_id.as_deref(),
12957                &self.state.npcs,
12958                &self.re_template_candidates(),
12959            );
12960            self.state.push_log(msg);
12961            return;
12962        }
12963        let mut picked = std::collections::BTreeSet::new();
12964        if let Some(t) = pre_template {
12965            picked.insert(t);
12966        }
12967        self.re_open_sheet(S::SellItem {
12968            npc_id,
12969            templates,
12970            index: if picked.is_empty() {
12971                crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
12972            } else {
12973                2
12974            },
12975            sell_all: pre_all,
12976            picked,
12977        });
12978    }
12979
12980    fn re_sell_item_activate(&mut self, index: usize) {
12981        use crate::worker_route_editor::{
12982            RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
12983        };
12984        let mut batch_log: Option<String> = None;
12985        {
12986            let Some(ed) = self.state.worker_route_editor.as_mut() else {
12987                return;
12988            };
12989            let S::SellItem {
12990                npc_id,
12991                templates,
12992                index: sheet_index,
12993                sell_all,
12994                picked,
12995            } = &mut ed.sheet
12996            else {
12997                return;
12998            };
12999            *sheet_index = index;
13000            if index == ROUTE_PICKER_DONE_ROW {
13001                if picked.is_empty() {
13002                    batch_log =
13003                        Some("Route: pick at least one item (Space toggles, Done confirms)".into());
13004                } else {
13005                    let picks: Vec<String> = picked.iter().cloned().collect();
13006                    let npc = npc_id.clone();
13007                    let all = *sell_all;
13008                    let added = ed.confirm_trade_picks(npc, &picks, all);
13009                    batch_log = Some(format!("Route: + {added} sell stop(s)"));
13010                }
13011            } else if index == SELL_ITEM_TOGGLE_ROW {
13012                *sell_all = !*sell_all;
13013            } else if let Some(template) = templates.get(index.saturating_sub(2)) {
13014                let sellable = crate::worker_route_editor::sellable_route_item_template_candidates(
13015                    std::slice::from_ref(template),
13016                    &self.state.npcs,
13017                    npc_id.as_deref(),
13018                )
13019                .iter()
13020                .any(|candidate| candidate == template);
13021                if !sellable && !picked.contains(template) {
13022                    return;
13023                }
13024                if picked.contains(template) {
13025                    picked.remove(template);
13026                } else {
13027                    picked.insert(template.clone());
13028                }
13029            }
13030        }
13031        if let Some(msg) = batch_log {
13032            self.state.push_log(msg);
13033        }
13034    }
13035
13036    fn re_open_market_list_item(&mut self) {
13037        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13038        let (pre_hall, pre_template, pre_all) = self
13039            .state
13040            .worker_route_editor
13041            .as_ref()
13042            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
13043            .and_then(|stop| match stop {
13044                WorkerRouteStop::ListOnMarket {
13045                    hall_id,
13046                    template,
13047                    list_all,
13048                } => Some((hall_id.clone(), Some(template.clone()), *list_all)),
13049                _ => None,
13050            })
13051            .unwrap_or((None, None, true));
13052        let mut templates = self.re_template_candidates();
13053        // Prefer items with a known NPC-value hint when available; keep the rest
13054        // so the stop can still be authored (sim rejects no-value / unlistable).
13055        templates.sort_by_key(|t| {
13056            std::cmp::Reverse(self.state.item_base_value_copper_hint(t).unwrap_or(0))
13057        });
13058        if let Some(template) = pre_template.as_ref() {
13059            if !templates.iter().any(|c| c == template) {
13060                templates.push(template.clone());
13061            }
13062        }
13063        if templates.is_empty() {
13064            self.state.push_log("Route: no item templates available for market list".to_string());
13065            return;
13066        }
13067        let mut picked = std::collections::BTreeSet::new();
13068        if let Some(t) = pre_template {
13069            picked.insert(t);
13070        }
13071        self.re_open_sheet(S::MarketListItem {
13072            hall_id: pre_hall,
13073            templates,
13074            index: if picked.is_empty() {
13075                crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
13076            } else {
13077                2
13078            },
13079            list_all: pre_all,
13080            picked,
13081        });
13082    }
13083
13084    fn re_market_list_item_activate(&mut self, index: usize) {
13085        use crate::worker_route_editor::{
13086            RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
13087        };
13088        let mut batch_log: Option<String> = None;
13089        {
13090            let Some(ed) = self.state.worker_route_editor.as_mut() else {
13091                return;
13092            };
13093            let S::MarketListItem {
13094                hall_id,
13095                templates,
13096                index: sheet_index,
13097                list_all,
13098                picked,
13099            } = &mut ed.sheet
13100            else {
13101                return;
13102            };
13103            *sheet_index = index;
13104            if index == ROUTE_PICKER_DONE_ROW {
13105                if picked.is_empty() {
13106                    batch_log = Some(
13107                        "Route: pick at least one item (Space toggles, Done confirms)".into(),
13108                    );
13109                } else {
13110                    let picks: Vec<String> = picked.iter().cloned().collect();
13111                    let hall = hall_id.clone();
13112                    let all = *list_all;
13113                    let added = ed.confirm_market_list_picks(hall, &picks, all);
13114                    batch_log = Some(format!("Route: + {added} market-list stop(s)"));
13115                }
13116            } else if index == SELL_ITEM_TOGGLE_ROW {
13117                *list_all = !*list_all;
13118            } else if let Some(template) = templates.get(index.saturating_sub(2)) {
13119                if picked.contains(template) {
13120                    picked.remove(template);
13121                } else {
13122                    picked.insert(template.clone());
13123                }
13124            }
13125        }
13126        if let Some(msg) = batch_log {
13127            self.state.push_log(msg);
13128        }
13129    }
13130
13131    /// Enter on the stop list: open the selected stop's sheet, prefilled.
13132    pub fn re_edit_selected_stop(&mut self) {
13133        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13134        let Some(stop) = self
13135            .state
13136            .worker_route_editor
13137            .as_ref()
13138            .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
13139        else {
13140            self.state
13141                .push_log("Route: no stop selected — press a to add one".to_string());
13142            return;
13143        };
13144        if let Some(ed) = self.state.worker_route_editor.as_mut() {
13145            ed.begin_edit_selected();
13146        }
13147        match stop {
13148            WorkerRouteStop::Waypoint { .. } => {
13149                self.re_open_sheet(S::WaypointMenu { index: 0 });
13150            }
13151            WorkerRouteStop::HarvestNode { node_id } => {
13152                let nodes = self.state.route_editor_node_candidates();
13153                if nodes.is_empty() {
13154                    self.re_cancel_edit();
13155                    self.state
13156                        .push_log("Route: no harvestable nodes visible to retarget".to_string());
13157                } else {
13158                    let mut picked = std::collections::BTreeSet::new();
13159                    picked.insert(node_id.clone());
13160                    let index = nodes
13161                        .iter()
13162                        .position(|n| n.id == node_id)
13163                        .map(|i| i + 1)
13164                        .unwrap_or(1);
13165                    self.re_open_harvest_picker(index, picked);
13166                }
13167            }
13168            WorkerRouteStop::WithdrawFrom { container_id, .. } => {
13169                // Open the container picker so the player can retarget storage
13170                // (previously jumped straight to items on the same chest).
13171                let containers = self.re_container_candidates();
13172                if containers.is_empty() {
13173                    self.re_cancel_edit();
13174                    self.state
13175                        .push_log("Route: place a storage chest first".to_string());
13176                } else {
13177                    let index = containers
13178                        .iter()
13179                        .position(|c| c.id == container_id)
13180                        .unwrap_or(0);
13181                    self.re_open_sheet(S::WithdrawContainers { index });
13182                }
13183            }
13184            WorkerRouteStop::DepositAt { container_id, .. } => {
13185                let containers = self.re_container_candidates();
13186                if containers.is_empty() {
13187                    self.re_cancel_edit();
13188                    self.state
13189                        .push_log("Route: place a storage chest first".to_string());
13190                } else {
13191                    let index = containers
13192                        .iter()
13193                        .position(|c| c.id == container_id)
13194                        .unwrap_or(0);
13195                    self.re_open_sheet(S::DepositContainers { index });
13196                }
13197            }
13198            WorkerRouteStop::TradeWith { npc_id, .. } => {
13199                let npcs = self.re_npc_candidates();
13200                // Row 0 is "auto / nearest"; rows 1.. map to npcs.
13201                let index = npc_id
13202                    .as_ref()
13203                    .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
13204                    .unwrap_or(0);
13205                self.re_open_sheet(S::SellNpcs { index });
13206            }
13207            WorkerRouteStop::ListOnMarket { .. } => {
13208                self.re_open_market_list_item();
13209            }
13210            WorkerRouteStop::CraftAt { blueprint, .. } => {
13211                let bps = self.re_blueprint_ids();
13212                let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
13213                if bps.is_empty() {
13214                    self.re_cancel_edit();
13215                    self.state
13216                        .push_log("Route: no known blueprints to retarget".to_string());
13217                } else {
13218                    self.re_open_sheet(S::CraftBlueprint { index });
13219                }
13220            }
13221            WorkerRouteStop::CultivatePlot { .. } => {
13222                self.re_open_farm_plot_picker(
13223                    crate::worker_route_editor::FarmPlotAction::Cultivate,
13224                );
13225            }
13226            WorkerRouteStop::PlantPlot { .. } => {
13227                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
13228            }
13229            WorkerRouteStop::HarvestPlot { .. } => {
13230                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
13231            }
13232            WorkerRouteStop::RestIfNeeded => {
13233                self.re_cancel_edit();
13234                self.state
13235                    .push_log("Route: rest has no settings (change the bed with l)".to_string());
13236            }
13237            WorkerRouteStop::Wait { wait_ticks } => {
13238                self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
13239            }
13240        }
13241    }
13242
13243    fn re_cancel_edit(&mut self) {
13244        if let Some(ed) = self.state.worker_route_editor.as_mut() {
13245            ed.editing_index = None;
13246        }
13247    }
13248
13249    // ---- mouse clicks inside the overlay ------------------------------
13250
13251    pub fn worker_route_editor_ui_click(
13252        &mut self,
13253        click: crate::worker_route_editor::RouteEditorClick,
13254    ) {
13255        use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
13256        match click {
13257            RouteEditorClick::SelectStop(i) => {
13258                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13259                    ed.sheet = S::Stops;
13260                    ed.select_stop(i);
13261                }
13262            }
13263            RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
13264            RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
13265            RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
13266        }
13267    }
13268
13269    /// Activate (Enter / click) a row of the current sheet.
13270    pub fn re_sheet_row_activate(&mut self, row: usize) {
13271        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13272        let Some(sheet) = self
13273            .state
13274            .worker_route_editor
13275            .as_ref()
13276            .map(|ed| ed.sheet.clone())
13277        else {
13278            return;
13279        };
13280        match sheet {
13281            S::Stops => {
13282                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13283                    ed.select_stop(row);
13284                }
13285            }
13286            S::AddMenu { .. } => match row {
13287                0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
13288                1 => {
13289                    if self.re_node_candidates().is_empty() {
13290                        self.state.push_log(
13291                            "Route: no harvestable nodes visible in this region".to_string(),
13292                        );
13293                    } else {
13294                        self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
13295                    }
13296                }
13297                2 | 3 => {
13298                    if self.re_container_candidates().is_empty() {
13299                        self.state
13300                            .push_log("Route: place a storage chest first".to_string());
13301                    } else if row == 2 {
13302                        self.re_open_sheet(S::WithdrawContainers { index: 0 });
13303                    } else {
13304                        self.re_open_sheet(S::DepositContainers { index: 0 });
13305                    }
13306                }
13307                4 => {
13308                    if self.re_template_candidates().is_empty() {
13309                        self.state.push_log(
13310                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
13311                                .to_string(),
13312                        );
13313                    } else {
13314                        self.re_open_sheet(S::SellNpcs { index: 0 });
13315                    }
13316                }
13317                5 => {
13318                    if self.re_template_candidates().is_empty() {
13319                        self.state.push_log(
13320                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
13321                                .to_string(),
13322                        );
13323                    } else {
13324                        self.re_open_market_list_item();
13325                    }
13326                }
13327                6 => {
13328                    if self.re_blueprint_ids().is_empty() {
13329                        self.state.push_log(
13330                            "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
13331                                .to_string(),
13332                        );
13333                    } else {
13334                        self.re_open_sheet(S::CraftBlueprint { index: 0 });
13335                    }
13336                }
13337                7 => self.re_confirm_stop(
13338                    WorkerRouteStop::RestIfNeeded,
13339                    "rest at lodging (if needed)".into(),
13340                ),
13341                8 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
13342                9 => self.re_open_farm_plot_picker(
13343                    crate::worker_route_editor::FarmPlotAction::Cultivate,
13344                ),
13345                10 => {
13346                    self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant)
13347                }
13348                11 => self
13349                    .re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
13350                _ => {}
13351            },
13352            S::WaypointMenu { .. } => match row {
13353                0 => {
13354                    let (x, y, z) = self.state.player_position_with_z();
13355                    let stop = WorkerRouteStop::Waypoint { x, y, z };
13356                    self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13357                }
13358                1 => {
13359                    self.re_open_sheet(S::WaypointMapPick);
13360                    self.state.push_log(
13361                        "Route: click the map to place the waypoint (Esc to finish)".to_string(),
13362                    );
13363                }
13364                _ => {}
13365            },
13366            S::HarvestPicker { .. } => {
13367                let mut log: Option<String> = None;
13368                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13369                    let S::HarvestPicker {
13370                        index: sheet_index,
13371                        picked,
13372                        nodes,
13373                    } = &mut ed.sheet
13374                    else {
13375                        return;
13376                    };
13377                    *sheet_index = row;
13378                    if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
13379                        if picked.is_empty() {
13380                            log = Some(
13381                                "Route: pick at least one node (Space toggles, Done confirms)"
13382                                    .into(),
13383                            );
13384                        } else {
13385                            let ids: Vec<String> = picked.iter().cloned().collect();
13386                            let added = ed.confirm_harvest_picks(&ids);
13387                            log = Some(format!("Route: + {added} harvest stop(s)"));
13388                        }
13389                    } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
13390                        if picked.contains(&n.id) {
13391                            picked.remove(&n.id);
13392                        } else {
13393                            picked.insert(n.id.clone());
13394                        }
13395                    }
13396                }
13397                if let Some(msg) = log {
13398                    self.state.push_log(msg);
13399                }
13400            }
13401            S::WithdrawContainers { .. } => {
13402                let containers = self.re_container_candidates();
13403                if let Some(c) = containers.get(row) {
13404                    let id = c.id.clone();
13405                    self.re_open_withdraw_items(id);
13406                }
13407            }
13408            S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
13409            S::DepositContainers { .. } => {
13410                let containers = self.re_container_candidates();
13411                if let Some(c) = containers.get(row) {
13412                    let id = c.id.clone();
13413                    self.re_open_deposit_filter(id);
13414                }
13415            }
13416            S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
13417            S::SellNpcs { .. } => {
13418                let templates = self.re_template_candidates();
13419                let npcs = self.re_npc_candidates();
13420                if row == 0 {
13421                    if !crate::worker_route_editor::any_trade_npc_buys_route_item(
13422                        &self.state.npcs,
13423                        &templates,
13424                    ) {
13425                        self.state.push_log(
13426                            crate::worker_route_editor::sell_merchant_empty_reason(
13427                                None,
13428                                &self.state.npcs,
13429                                &templates,
13430                            ),
13431                        );
13432                        return;
13433                    }
13434                    self.re_open_sell_item(None);
13435                    return;
13436                }
13437                let Some(n) = npcs.get(row - 1) else {
13438                    return;
13439                };
13440                if !n.buys_route_item {
13441                    self.state.push_log(
13442                        crate::worker_route_editor::sell_merchant_empty_reason(
13443                            Some(n.id.as_str()),
13444                            &self.state.npcs,
13445                            &templates,
13446                        ),
13447                    );
13448                    return;
13449                }
13450                self.re_open_sell_item(Some(n.id.clone()));
13451            }
13452            S::SellItem { .. } => self.re_sell_item_activate(row),
13453            S::MarketListItem { .. } => self.re_market_list_item_activate(row),
13454            S::CraftBlueprint { .. } => {
13455                let bps = self.re_blueprint_ids();
13456                if let Some(bp) = bps.get(row) {
13457                    let stop = WorkerRouteStop::CraftAt {
13458                        device: "hand".into(),
13459                        blueprint: bp.clone(),
13460                        qty: None,
13461                    };
13462                    self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
13463                }
13464            }
13465            S::WaitEntry { ticks } => {
13466                let stop = WorkerRouteStop::Wait { wait_ticks: ticks };
13467                self.re_confirm_stop(stop, format!("wait {ticks}t"));
13468            }
13469            S::BedPicker { .. } => {
13470                let beds = self.re_bed_candidates();
13471                if let Some((id, name)) = beds.get(row) {
13472                    let (id, name) = (id.clone(), name.clone());
13473                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13474                        ed.lodging_container_id = Some(id.clone());
13475                        ed.sheet = S::Stops;
13476                    }
13477                    self.state
13478                        .push_log(format!("Route: rest bed set to {name}"));
13479                }
13480            }
13481            S::FarmPlotPicker { action, .. } => {
13482                let plots = self.re_farm_plot_candidates();
13483                let Some(plot) = plots.get(row).cloned() else {
13484                    return;
13485                };
13486                match action {
13487                    crate::worker_route_editor::FarmPlotAction::Cultivate => {
13488                        let label = plot_route_label(&plot);
13489                        self.re_confirm_stop(
13490                            WorkerRouteStop::CultivatePlot {
13491                                plot_id: plot.plot_id,
13492                            },
13493                            format!("cultivate {label}"),
13494                        );
13495                    }
13496                    crate::worker_route_editor::FarmPlotAction::Harvest => {
13497                        let label = plot_route_label(&plot);
13498                        self.re_confirm_stop(
13499                            WorkerRouteStop::HarvestPlot {
13500                                plot_id: plot.plot_id,
13501                            },
13502                            format!("harvest {label}"),
13503                        );
13504                    }
13505                    crate::worker_route_editor::FarmPlotAction::Plant => {
13506                        let seeds = self.re_farm_seed_candidates();
13507                        if seeds.is_empty() {
13508                            self.state.push_log(
13509                                "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
13510                            );
13511                            return;
13512                        }
13513                        self.re_open_sheet(S::FarmPlantSeed {
13514                            plot_id: plot.plot_id,
13515                            seeds,
13516                            index: 0,
13517                        });
13518                    }
13519                }
13520            }
13521            S::FarmPlantSeed { plot_id, seeds, .. } => {
13522                if let Some(seed) = seeds.get(row).cloned() {
13523                    self.re_confirm_stop(
13524                        WorkerRouteStop::PlantPlot {
13525                            plot_id,
13526                            seed_template: seed.clone(),
13527                        },
13528                        format!("plant {seed}"),
13529                    );
13530                }
13531            }
13532            S::WaypointMapPick => {}
13533        }
13534    }
13535
13536    fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
13537        use crate::worker_route_editor::RouteEditorSheet as S;
13538        if self.re_farm_plot_candidates().is_empty() {
13539            self.state
13540                .push_log("Route: no farmable plots visible — claim land or get farm access first");
13541            return;
13542        }
13543        self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
13544    }
13545
13546    fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
13547        self.state
13548            .property_plots
13549            .iter()
13550            .filter(|p| p.is_mine || p.may_farm)
13551            .cloned()
13552            .collect()
13553    }
13554
13555    /// Seed templates for a plant-plot route stop — does **not** require seeds on the
13556    /// player. Unions inventory, owned storage, withdraw lines already in the draft,
13557    /// and catalog `seed_for` templates so routes can withdraw-then-plant.
13558    fn re_farm_seed_candidates(&self) -> Vec<String> {
13559        let mut set = std::collections::BTreeSet::new();
13560        let looks_like_seed = |id: &str, catalog: &std::collections::HashMap<String, ItemCatalogEntryView>| {
13561            catalog.get(id).is_some_and(|e| e.is_farm_seed()) || id.ends_with("_seed")
13562        };
13563        for (id, _, _) in self.state.farm_seed_entries() {
13564            set.insert(id);
13565        }
13566        for c in &self.state.placed_containers {
13567            let mine = match (self.state.character_id, c.owner_character_id) {
13568                (Some(a), Some(b)) => a == b,
13569                _ => false,
13570            };
13571            if !mine {
13572                continue;
13573            }
13574            for s in &c.contents {
13575                if s.quantity > 0
13576                    && (s.props.contains_key("seed_for")
13577                        || looks_like_seed(&s.template_id, &self.state.item_catalog))
13578                {
13579                    set.insert(s.template_id.clone());
13580                }
13581            }
13582        }
13583        if let Some(ed) = self.state.worker_route_editor.as_ref() {
13584            for stop in &ed.stops {
13585                if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } =
13586                    stop
13587                {
13588                    for it in items {
13589                        if looks_like_seed(&it.template, &self.state.item_catalog) {
13590                            set.insert(it.template.clone());
13591                        }
13592                    }
13593                }
13594                if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
13595                    seed_template,
13596                    ..
13597                } = stop
13598                {
13599                    if !seed_template.is_empty() {
13600                        set.insert(seed_template.clone());
13601                    }
13602                }
13603            }
13604        }
13605        for (id, entry) in &self.state.item_catalog {
13606            if entry.is_farm_seed() {
13607                set.insert(id.clone());
13608            }
13609        }
13610        set.into_iter().collect()
13611    }
13612
13613    // ---- map clicks (sheet-scoped accelerators) ------------------------
13614
13615    /// Map click while the route editor is open. When a picker sheet is open
13616    /// the click feeds *that* sheet (chest clicks choose the withdraw/deposit
13617    /// container, NPC clicks the merchant, node clicks the harvest target);
13618    /// otherwise the click quick-adds the nearest target (context-aware).
13619    pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
13620        use crate::worker_route_editor as wre;
13621        use wre::RouteEditorSheet as S;
13622        if self.state.worker_route_editor.is_none() {
13623            return;
13624        }
13625        let sheet = self
13626            .state
13627            .worker_route_editor
13628            .as_ref()
13629            .map(|ed| ed.sheet.clone())
13630            .unwrap_or(S::Stops);
13631        match sheet {
13632            S::WaypointMapPick => {
13633                let (_, _, z) = self.state.player_position_with_z();
13634                let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
13635                self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13636                // Stay in map-pick mode when adding (not editing) for fast pathing.
13637                let editing = self
13638                    .state
13639                    .worker_route_editor
13640                    .as_ref()
13641                    .is_some_and(|ed| ed.editing_index.is_some());
13642                if !editing {
13643                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13644                        ed.sheet = S::WaypointMapPick;
13645                    }
13646                }
13647            }
13648            S::HarvestPicker { .. } => {
13649                if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
13650                    let mut log: Option<String> = None;
13651                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13652                        let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
13653                            return;
13654                        };
13655                        let selected = if picked.contains(&node.id) {
13656                            picked.remove(&node.id);
13657                            false
13658                        } else {
13659                            picked.insert(node.id.clone());
13660                            true
13661                        };
13662                        log = Some(format!(
13663                            "Route: {} {}",
13664                            if selected { "selected" } else { "deselected" },
13665                            resource_node_route_label(node)
13666                        ));
13667                    }
13668                    if let Some(msg) = log {
13669                        self.state.push_log(msg);
13670                    }
13671                }
13672            }
13673            S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
13674                // Chest click picks (or switches) the withdraw source.
13675                let inside = self.state.effective_inside_building();
13676                if let Some(cid) = wre::pick_storage_container_at(
13677                    &self.state.placed_containers,
13678                    self.state.character_id,
13679                    x,
13680                    y,
13681                    inside.as_deref(),
13682                ) {
13683                    self.re_open_withdraw_items(cid);
13684                }
13685            }
13686            S::DepositContainers { .. } | S::DepositFilter { .. } => {
13687                let inside = self.state.effective_inside_building();
13688                if let Some(cid) = wre::pick_storage_container_at(
13689                    &self.state.placed_containers,
13690                    self.state.character_id,
13691                    x,
13692                    y,
13693                    inside.as_deref(),
13694                ) {
13695                    self.re_open_deposit_filter(cid);
13696                }
13697            }
13698            S::SellNpcs { .. } => {
13699                if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13700                    self.re_open_sell_item(Some(npc_id));
13701                }
13702            }
13703            S::SellItem { .. } => {
13704                if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13705                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13706                        if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
13707                            *slot = Some(npc_id.clone());
13708                        }
13709                    }
13710                    self.state
13711                        .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
13712                }
13713            }
13714            // Stop list / other sheets: context-aware quick add.
13715            _ => self.worker_route_editor_quick_add_click(x, y),
13716        }
13717    }
13718
13719    /// Quick-add map click with no picker open: nearest of bed/chest/merchant/
13720    /// node wins; duplicates select the existing stop; a click on a merchant
13721    /// while a sell stop is selected pins it.
13722    fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
13723        use crate::worker_route_editor as wre;
13724        let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
13725            let dx = ax - bx;
13726            let dy = ay - by;
13727            (dx * dx + dy * dy).sqrt()
13728        };
13729
13730        // 1. Retarget the selected stop when the click names its target:
13731        //    sell stop → pin the merchant; withdraw stop → set the source chest.
13732        let selected_stop_kind = self
13733            .state
13734            .worker_route_editor
13735            .as_ref()
13736            .and_then(|ed| ed.stops.get(ed.selected_stop_index))
13737            .map(|s| match s {
13738                wre::WorkerRouteStop::TradeWith { .. } => 1,
13739                wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
13740                _ => 0,
13741            })
13742            .unwrap_or(0);
13743        if selected_stop_kind == 1 {
13744            if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13745                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13746                    ed.set_selected_trade_npc(npc_id.clone());
13747                }
13748                self.state
13749                    .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
13750                return;
13751            }
13752        }
13753        if selected_stop_kind == 2 {
13754            let inside = self.state.effective_inside_building();
13755            if let Some(cid) = wre::pick_storage_container_at(
13756                &self.state.placed_containers,
13757                self.state.character_id,
13758                x,
13759                y,
13760                inside.as_deref(),
13761            ) {
13762                let name = self
13763                    .state
13764                    .placed_containers
13765                    .iter()
13766                    .find(|c| c.id == cid)
13767                    .map(|c| c.display_name.clone())
13768                    .unwrap_or_else(|| "container".into());
13769                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13770                    ed.set_selected_withdraw_container(cid.clone());
13771                }
13772                self.state
13773                    .push_log(format!("Route: withdraw source → {name}"));
13774                return;
13775            }
13776        }
13777
13778        // 2. Nearest candidate of any kind wins. Category order is the
13779        //    tie-break for exact overlaps (bed over chest over merchant over node).
13780        enum Target {
13781            Bed(String),
13782            Container(String),
13783            Npc(String, String),
13784            Node(String, String),
13785        }
13786        let mut best: Option<(f32, u8, Target)> = None;
13787        let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
13788            let better = match best {
13789                None => true,
13790                Some((bd, brank, _)) => {
13791                    d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank)
13792                }
13793            };
13794            if better {
13795                *best = Some((d, rank, t));
13796            }
13797        };
13798        let inside = self.state.effective_inside_building();
13799        if let Some(bed_id) = wre::pick_lodging_container_at(
13800            &self.state.placed_containers,
13801            self.state.character_id,
13802            x,
13803            y,
13804            inside.as_deref(),
13805        ) {
13806            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
13807                // A bed that's already the rest bed doubles as a storage chest —
13808                // a second click on it means "deposit here", not "set bed again".
13809                let already_bed =
13810                    self.state.worker_route_editor.as_ref().is_some_and(|ed| {
13811                        ed.lodging_container_id.as_deref() == Some(bed_id.as_str())
13812                    });
13813                if already_bed {
13814                    consider(
13815                        dist(x, y, c.x, c.y),
13816                        1,
13817                        Target::Container(bed_id),
13818                        &mut best,
13819                    );
13820                } else {
13821                    consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
13822                }
13823            }
13824        }
13825        if let Some(cid) = wre::pick_storage_container_at(
13826            &self.state.placed_containers,
13827            self.state.character_id,
13828            x,
13829            y,
13830            inside.as_deref(),
13831        ) {
13832            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
13833                consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
13834            }
13835        }
13836        if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13837            if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
13838                consider(
13839                    dist(x, y, n.x, n.y),
13840                    2,
13841                    Target::Npc(npc_id, label),
13842                    &mut best,
13843                );
13844            }
13845        }
13846        if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
13847            let d = dist(x, y, node.x, node.y);
13848            let label = resource_node_route_label(node);
13849            consider(d, 3, Target::Node(node.id.clone(), label), &mut best);
13850        }
13851
13852        match best.map(|(_, _, t)| t) {
13853            Some(Target::Bed(bed_id)) => {
13854                let name = self
13855                    .state
13856                    .placed_containers
13857                    .iter()
13858                    .find(|c| c.id == bed_id)
13859                    .map(|c| c.display_name.clone())
13860                    .unwrap_or_else(|| "camp bed".into());
13861                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13862                    ed.lodging_container_id = Some(bed_id.clone());
13863                }
13864                self.state
13865                    .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
13866            }
13867            Some(Target::Container(cid)) => {
13868                let name = self
13869                    .state
13870                    .placed_containers
13871                    .iter()
13872                    .find(|c| c.id == cid)
13873                    .map(|c| c.display_name.clone())
13874                    .unwrap_or_else(|| "container".into());
13875                let added = self
13876                    .state
13877                    .worker_route_editor
13878                    .as_mut()
13879                    .is_some_and(|ed| ed.append_deposit_at(&cid));
13880                if added {
13881                    self.state
13882                        .push_log(format!("Route: + deposit at {name} ({cid})"));
13883                } else {
13884                    self.state.push_log(format!(
13885                        "Route: {name} already in route — selected it (d to remove)"
13886                    ));
13887                }
13888            }
13889            Some(Target::Npc(npc_id, label)) => {
13890                // Quick-add sell: first storage template (the full sell flow
13891                // lives in the a → Sell sheet).
13892                let template = self.re_template_candidates().into_iter().next();
13893                let Some(template) = template else {
13894                    self.state.push_log(
13895                        "Route: no items in your storage to sell — stock a chest first".to_string(),
13896                    );
13897                    return;
13898                };
13899                let added = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
13900                    ed.append_trade_with(template.clone(), Some(npc_id.clone()), true)
13901                });
13902                if added {
13903                    self.state
13904                        .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
13905                } else {
13906                    self.state.push_log(format!(
13907                        "Route: {label} already sells {template} — selected it (d to remove)"
13908                    ));
13909                }
13910            }
13911            Some(Target::Node(id, label)) => {
13912                let added = self
13913                    .state
13914                    .worker_route_editor
13915                    .as_mut()
13916                    .is_some_and(|ed| ed.append_harvest_node(&id));
13917                if added {
13918                    self.state
13919                        .push_log(format!("Route: + harvest node {label}"));
13920                } else {
13921                    self.state.push_log(format!(
13922                        "Route: {label} already in route — selected it (d to remove)"
13923                    ));
13924                }
13925            }
13926            None => {}
13927        }
13928    }
13929
13930    pub fn worker_route_editor_select(&mut self, delta: i32) {
13931        let Some(ed) = self.state.worker_route_editor.as_mut() else {
13932            return;
13933        };
13934        if ed.stops.is_empty() {
13935            return;
13936        }
13937        let n = ed.stops.len() as i32;
13938        let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
13939        ed.selected_stop_index = next;
13940    }
13941
13942    pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
13943        let Some(ed) = self.state.worker_route_editor.as_mut() else {
13944            return;
13945        };
13946        if delta < 0 {
13947            ed.move_selected_up();
13948        } else if delta > 0 {
13949            ed.move_selected_down();
13950        }
13951    }
13952
13953    pub fn worker_route_editor_delete_selected(&mut self) {
13954        let removed = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
13955            let before = ed.stop_count();
13956            ed.remove_selected_stop();
13957            ed.stop_count() < before
13958        });
13959        if removed {
13960            self.state.push_log("Route: removed selected stop");
13961        }
13962    }
13963
13964    /// Clear all stops. Saving afterwards parks the worker in idle mode
13965    /// instead of leaving it in a broken job loop.
13966    pub fn worker_route_editor_clear_stops(&mut self) {
13967        let Some(ed) = self.state.worker_route_editor.as_mut() else {
13968            return;
13969        };
13970        if ed.stops.is_empty() {
13971            self.state
13972                .push_log("Route: already empty — s saves an idle worker".to_string());
13973            return;
13974        }
13975        ed.stops.clear();
13976        ed.selected_stop_index = 0;
13977        self.state.push_log(
13978            "Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string(),
13979        );
13980    }
13981
13982    pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
13983        if self.state.pending_worker_job_ack.is_some() {
13984            anyhow::bail!("route save still pending — wait for server ack");
13985        }
13986        let Some(ed) = self.state.worker_route_editor.clone() else {
13987            anyhow::bail!("route editor not open");
13988        };
13989        // Empty route = stand down: park the worker in idle mode rather than
13990        // erroring out or leaving it marching a ghost loop.
13991        let (job_yaml, idle) = if ed.stops.is_empty() {
13992            (ed.build_idle_job_yaml(), true)
13993        } else {
13994            (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
13995        };
13996        let worker_id = ed.worker_instance_id.clone();
13997        let route_view = if idle { None } else { Some(ed.to_route_view()) };
13998        let mode = if idle {
13999            flatland_protocol::WorkerModeView::Idle
14000        } else {
14001            flatland_protocol::WorkerModeView::JobLoop
14002        };
14003        let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
14004            .state
14005            .hired_workers
14006            .iter()
14007            .find(|w| w.instance_id == worker_id)
14008            .map(|w| {
14009                (
14010                    w.route.clone(),
14011                    w.mode,
14012                    w.step_label.clone(),
14013                    w.last_error.clone(),
14014                )
14015            })
14016            .unwrap_or((
14017                None,
14018                flatland_protocol::WorkerModeView::Idle,
14019                String::new(),
14020                None,
14021            ));
14022        self.seq += 1;
14023        let seq = self.seq;
14024        self.session
14025            .submit_intent(Intent::SetWorkerJob {
14026                entity_id: self.state.entity_id,
14027                worker_instance_id: worker_id.clone(),
14028                job_yaml,
14029                seq,
14030            })
14031            .await?;
14032        self.state.intents_sent += 1;
14033        if let Some(w) = self
14034            .state
14035            .hired_workers
14036            .iter_mut()
14037            .find(|w| w.instance_id == worker_id)
14038        {
14039            w.route = route_view;
14040            w.mode = mode;
14041            w.last_error = None;
14042            if idle {
14043                w.step_label.clear();
14044                w.route_stop_index = None;
14045            }
14046        }
14047        self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
14048            seq,
14049            worker_instance_id: worker_id,
14050            worker_label: ed.worker_label.clone(),
14051            idle,
14052            stop_count: ed.stops.len(),
14053            prev_route,
14054            prev_mode,
14055            prev_step_label,
14056            prev_last_error,
14057        });
14058        self.state.push_log(format!(
14059            "Route: saving for {}… (waiting for server)",
14060            ed.worker_label
14061        ));
14062        // Keep editor open until IntentAck; reject Interaction reverts optimistic state.
14063        Ok(())
14064    }
14065    pub fn quest_menu_move(&mut self, delta: i32) {
14066        let n = self.state.active_quest_entries().len();
14067        if n == 0 {
14068            return;
14069        }
14070        let idx = self.state.quest_menu_index as i32;
14071        self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
14072    }
14073
14074    pub fn quest_menu_page(&mut self, pages: i32) {
14075        let n = self.state.active_quest_entries().len();
14076        self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
14077    }
14078
14079    pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
14080        let Some(offer) = self.state.selected_quest_offer().cloned() else {
14081            anyhow::bail!("no quest offer");
14082        };
14083        self.seq += 1;
14084        let seq = self.seq;
14085        self.session
14086            .submit_intent(Intent::AcceptQuest {
14087                entity_id: self.state.entity_id,
14088                quest_id: offer.quest_id,
14089                seq,
14090            })
14091            .await?;
14092        self.state.intents_sent += 1;
14093        Ok(())
14094    }
14095
14096    pub fn quest_offer_move(&mut self, delta: i32) {
14097        self.state.move_quest_offer_selection(delta);
14098    }
14099
14100    pub fn quest_offer_decline(&mut self) {
14101        self.state.clear_quest_offers();
14102        if !self.state.show_npc_chat
14103            && !self.state.show_shop_menu
14104            && self.state.npc_verb_target.is_some()
14105        {
14106            self.state.show_npc_verb_menu = true;
14107        }
14108    }
14109
14110    pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
14111        if !self.state.show_quest_menu {
14112            return Ok(());
14113        }
14114        let active: Vec<_> = self
14115            .state
14116            .active_quest_entries()
14117            .into_iter()
14118            .cloned()
14119            .collect();
14120        let Some(entry) = active.get(self.state.quest_menu_index) else {
14121            return Ok(());
14122        };
14123        if self.state.quest_withdraw_confirm {
14124            if !entry.can_withdraw {
14125                anyhow::bail!("quest cannot be withdrawn");
14126            }
14127            self.seq += 1;
14128            let seq = self.seq;
14129            self.session
14130                .submit_intent(Intent::WithdrawQuest {
14131                    entity_id: self.state.entity_id,
14132                    quest_id: entry.quest_id.clone(),
14133                    seq,
14134                })
14135                .await?;
14136            self.state.intents_sent += 1;
14137            self.state.quest_withdraw_confirm = false;
14138            return Ok(());
14139        }
14140        self.seq += 1;
14141        let seq = self.seq;
14142        self.session
14143            .submit_intent(Intent::TrackQuest {
14144                entity_id: self.state.entity_id,
14145                quest_id: entry.quest_id.clone(),
14146                seq,
14147            })
14148            .await?;
14149        self.state.intents_sent += 1;
14150        Ok(())
14151    }
14152
14153    pub fn quest_request_withdraw(&mut self) {
14154        if self.state.show_quest_menu {
14155            self.state.quest_withdraw_confirm = true;
14156        }
14157    }
14158
14159    pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
14160        if !self.state.is_alive() {
14161            anyhow::bail!("you are dead");
14162        }
14163        let Some(catalog) = self.state.shop_catalog.clone() else {
14164            anyhow::bail!("no shop open");
14165        };
14166        self.seq += 1;
14167        let seq = self.seq;
14168        match self.state.shop_tab {
14169            ShopTab::Buy => {
14170                let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
14171                    anyhow::bail!("nothing selected");
14172                };
14173                if offer.already_owned {
14174                    anyhow::bail!("already owned");
14175                }
14176                self.session
14177                    .submit_intent(Intent::ShopBuy {
14178                        entity_id: self.state.entity_id,
14179                        npc_id: catalog.npc_id.clone(),
14180                        offer_id: offer.offer_id.clone(),
14181                        quantity: self.state.shop_quantity,
14182                        seq,
14183                    })
14184                    .await?;
14185            }
14186            ShopTab::Sell => {
14187                let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
14188                    anyhow::bail!("nothing to sell");
14189                };
14190                if line.quantity == 0 {
14191                    anyhow::bail!("you have no {}", line.label);
14192                }
14193                let quantity = self.state.shop_quantity.min(line.quantity).max(1);
14194                self.session
14195                    .submit_intent(Intent::ShopSell {
14196                        entity_id: self.state.entity_id,
14197                        npc_id: catalog.npc_id.clone(),
14198                        template_id: line.template_id.clone(),
14199                        quantity,
14200                        seq,
14201                    })
14202                    .await?;
14203            }
14204        }
14205        self.state.intents_sent += 1;
14206        Ok(())
14207    }
14208
14209    pub fn craft_menu_move(&mut self, delta: i32) {
14210        let n = self.state.craft_filtered_indices().len();
14211        if n == 0 {
14212            return;
14213        }
14214        let idx = self.state.craft_menu_index as i32;
14215        let next = (idx + delta).rem_euclid(n as i32);
14216        self.state.craft_menu_index = next as usize;
14217        self.state.clamp_craft_batch_quantity();
14218    }
14219
14220    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
14221        self.state.craft_batch_adjust_quantity(delta);
14222    }
14223
14224    pub fn craft_batch_set_max(&mut self) {
14225        self.state.craft_batch_set_max();
14226    }
14227
14228    pub fn craft_batch_set_min(&mut self) {
14229        self.state.craft_batch_set_min();
14230    }
14231
14232    pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
14233        let Some(blueprint) = self.state.craft_selected_blueprint().cloned() else {
14234            anyhow::bail!("no blueprints in this tab");
14235        };
14236        if !self.state.can_craft_blueprint(&blueprint) {
14237            let hint = self
14238                .state
14239                .craft_missing_hint(&blueprint)
14240                .unwrap_or_else(|| "missing materials".into());
14241            anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
14242        }
14243        let count = self.state.craft_batch_quantity;
14244        let max = self.state.max_craft_batches(&blueprint);
14245        if max == 0 {
14246            anyhow::bail!("cannot craft {}", blueprint.label);
14247        }
14248        let batches = count.min(max);
14249        self.craft(&blueprint.id, Some(batches)).await?;
14250        // Keep the craft menu open so progress / blockers stay visible.
14251        Ok(())
14252    }
14253
14254    pub async fn move_by(
14255        &mut self,
14256        forward: f32,
14257        strafe: f32,
14258        vertical: f32,
14259        sprint: bool,
14260        sneak: bool,
14261    ) -> anyhow::Result<()> {
14262        if !self.state.is_alive() {
14263            anyhow::bail!("you are dead");
14264        }
14265        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
14266            self.last_move_forward = forward;
14267            self.last_move_strafe = strafe;
14268        }
14269        self.seq += 1;
14270        self.session
14271            .submit_intent(Intent::Move {
14272                entity_id: self.state.entity_id,
14273                forward,
14274                strafe,
14275                vertical,
14276                sprint: sprint && !sneak,
14277                sneak,
14278                seq: self.seq,
14279            })
14280            .await?;
14281        self.state.intents_sent += 1;
14282        Ok(())
14283    }
14284
14285    pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
14286        if !self.state.connected {
14287            crate::harvest_trace!("harvest_nearest rejected: not connected");
14288            anyhow::bail!("not connected");
14289        }
14290        if !self.state.is_alive() {
14291            crate::harvest_trace!("harvest_nearest rejected: player dead");
14292            anyhow::bail!("you are dead");
14293        }
14294        if self.state.harvest_in_progress {
14295            if self.state.harvest_state_stale() {
14296                self.state.clear_harvest_state();
14297            } else {
14298                anyhow::bail!("already harvesting");
14299            }
14300        }
14301        let (px, py) = self
14302            .state
14303            .player
14304            .as_ref()
14305            .map(|p| (p.transform.position.x, p.transform.position.y))
14306            .unwrap_or((0.0, 0.0));
14307
14308        let available = self
14309            .state
14310            .resource_nodes
14311            .iter()
14312            .filter(|n| !n.harvest_off)
14313            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14314            .count();
14315        let node_id = self
14316            .state
14317            .resource_nodes
14318            .iter()
14319            .filter(|n| !n.harvest_off)
14320            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14321            .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
14322            .min_by(|a, b| {
14323                let da = distance(px, py, a.x, a.y);
14324                let db = distance(px, py, b.x, b.y);
14325                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
14326            })
14327            .map(|n| n.id.clone());
14328
14329        let Some(node_id) = node_id else {
14330            let has_loot = self
14331                .state
14332                .ground_drops
14333                .iter()
14334                .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14335            if has_loot {
14336                return self.pickup_nearest().await;
14337            }
14338            anyhow::bail!(
14339                "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
14340            );
14341        };
14342
14343        self.seq += 1;
14344        let seq = self.seq;
14345        crate::harvest_trace!(
14346            entity_id = self.state.entity_id,
14347            node_id = %node_id,
14348            seq,
14349            px,
14350            py,
14351            available_nodes = available,
14352            "submitting harvest intent"
14353        );
14354        self.session
14355            .submit_intent(Intent::Harvest {
14356                entity_id: self.state.entity_id,
14357                node_id,
14358                seq,
14359            })
14360            .await?;
14361        self.state.intents_sent += 1;
14362        self.state.harvest_in_progress = true;
14363        self.state.harvest_started_at = Some(Instant::now());
14364        self.state.push_log("Harvesting…");
14365        crate::harvest_trace!(
14366            entity_id = self.state.entity_id,
14367            seq,
14368            "harvest intent queued to session"
14369        );
14370        Ok(())
14371    }
14372
14373    pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
14374        if !self.state.is_alive() {
14375            anyhow::bail!("you are dead");
14376        }
14377        let blueprint_id = self
14378            .state
14379            .blueprints
14380            .iter()
14381            .find(|bp| self.state.can_craft_blueprint(bp))
14382            .map(|bp| bp.id.clone())
14383            .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
14384        self.craft(&blueprint_id, None).await
14385    }
14386
14387    pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
14388        if !self.state.is_alive() {
14389            anyhow::bail!("you are dead");
14390        }
14391        self.seq += 1;
14392        self.session
14393            .submit_intent(Intent::Craft {
14394                entity_id: self.state.entity_id,
14395                blueprint_id: blueprint_id.to_string(),
14396                count,
14397                seq: self.seq,
14398            })
14399            .await?;
14400        self.state.intents_sent += 1;
14401        let (label, batches) = self
14402            .state
14403            .blueprints
14404            .iter()
14405            .find(|b| b.id == blueprint_id)
14406            .map(|b| {
14407                let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
14408                (b.label.as_str(), n)
14409            })
14410            .unwrap_or((blueprint_id, count.unwrap_or(1)));
14411        self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
14412        self.state.craft_channel_blueprint_id = Some(blueprint_id.to_string());
14413        Ok(())
14414    }
14415
14416    pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
14417        if !self.state.is_alive() {
14418            anyhow::bail!("you are dead");
14419        }
14420        let target_id = match self.state.nearest_interact_target() {
14421            Some(id) => id,
14422            None => {
14423                anyhow::bail!("nothing to interact with nearby");
14424            }
14425        };
14426        if self.state.npcs.iter().any(|n| n.id == target_id) {
14427            self.state.show_npc_verb_menu = true;
14428            self.state.npc_verb_target = Some(target_id);
14429            self.state.npc_verb_index = 0;
14430            self.state.npc_verb_notice = None;
14431            return Ok(());
14432        }
14433        if self
14434            .state
14435            .hired_workers
14436            .iter()
14437            .any(|w| w.instance_id == target_id)
14438        {
14439            return self.open_workers_menu_for(&target_id).await;
14440        }
14441        if let Ok(peer_id) = target_id.parse::<EntityId>() {
14442            if self
14443                .state
14444                .hired_workers
14445                .iter()
14446                .any(|w| w.entity_id == peer_id)
14447            {
14448                if let Some(w) = self
14449                    .state
14450                    .hired_workers
14451                    .iter()
14452                    .find(|w| w.entity_id == peer_id)
14453                {
14454                    let id = w.instance_id.clone();
14455                    return self.open_workers_menu_for(&id).await;
14456                }
14457            }
14458            if let Some(entity) = self
14459                .state
14460                .entities
14461                .iter()
14462                .find(|e| e.id == peer_id && e.id != self.state.entity_id)
14463            {
14464                self.state.player_verbs.open_for(peer_id, &entity.label);
14465                return Ok(());
14466            }
14467        }
14468        self.seq += 1;
14469        self.session
14470            .submit_intent(Intent::Interact {
14471                entity_id: self.state.entity_id,
14472                target_id: target_id.clone(),
14473                seq: self.seq,
14474            })
14475            .await?;
14476        self.state.intents_sent += 1;
14477        Ok(())
14478    }
14479
14480    /// Context-sensitive world use: loot → interact/door → chest → plot farm → harvest.
14481    ///
14482    /// After loot-first, follows [`crate::UseWorldKind::cascade_stage`] so HUD probe
14483    /// and `f` never diverge (doors beat lodging/chest pickup indoors).
14484    pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
14485        if !self.state.is_alive() {
14486            anyhow::bail!("you are dead");
14487        }
14488        let (px, py) = self.state.player_position();
14489        let has_loot = self
14490            .state
14491            .ground_drops
14492            .iter()
14493            .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14494        if has_loot {
14495            return self.pickup_nearest().await;
14496        }
14497
14498        // Align with probe cascade: interact (doors/NPCs/boards) → chest → harvest.
14499        if let Some(primary) = self.state.probe_use_world().primary {
14500            match primary.kind.cascade_stage() {
14501                0 => return self.interact_nearest().await,
14502                2 => return self.pickup_nearest_container().await,
14503                3 => return self.harvest_nearest().await,
14504                _ => {}
14505            }
14506        }
14507
14508        if let Some(plot) = self.state.my_plot_under_player().cloned() {
14509            // Double-tap f within 1.2s sells; otherwise farm the plot.
14510            const SELL_WINDOW: Duration = Duration::from_millis(1200);
14511            let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
14512                && self
14513                    .state
14514                    .sell_plot_armed_at
14515                    .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
14516            if sell_armed {
14517                return self.confirm_sell_plot_to_crown(plot.plot_id).await;
14518            }
14519            self.state.sell_plot_confirm = None;
14520            self.state.sell_plot_armed_at = None;
14521
14522            // Prefer farm actions on your own land unless an NPC/player/door/board
14523            // is in interact range (those still win).
14524            let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
14525                self.state.npcs.iter().any(|n| n.id == id)
14526                    || self.state.hired_workers.iter().any(|w| w.instance_id == id)
14527                    || self.state.doors.iter().any(|d| d.id == id)
14528                    || self.state.interactables.iter().any(|i| {
14529                        i.id == id
14530                            && matches!(i.kind.as_str(), "quest_board" | "well" | "exit" | "enter")
14531                    })
14532                    || id.parse::<EntityId>().is_ok_and(|eid| {
14533                        self.state
14534                            .entities
14535                            .iter()
14536                            .any(|e| e.id == eid && e.id != self.state.entity_id)
14537                    })
14538            });
14539            if !blocking_interact {
14540                // On your plot, `f` is harvest only — use `c` / `p` for till and plant.
14541                match self.harvest_nearest().await {
14542                    Ok(()) => return Ok(()),
14543                    Err(err) => {
14544                        let msg = err.to_string();
14545                        if !(msg.contains("no harvestable")
14546                            || msg.contains("press p")
14547                            || msg.contains("press f")
14548                            || msg.contains("nothing"))
14549                        {
14550                            return Err(err);
14551                        }
14552                    }
14553                }
14554                return Ok(());
14555            }
14556        }
14557        if self.state.nearest_interact_target().is_some() {
14558            return self.interact_nearest().await;
14559        }
14560        // Prefer a clear "move closer" when a board is visible but out of reach,
14561        // instead of silently falling through to harvest.
14562        if let Some((label, dist)) = self.state.nearest_quest_board() {
14563            if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
14564                anyhow::bail!(
14565                    "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
14566                );
14567            }
14568        }
14569
14570        match self.harvest_nearest().await {
14571            Ok(()) => Ok(()),
14572            Err(err) => {
14573                let msg = err.to_string();
14574                if msg.contains("no harvestable")
14575                    || msg.contains("press p")
14576                    || msg.contains("press f")
14577                {
14578                    anyhow::bail!(
14579                        "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
14580                    );
14581                }
14582                Err(err)
14583            }
14584        }
14585    }
14586
14587    /// Enter claim-mode footprint editor on unclaimed crown land (`k`).
14588    pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
14589        if !self.state.is_alive() {
14590            anyhow::bail!("you are dead");
14591        }
14592        if self.state.claim_mode.is_some() {
14593            anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
14594        }
14595        let zone = self
14596            .state
14597            .free_property_zone_under_player()
14598            .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
14599        let zone_id = zone.id.clone();
14600        let label = zone
14601            .label
14602            .as_deref()
14603            .filter(|s| !s.trim().is_empty())
14604            .unwrap_or(zone.id.as_str())
14605            .to_string();
14606        self.enter_claim_mode(&zone_id);
14607        self.state.push_log(format!(
14608            "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
14609        ));
14610        Ok(())
14611    }
14612
14613    /// Enter claim mode over a property zone (default 4×4 or min size).
14614    pub fn enter_claim_mode(&mut self, zone_id: &str) {
14615        let Some(zone) = self
14616            .state
14617            .property_zones
14618            .iter()
14619            .find(|z| z.id == zone_id)
14620            .cloned()
14621        else {
14622            self.state.push_log("unknown property zone");
14623            return;
14624        };
14625        self.state.sell_plot_confirm = None;
14626        self.state.sell_plot_armed_at = None;
14627        let min_area = self
14628            .state
14629            .property_plot_settings
14630            .as_ref()
14631            .map(|s| s.min_plot_area_m2)
14632            .unwrap_or(4.0)
14633            .max(1.0);
14634        let min_side = min_area.sqrt().ceil().max(1.0) as u32;
14635        let side = 4u32.max(min_side);
14636        let (px, py) = self.state.player_position();
14637        let anchor_x = px.floor();
14638        let anchor_y = py.floor();
14639        self.state.claim_mode = Some(ClaimModeState {
14640            zone_id: zone.id.clone(),
14641            width_m: side,
14642            height_m: side,
14643            anchor_x,
14644            anchor_y,
14645        });
14646        let label = zone
14647            .label
14648            .as_deref()
14649            .filter(|s| !s.trim().is_empty())
14650            .unwrap_or(zone.id.as_str());
14651        self.state.push_log(format!(
14652            "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
14653        ));
14654    }
14655
14656    pub fn cancel_claim_mode(&mut self) {
14657        if self.state.claim_mode.take().is_some() {
14658            self.state.push_log("Claim cancelled");
14659        }
14660    }
14661
14662    /// Enter relocate mode for a placed container (1×1 ghost).
14663    pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
14664        if !self.state.is_alive() {
14665            anyhow::bail!("you are dead");
14666        }
14667        if self.state.relocate_mode.is_some() {
14668            anyhow::bail!("already relocating — Enter confirm, Esc cancel");
14669        }
14670        if self.state.claim_mode.is_some() {
14671            anyhow::bail!("finish or cancel claim mode first");
14672        }
14673        let chest = self
14674            .state
14675            .placed_containers
14676            .iter()
14677            .find(|c| c.id == container_id)
14678            .cloned()
14679            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
14680        let (px, py) = self.state.player_position();
14681        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
14682            anyhow::bail!("too far from {}", chest.display_name);
14683        }
14684        if chest.locked && !chest.accessible {
14685            anyhow::bail!(
14686                "need the matching key for {} before moving it",
14687                chest.display_name
14688            );
14689        }
14690        let label = if chest.display_name.trim().is_empty() {
14691            chest.template_id.clone()
14692        } else {
14693            chest.display_name.clone()
14694        };
14695        self.state.relocate_mode = Some(RelocateModeState {
14696            container_id: chest.id.clone(),
14697            label: label.clone(),
14698            cursor_x: chest.x.floor() + 0.5,
14699            cursor_y: chest.y.floor() + 0.5,
14700        });
14701        self.state.push_log(format!(
14702            "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
14703        ));
14704        Ok(())
14705    }
14706
14707    /// World `Shift+m`: relocate nearest owned/accessible placed container.
14708    pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
14709        let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
14710            anyhow::bail!("no chest nearby to relocate");
14711        };
14712        if chest.locked && !chest.accessible {
14713            anyhow::bail!(
14714                "need the matching key for {} before moving it",
14715                chest.display_name
14716            );
14717        }
14718        // Prefer own chests: if owner is set and we're not the owner, skip unless accessible unlocked?
14719        // Server enforces ownership; client starts on nearest accessible chest in range.
14720        self.begin_relocate_container(&chest.id)
14721    }
14722
14723    pub fn cancel_relocate_mode(&mut self) {
14724        if self.state.relocate_mode.take().is_some() {
14725            self.state.push_log("Relocate cancelled");
14726        }
14727    }
14728
14729    pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
14730        let Some(mode) = self.state.relocate_mode.as_mut() else {
14731            return;
14732        };
14733        let max_x = self.state.world_width_m.max(1.0);
14734        let max_y = self.state.world_height_m.max(1.0);
14735        let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
14736        let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
14737        mode.cursor_x = nx.floor() + 0.5;
14738        mode.cursor_y = ny.floor() + 0.5;
14739    }
14740
14741    pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
14742        let Some(mode) = self.state.relocate_mode.as_mut() else {
14743            return;
14744        };
14745        let max_x = self.state.world_width_m.max(1.0);
14746        let max_y = self.state.world_height_m.max(1.0);
14747        mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
14748        mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
14749    }
14750
14751    pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
14752        if !self.state.is_alive() {
14753            anyhow::bail!("you are dead");
14754        }
14755        let Some(mode) = self.state.relocate_mode.clone() else {
14756            anyhow::bail!("not relocating");
14757        };
14758        let (px, py) = self.state.player_position();
14759        let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
14760        if dist > 8.0 {
14761            anyhow::bail!("destination too far (max 8 m)");
14762        }
14763        self.seq += 1;
14764        self.session
14765            .submit_intent(Intent::MovePlacedContainer {
14766                entity_id: self.state.entity_id,
14767                container_id: mode.container_id.clone(),
14768                x: mode.cursor_x,
14769                y: mode.cursor_y,
14770                seq: self.seq,
14771            })
14772            .await?;
14773        self.state.intents_sent += 1;
14774        self.state.relocate_mode = None;
14775        self.state.push_log(format!("Moving {}…", mode.label));
14776        Ok(())
14777    }
14778
14779    pub fn claim_set_preset(&mut self, w: u32, h: u32) {
14780        let Some(mode) = self.state.claim_mode.as_mut() else {
14781            return;
14782        };
14783        mode.width_m = w.max(1);
14784        mode.height_m = h.max(1);
14785    }
14786
14787    pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
14788        let Some(mode) = self.state.claim_mode.as_mut() else {
14789            return;
14790        };
14791        let w = (mode.width_m as i32 + dw).max(1) as u32;
14792        let h = (mode.height_m as i32 + dh).max(1) as u32;
14793        mode.width_m = w;
14794        mode.height_m = h;
14795    }
14796
14797    /// Nudge the claim footprint SW corner one meter cell (WASD / arrows).
14798    pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
14799        let Some(mode) = self.state.claim_mode.as_mut() else {
14800            return;
14801        };
14802        let max_x = self.state.world_width_m.max(1.0);
14803        let max_y = self.state.world_height_m.max(1.0);
14804        let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
14805        let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
14806        mode.anchor_x = nx.floor();
14807        mode.anchor_y = ny.floor();
14808    }
14809
14810    pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
14811        if !self.state.is_alive() {
14812            anyhow::bail!("you are dead");
14813        }
14814        let Some(mode) = self.state.claim_mode.clone() else {
14815            anyhow::bail!("not in claim mode");
14816        };
14817        let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
14818            self.state.claim_quote()
14819        else {
14820            anyhow::bail!("cannot quote claim");
14821        };
14822        if !valid {
14823            anyhow::bail!(reason);
14824        }
14825        if !can_afford {
14826            anyhow::bail!(
14827                "not enough copper (need {})",
14828                crate::currency::format_copper(purchase)
14829            );
14830        }
14831        let (x0, y0, x1, y1) = self
14832            .state
14833            .claim_footprint_rect()
14834            .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
14835        let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
14836        self.seq += 1;
14837        self.session
14838            .submit_intent(Intent::BuyPlot {
14839                entity_id: self.state.entity_id,
14840                zone_id: mode.zone_id,
14841                x0,
14842                y0,
14843                x1,
14844                y1,
14845                seq: self.seq,
14846            })
14847            .await?;
14848        self.state.intents_sent += 1;
14849        self.state.claim_mode = None;
14850        self.state.push_log(format!(
14851            "Buying plot for {}",
14852            crate::currency::format_copper(purchase)
14853        ));
14854        Ok(())
14855    }
14856
14857    pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
14858        if !self.state.is_alive() {
14859            anyhow::bail!("you are dead");
14860        }
14861        let zone_id = self
14862            .state
14863            .claim_mode
14864            .as_ref()
14865            .map(|m| m.zone_id.clone())
14866            .or_else(|| {
14867                self.state
14868                    .free_property_zone_under_player()
14869                    .map(|z| z.id.clone())
14870            })
14871            .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
14872        self.seq += 1;
14873        self.session
14874            .submit_intent(Intent::BuyPlotAllFree {
14875                entity_id: self.state.entity_id,
14876                zone_id,
14877                seq: self.seq,
14878            })
14879            .await?;
14880        self.state.intents_sent += 1;
14881        self.state.claim_mode = None;
14882        self.state.push_log("Claiming largest free plot…");
14883        Ok(())
14884    }
14885
14886    pub async fn confirm_sell_plot_to_crown(&mut self, plot_id: uuid::Uuid) -> anyhow::Result<()> {
14887        if !self.state.is_alive() {
14888            anyhow::bail!("you are dead");
14889        }
14890        self.seq += 1;
14891        self.session
14892            .submit_intent(Intent::SellPlotToCrown {
14893                entity_id: self.state.entity_id,
14894                plot_id,
14895                seq: self.seq,
14896            })
14897            .await?;
14898        self.state.intents_sent += 1;
14899        self.state.sell_plot_confirm = None;
14900        self.state.sell_plot_armed_at = None;
14901        self.state.push_log("Selling plot to the crown…");
14902        Ok(())
14903    }
14904
14905    pub async fn set_plot_farm_public(
14906        &mut self,
14907        plot_id: uuid::Uuid,
14908        public: bool,
14909        public_tax_discount_bps: u32,
14910    ) -> anyhow::Result<()> {
14911        self.seq += 1;
14912        self.session
14913            .submit_intent(Intent::SetPlotFarmPublic {
14914                entity_id: self.state.entity_id,
14915                plot_id,
14916                public,
14917                public_tax_discount_bps,
14918                seq: self.seq,
14919            })
14920            .await?;
14921        self.state.intents_sent += 1;
14922        Ok(())
14923    }
14924
14925    pub async fn plot_farm_allow_upsert(
14926        &mut self,
14927        plot_id: uuid::Uuid,
14928        character_id: Option<uuid::Uuid>,
14929        character_name: String,
14930        tax_discount_bps: u32,
14931    ) -> anyhow::Result<()> {
14932        self.seq += 1;
14933        self.session
14934            .submit_intent(Intent::PlotFarmAllowUpsert {
14935                entity_id: self.state.entity_id,
14936                plot_id,
14937                character_id,
14938                character_name,
14939                tax_discount_bps,
14940                seq: self.seq,
14941            })
14942            .await?;
14943        self.state.intents_sent += 1;
14944        Ok(())
14945    }
14946
14947    pub async fn plot_farm_allow_remove(
14948        &mut self,
14949        plot_id: uuid::Uuid,
14950        character_id: uuid::Uuid,
14951    ) -> anyhow::Result<()> {
14952        self.seq += 1;
14953        self.session
14954            .submit_intent(Intent::PlotFarmAllowRemove {
14955                entity_id: self.state.entity_id,
14956                plot_id,
14957                character_id,
14958                seq: self.seq,
14959            })
14960            .await?;
14961        self.state.intents_sent += 1;
14962        Ok(())
14963    }
14964
14965    pub fn open_farm_access_panel(&mut self) {
14966        let Some(plot) = self.state.my_plot_under_player() else {
14967            self.state
14968                .push_log("Stand on your deed plot to manage farm access");
14969            return;
14970        };
14971        self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
14972        self.state.farm_access_index = 0;
14973        self.state.show_farm_access = true;
14974    }
14975
14976    pub fn close_farm_access_panel(&mut self) {
14977        self.state.show_farm_access = false;
14978        self.state.farm_access_name_draft.clear();
14979        self.state.farm_access_index = 0;
14980    }
14981
14982    pub fn farm_access_move(&mut self, delta: i32) {
14983        let n = self.farm_access_row_count().max(1);
14984        let idx = self.state.farm_access_index as i32 + delta;
14985        self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
14986    }
14987
14988    pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
14989        let Some(plot) = self.state.my_plot_under_player() else {
14990            return vec![FarmAccessRow::PublicToggle];
14991        };
14992        let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
14993        for g in &plot.farm_allow {
14994            rows.push(FarmAccessRow::AllowRemove {
14995                character_id: g.character_id,
14996                label: if g.character_label.trim().is_empty() {
14997                    g.character_id.to_string()[..8].to_string()
14998                } else {
14999                    g.character_label.clone()
15000                },
15001                tax_discount_bps: g.tax_discount_bps,
15002            });
15003        }
15004        for e in &self.state.entities {
15005            if e.id == self.state.entity_id || e.label.trim().is_empty() {
15006                continue;
15007            }
15008            if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
15009                continue;
15010            }
15011            if self
15012                .state
15013                .npcs
15014                .iter()
15015                .any(|n| n.id == e.label || n.label == e.label)
15016            {
15017                continue;
15018            }
15019            if plot
15020                .farm_allow
15021                .iter()
15022                .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
15023            {
15024                continue;
15025            }
15026            rows.push(FarmAccessRow::NearbyAdd {
15027                name: e.label.clone(),
15028            });
15029        }
15030        rows
15031    }
15032
15033    pub fn farm_access_row_count(&self) -> usize {
15034        self.farm_access_rows().len().max(1)
15035    }
15036
15037    pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
15038        let Some(plot) = self.state.my_plot_under_player().cloned() else {
15039            self.close_farm_access_panel();
15040            return Ok(());
15041        };
15042        let rows = self.farm_access_rows();
15043        let Some(row) = rows.get(self.state.farm_access_index) else {
15044            return Ok(());
15045        };
15046        match row {
15047            FarmAccessRow::PublicToggle => {
15048                self.set_plot_farm_public(
15049                    plot.plot_id,
15050                    !plot.farm_public,
15051                    plot.public_tax_discount_bps,
15052                )
15053                .await
15054            }
15055            FarmAccessRow::PublicDiscount => Ok(()),
15056            FarmAccessRow::AllowRemove { character_id, .. } => {
15057                self.plot_farm_allow_remove(plot.plot_id, *character_id)
15058                    .await
15059            }
15060            FarmAccessRow::NearbyAdd { name } => {
15061                let disc = self
15062                    .state
15063                    .farm_access_discount_bps
15064                    .max(plot.public_tax_discount_bps);
15065                self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
15066                    .await
15067            }
15068        }
15069    }
15070
15071    pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
15072        let Some(plot) = self.state.my_plot_under_player().cloned() else {
15073            return Ok(());
15074        };
15075        let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
15076        self.state.farm_access_discount_bps = next;
15077        self.state.farm_access_index = 1;
15078        self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
15079            .await
15080    }
15081
15082    /// Till the cell under you on a farmable plot (`c`).
15083    pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
15084        if self.state.farmable_plot_under_player().is_none() {
15085            anyhow::bail!("stand on a farmable plot to cultivate");
15086        }
15087        let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
15088            let (px, py) = self.state.player_position();
15089            if self
15090                .state
15091                .terrain_at(px, py)
15092                .is_some_and(|k| k == TerrainKindView::Tilled)
15093            {
15094                anyhow::bail!("already tilled — stand on bare soil and press c");
15095            }
15096            anyhow::bail!("cannot till this cell — move onto soil on your plot");
15097        };
15098        self.cultivate_at(tx, ty).await
15099    }
15100
15101    /// Plant seeds on empty tilled soil under you (`p`).
15102    pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
15103        if self.state.farmable_plot_under_player().is_none() {
15104            anyhow::bail!("stand on a farmable plot to plant");
15105        }
15106        if !self.state.underfoot_free_tilled_plant_slot() {
15107            anyhow::bail!("stand on empty tilled soil and press p");
15108        }
15109        let seeds = self.state.farm_seed_entries();
15110        if seeds.is_empty() {
15111            anyhow::bail!("no seeds in inventory — buy seeds from Eli");
15112        }
15113        if seeds.len() == 1 {
15114            return self.plant_seeds(seeds[0].0.clone(), 1).await;
15115        }
15116        self.open_plant_menu();
15117        Ok(())
15118    }
15119
15120    /// Open the wall/roof build picker on the owned plot underfoot (`B`).
15121    pub fn open_plot_build_menu(&mut self) -> anyhow::Result<()> {
15122        let Some(plot) = self.state.my_plot_under_player() else {
15123            anyhow::bail!("stand on your plot to build");
15124        };
15125        if plot.building_id.is_some() {
15126            anyhow::bail!("this plot already has a building");
15127        }
15128        let building_now = self
15129            .state
15130            .timed_channel
15131            .as_ref()
15132            .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
15133        if !building_now && self.state.building_materials.is_empty() {
15134            anyhow::bail!("no building materials loaded — wait a moment and try again");
15135        }
15136        self.state.show_plot_build_menu = true;
15137        self.state.show_craft_menu = false;
15138        self.state.show_shop_menu = false;
15139        self.state.shop_catalog = None;
15140        self.state.show_stats = false;
15141        self.state.show_inventory_menu = false;
15142        self.state.plot_build_focus_wall = true;
15143        let walls = self.state.plot_build_wall_options().len();
15144        let roofs = self.state.plot_build_roof_options().len();
15145        if walls > 0 {
15146            self.state.plot_build_wall_index = self.state.plot_build_wall_index.min(walls - 1);
15147        } else {
15148            self.state.plot_build_wall_index = 0;
15149        }
15150        if roofs > 0 {
15151            self.state.plot_build_roof_index = self.state.plot_build_roof_index.min(roofs - 1);
15152        } else {
15153            self.state.plot_build_roof_index = 0;
15154        }
15155        Ok(())
15156    }
15157
15158    pub fn close_plot_build_menu(&mut self) {
15159        self.state.show_plot_build_menu = false;
15160    }
15161
15162    pub fn plot_build_menu_move(&mut self, delta: i32) {
15163        let walls = self.state.plot_build_wall_options();
15164        let roofs = self.state.plot_build_roof_options();
15165        if self.state.plot_build_focus_wall {
15166            if walls.is_empty() {
15167                return;
15168            }
15169            let n = walls.len() as i32;
15170            let cur = self.state.plot_build_wall_index as i32;
15171            self.state.plot_build_wall_index = ((cur + delta).rem_euclid(n)) as usize;
15172        } else {
15173            if roofs.is_empty() {
15174                return;
15175            }
15176            let n = roofs.len() as i32;
15177            let cur = self.state.plot_build_roof_index as i32;
15178            self.state.plot_build_roof_index = ((cur + delta).rem_euclid(n)) as usize;
15179        }
15180    }
15181
15182    pub fn plot_build_menu_toggle_focus(&mut self) {
15183        self.state.plot_build_focus_wall = !self.state.plot_build_focus_wall;
15184    }
15185
15186    /// Confirm selection from the B menu.
15187    pub async fn plot_build_menu_confirm(&mut self) -> anyhow::Result<()> {
15188        let wall = self
15189            .state
15190            .plot_build_selected_wall()
15191            .ok_or_else(|| anyhow::anyhow!("pick a wall material"))?
15192            .id
15193            .clone();
15194        let roof = self
15195            .state
15196            .plot_build_selected_roof()
15197            .ok_or_else(|| anyhow::anyhow!("pick a roof material"))?
15198            .id
15199            .clone();
15200        // Keep the B menu open so it switches to the in-progress panel.
15201        self.start_plot_build(&wall, &roof).await
15202    }
15203
15204    /// Cancel an in-progress plot build (materials returned).
15205    pub async fn plot_build_menu_cancel_build(&mut self) -> anyhow::Result<()> {
15206        self.seq += 1;
15207        self.session
15208            .submit_intent(Intent::CancelPlotBuild {
15209                entity_id: self.state.entity_id,
15210                seq: self.seq,
15211            })
15212            .await?;
15213        self.state.intents_sent += 1;
15214        Ok(())
15215    }
15216
15217    /// Start timed shell craft on the plot's solid tilled rectangle.
15218    pub async fn start_plot_build(
15219        &mut self,
15220        wall_material_id: &str,
15221        roof_material_id: &str,
15222    ) -> anyhow::Result<()> {
15223        let Some(plot) = self.state.my_plot_under_player() else {
15224            anyhow::bail!("stand on your plot to build");
15225        };
15226        if plot.building_id.is_some() {
15227            anyhow::bail!("this plot already has a building");
15228        }
15229        let plot_id = plot.plot_id;
15230        self.seq += 1;
15231        self.session
15232            .submit_intent(Intent::StartPlotBuild {
15233                entity_id: self.state.entity_id,
15234                plot_id,
15235                wall_material_id: wall_material_id.to_string(),
15236                roof_material_id: roof_material_id.to_string(),
15237                seq: self.seq,
15238            })
15239            .await?;
15240        self.state.intents_sent += 1;
15241        Ok(())
15242    }
15243
15244    /// Lock/unlock nearest exterior door when you hold its key (`l` / `L`).
15245    pub async fn toggle_nearby_door_lock(&mut self) -> anyhow::Result<()> {
15246        let (px, py) = self.state.player_position();
15247        let mut best: Option<(f32, String, bool)> = None;
15248        for d in &self.state.doors {
15249            if d.lock_id.is_none() {
15250                continue;
15251            }
15252            let dist = (d.x - px).hypot(d.y - py);
15253            if dist > DOOR_INTERACTION_RADIUS_M {
15254                continue;
15255            }
15256            if best.as_ref().is_none_or(|(bd, _, _)| dist < *bd) {
15257                best = Some((dist, d.id.clone(), d.locked));
15258            }
15259        }
15260        let Some((_, door_id, locked_now)) = best else {
15261            anyhow::bail!("no lockable door nearby");
15262        };
15263        let locked = !locked_now;
15264        self.seq += 1;
15265        self.session
15266            .submit_intent(Intent::SetDoorLocked {
15267                entity_id: self.state.entity_id,
15268                door_id,
15269                locked,
15270                seq: self.seq,
15271            })
15272            .await?;
15273        self.state.intents_sent += 1;
15274        Ok(())
15275    }
15276
15277    /// Enter through a nearby open player-building door (`Enter`).
15278    pub async fn enter_nearby_open_door(&mut self) -> anyhow::Result<()> {
15279        if !self.state.is_alive() {
15280            anyhow::bail!("you are dead");
15281        }
15282        if self.state.effective_inside_building().is_some() {
15283            anyhow::bail!("already inside");
15284        }
15285        let (px, py) = self.state.player_position();
15286        let mut best: Option<(f32, String)> = None;
15287        for d in &self.state.doors {
15288            if !d.open || d.locked {
15289                continue;
15290            }
15291            let player_house = self
15292                .state
15293                .buildings
15294                .iter()
15295                .find(|b| b.id == d.building_id)
15296                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15297            if !player_house {
15298                continue;
15299            }
15300            let dist = (d.x - px).hypot(d.y - py);
15301            if dist > DOOR_INTERACTION_RADIUS_M {
15302                continue;
15303            }
15304            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15305                best = Some((dist, d.id.clone()));
15306            }
15307        }
15308        let Some((_, door_id)) = best else {
15309            anyhow::bail!("no open house door nearby — open with f first");
15310        };
15311        self.seq += 1;
15312        self.session
15313            .submit_intent(Intent::EnterBuildingDoor {
15314                entity_id: self.state.entity_id,
15315                door_id,
15316                seq: self.seq,
15317            })
15318            .await?;
15319        self.state.intents_sent += 1;
15320        Ok(())
15321    }
15322
15323    /// Exit through a nearby exterior portal from inside a player house (`Enter`).
15324    /// Works even when the door is closed or locked.
15325    pub async fn exit_nearby_building_door(&mut self) -> anyhow::Result<()> {
15326        if !self.state.is_alive() {
15327            anyhow::bail!("you are dead");
15328        }
15329        let Some(bid) = self.state.effective_inside_building() else {
15330            anyhow::bail!("not inside a building");
15331        };
15332        let (px, py) = self.state.player_position();
15333        let mut best: Option<(f32, String)> = None;
15334        for d in &self.state.doors {
15335            if d.building_id != bid || d.portal.is_none() {
15336                continue;
15337            }
15338            let player_house = self
15339                .state
15340                .buildings
15341                .iter()
15342                .find(|b| b.id == d.building_id)
15343                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15344            if !player_house {
15345                continue;
15346            }
15347            let dist = (d.x - px).hypot(d.y - py);
15348            if dist > 1.5 {
15349                continue;
15350            }
15351            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15352                best = Some((dist, d.id.clone()));
15353            }
15354        }
15355        let Some((_, door_id)) = best else {
15356            anyhow::bail!("stand by the door to exit");
15357        };
15358        self.seq += 1;
15359        self.session
15360            .submit_intent(Intent::ExitBuildingDoor {
15361                entity_id: self.state.entity_id,
15362                door_id,
15363                seq: self.seq,
15364            })
15365            .await?;
15366        self.state.intents_sent += 1;
15367        Ok(())
15368    }
15369
15370    /// Confirm interior layout for your player building (copper charged on server).
15371    pub async fn confirm_interior_edit(
15372        &mut self,
15373        building_id: String,
15374        rooms: Vec<flatland_protocol::InteriorRoomEdit>,
15375        room_doors: Vec<flatland_protocol::InteriorRoomDoorEdit>,
15376    ) -> anyhow::Result<()> {
15377        self.seq += 1;
15378        self.session
15379            .submit_intent(Intent::ConfirmInteriorEdit {
15380                entity_id: self.state.entity_id,
15381                building_id,
15382                rooms,
15383                room_doors,
15384                seq: self.seq,
15385            })
15386            .await?;
15387        self.state.intents_sent += 1;
15388        Ok(())
15389    }
15390
15391    pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
15392        if !self.state.is_alive() {
15393            anyhow::bail!("you are dead");
15394        }
15395        self.seq += 1;
15396        self.session
15397            .submit_intent(Intent::Cultivate {
15398                entity_id: self.state.entity_id,
15399                x,
15400                y,
15401                seq: self.seq,
15402            })
15403            .await?;
15404        self.state.intents_sent += 1;
15405        Ok(())
15406    }
15407
15408    pub async fn plant_seeds(
15409        &mut self,
15410        seed_template_id: String,
15411        quantity: u32,
15412    ) -> anyhow::Result<()> {
15413        if !self.state.is_alive() {
15414            anyhow::bail!("you are dead");
15415        }
15416        self.seq += 1;
15417        self.session
15418            .submit_intent(Intent::PlantSeeds {
15419                entity_id: self.state.entity_id,
15420                seed_template_id: seed_template_id.clone(),
15421                quantity,
15422                seq: self.seq,
15423            })
15424            .await?;
15425        self.state.intents_sent += 1;
15426        self.state
15427            .push_log(format!("Planting {quantity}× {seed_template_id}…"));
15428        Ok(())
15429    }
15430
15431    pub fn open_plant_menu(&mut self) {
15432        if self.state.farm_seed_entries().is_empty() {
15433            self.state.push_log("No seeds in inventory to plant");
15434            return;
15435        }
15436        self.state.show_plant_menu = true;
15437        self.state.plant_menu_index = 0;
15438        self.state.plant_quantity = 1;
15439        self.state.clamp_plant_menu();
15440    }
15441
15442    pub fn close_plant_menu(&mut self) {
15443        self.state.show_plant_menu = false;
15444    }
15445
15446    pub fn plant_menu_move(&mut self, delta: i32) {
15447        let n = self.state.farm_seed_entries().len();
15448        if n == 0 {
15449            return;
15450        }
15451        let idx = self.state.plant_menu_index as i32 + delta;
15452        self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
15453        self.state.clamp_plant_menu();
15454    }
15455
15456    pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
15457        let next = self.state.plant_quantity as i32 + delta;
15458        self.state.plant_quantity = next.max(1) as u32;
15459        self.state.clamp_plant_menu();
15460    }
15461
15462    pub fn plant_menu_set_quantity_max(&mut self) {
15463        if let Some((_, max, _)) = self.state.plant_menu_selection() {
15464            self.state.plant_quantity = max;
15465        }
15466        self.state.clamp_plant_menu();
15467    }
15468
15469    pub fn plant_menu_set_quantity_min(&mut self) {
15470        self.state.plant_quantity = 1;
15471        self.state.clamp_plant_menu();
15472    }
15473
15474    pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
15475        let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
15476            self.close_plant_menu();
15477            anyhow::bail!("no seeds to plant");
15478        };
15479        self.close_plant_menu();
15480        self.plant_seeds(seed, qty).await?;
15481        self.state.push_log(format!("Planted {qty}× {label}"));
15482        Ok(())
15483    }
15484
15485    /// Activate the ability or consumable bound to hotbar slot `1`–`9`.
15486    /// Heals prefer T2/self; other abilities prefer T1. Consumables need no target.
15487    pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
15488        if !self.state.is_alive() {
15489            anyhow::bail!("you are dead");
15490        }
15491        let binding = self
15492            .state
15493            .hotbar_ability(slot)
15494            .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
15495            .to_string();
15496        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
15497            let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
15498            if qty == 0 {
15499                anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
15500            }
15501            return self.use_item(template_id).await;
15502        }
15503        let ability_id = binding;
15504        if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
15505            return self
15506                .cast_ability(&ability_id, Some(self.state.entity_id))
15507                .await;
15508        }
15509        let is_heal = ability_id == "heal_touch"
15510            || self
15511                .state
15512                .ability_meta
15513                .get(&ability_id)
15514                .map(|meta| meta.is_heal)
15515                .unwrap_or(false);
15516        let target = if is_heal {
15517            Some(
15518                self.state
15519                    .target_for_slot(2)
15520                    .unwrap_or(self.state.entity_id),
15521            )
15522        } else {
15523            self.state
15524                .target_for_slot(1)
15525                .or_else(|| self.state.target_for_slot(2))
15526        };
15527        let Some(target_id) = target else {
15528            anyhow::bail!("no target — Tab to select, then press the hotbar key");
15529        };
15530        self.cast_ability(&ability_id, Some(target_id)).await
15531    }
15532
15533    /// Bind or clear a hotbar slot (`1`–`9`) via [`Intent::SetHotbarSlot`].
15534    /// `ability_id` may be a learned ability or `item:<template_id>` for consumables.
15535    pub async fn set_hotbar_slot(
15536        &mut self,
15537        slot: u8,
15538        ability_id: Option<&str>,
15539    ) -> anyhow::Result<()> {
15540        if !self.state.is_alive() {
15541            anyhow::bail!("you are dead");
15542        }
15543        if !(1..=9).contains(&slot) {
15544            anyhow::bail!("hotbar slot must be 1–9");
15545        }
15546        let ability_id = ability_id
15547            .map(str::trim)
15548            .filter(|id| !id.is_empty())
15549            .map(str::to_string);
15550        self.seq += 1;
15551        self.session
15552            .submit_intent(Intent::SetHotbarSlot {
15553                entity_id: self.state.entity_id,
15554                slot,
15555                ability_id: ability_id.clone(),
15556                seq: self.seq,
15557            })
15558            .await?;
15559        self.state.intents_sent += 1;
15560        let idx = (slot - 1) as usize;
15561        if self.state.hotbar.len() < 9 {
15562            self.state.hotbar.resize(9, None);
15563        }
15564        if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
15565            *slot_mut = ability_id.clone();
15566        }
15567        match ability_id {
15568            Some(id) => {
15569                let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
15570                    format!("use {tid}")
15571                } else {
15572                    id
15573                };
15574                self.state.push_log(format!("Hotbar {slot} → {label}"))
15575            }
15576            None => self.state.push_log(format!("Hotbar {slot} cleared")),
15577        }
15578        Ok(())
15579    }
15580
15581    pub fn npc_verb_options(&self) -> Vec<NpcVerbChoice> {
15582        self.state.npc_verb_options()
15583    }
15584
15585    pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
15586        let Some(npc_id) = self.state.npc_verb_target.clone() else {
15587            return Ok(());
15588        };
15589        let options = self.npc_verb_options();
15590        let choice = options
15591            .get(self.state.npc_verb_index)
15592            .cloned()
15593            .unwrap_or_else(GameState::talk_choice);
15594        match choice.action {
15595            NpcVerbAction::QuestGive { quest_id } => {
15596                self.submit_npc_quest_turn_in(&npc_id, Some(&quest_id))
15597                    .await?;
15598                self.state.show_npc_verb_menu = false;
15599            }
15600            NpcVerbAction::Talk => {
15601                self.open_npc_talk(&npc_id, None).await?;
15602            }
15603            NpcVerbAction::QuestTalk { quest_id } => {
15604                self.open_npc_talk(&npc_id, Some(&quest_id)).await?;
15605            }
15606            NpcVerbAction::Trade | NpcVerbAction::Bank | NpcVerbAction::Storage | NpcVerbAction::Market => {
15607                self.seq += 1;
15608                self.session
15609                    .submit_intent(Intent::Interact {
15610                        entity_id: self.state.entity_id,
15611                        target_id: npc_id,
15612                        seq: self.seq,
15613                    })
15614                    .await?;
15615                self.state.intents_sent += 1;
15616            }
15617        }
15618        Ok(())
15619    }
15620
15621    async fn open_npc_talk(&mut self, npc_id: &str, quest_id: Option<&str>) -> anyhow::Result<()> {
15622        self.seq += 1;
15623        self.session
15624            .submit_intent(Intent::NpcTalkOpen {
15625                entity_id: self.state.entity_id,
15626                npc_id: npc_id.to_string(),
15627                quest_id: quest_id.map(str::to_string),
15628                seq: self.seq,
15629            })
15630            .await?;
15631        self.state.intents_sent += 1;
15632        Ok(())
15633    }
15634
15635    async fn submit_npc_quest_turn_in(
15636        &mut self,
15637        npc_id: &str,
15638        quest_id: Option<&str>,
15639    ) -> anyhow::Result<()> {
15640        let catalog_ref = self.state.npc_quest_catalog_ref(npc_id);
15641        let pending: Vec<(String, u32, String)> = self
15642            .state
15643            .quest_log
15644            .iter()
15645            .filter(|q| {
15646                q.status == flatland_protocol::QuestStatusView::Active
15647                    && quest_id.is_none_or(|id| q.quest_id == id)
15648            })
15649            .flat_map(|q| q.objectives.iter())
15650            .filter(|o| {
15651                !o.done
15652                    && o.kind == "give_item"
15653                    && o.npc_ref.as_deref() == Some(catalog_ref.as_str())
15654            })
15655            .filter_map(|o| {
15656                let template = o.item_template.clone()?;
15657                let remaining = o.required.saturating_sub(o.current);
15658                if remaining == 0 {
15659                    return None;
15660                }
15661                Some((template, remaining, o.label.clone()))
15662            })
15663            .collect();
15664        if pending.is_empty() {
15665            self.state.push_log("Nothing to turn in here.");
15666            return Ok(());
15667        }
15668        let mut sent = 0u32;
15669        for (template, remaining, label) in pending {
15670            let held = self.state.count_inventory_template(&template);
15671            let qty = remaining.min(held);
15672            if qty == 0 {
15673                self.state.push_log(format!("Need {label}"));
15674                continue;
15675            }
15676            self.seq += 1;
15677            self.session
15678                .submit_intent(Intent::QuestGiveItem {
15679                    entity_id: self.state.entity_id,
15680                    npc_id: npc_id.to_string(),
15681                    template_id: template,
15682                    quantity: qty,
15683                    seq: self.seq,
15684                })
15685                .await?;
15686            self.state.intents_sent += 1;
15687            sent += 1;
15688        }
15689        if sent > 0 {
15690            self.state.push_log("Turning in quest items.");
15691        }
15692        Ok(())
15693    }
15694
15695    pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
15696        let Some(chat) = self.state.npc_chat.clone() else {
15697            return Ok(());
15698        };
15699        let message = chat.input.trim().to_string();
15700        if message.is_empty() || chat.pending {
15701            return Ok(());
15702        }
15703        if let Some(c) = self.state.npc_chat.as_mut() {
15704            c.lines.push(format!("You: {message}"));
15705            c.input.clear();
15706            c.pending = true;
15707        }
15708        self.seq += 1;
15709        self.session
15710            .submit_intent(Intent::NpcTalkSay {
15711                entity_id: self.state.entity_id,
15712                npc_id: chat.npc_id,
15713                message,
15714                seq: self.seq,
15715            })
15716            .await?;
15717        self.state.intents_sent += 1;
15718        Ok(())
15719    }
15720
15721    pub async fn npc_talk_topic(&mut self, index: usize) -> anyhow::Result<()> {
15722        let topic = self
15723            .state
15724            .npc_chat
15725            .as_ref()
15726            .and_then(|c| c.suggested_topics.get(index))
15727            .cloned();
15728        let Some(topic) = topic else {
15729            return Ok(());
15730        };
15731        if let Some(c) = self.state.npc_chat.as_mut() {
15732            if c.pending {
15733                return Ok(());
15734            }
15735            c.input = topic;
15736        }
15737        self.npc_talk_send().await
15738    }
15739
15740    pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
15741        let return_to_verbs = self.state.npc_verb_target.is_some();
15742        let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
15743            self.state.show_npc_chat = false;
15744            if return_to_verbs {
15745                self.state.show_npc_verb_menu = true;
15746            }
15747            return Ok(());
15748        };
15749        self.seq += 1;
15750        self.session
15751            .submit_intent(Intent::NpcTalkClose {
15752                entity_id: self.state.entity_id,
15753                npc_id,
15754                seq: self.seq,
15755            })
15756            .await?;
15757        self.state.intents_sent += 1;
15758        self.state.show_npc_chat = false;
15759        self.state.npc_chat = None;
15760        if return_to_verbs {
15761            self.state.show_npc_verb_menu = true;
15762            self.state.npc_verb_notice = None;
15763        }
15764        Ok(())
15765    }
15766
15767    /// Esc/back inside Talk, Trade, quest-offer, or the verb menu — pop one layer, not the whole session.
15768    pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
15769        if self.state.show_quest_offer
15770            && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
15771        {
15772            self.quest_offer_decline();
15773            return Ok(());
15774        }
15775        if self.state.show_npc_chat {
15776            return self.npc_talk_close().await;
15777        }
15778        if self.state.show_shop_menu {
15779            return self.back_from_shop_menu().await;
15780        }
15781        if self.state.bank_panel.is_some() {
15782            if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
15783                self.bank_transfer_back();
15784                return Ok(());
15785            }
15786            return self.close_bank_panel().await;
15787        }
15788        if self.state.storage_panel.is_some() {
15789            if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
15790                self.storage_ui_back();
15791                return Ok(());
15792            }
15793            return self.close_storage_panel().await;
15794        }
15795        if self.state.market_panel.is_some() {
15796            if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
15797                self.market_ui_back();
15798                return Ok(());
15799            }
15800            if self.state.market_buy_confirm.is_some() {
15801                self.state.market_buy_confirm = None;
15802                return Ok(());
15803            }
15804            return self.close_market_panel().await;
15805        }
15806        if self.state.show_npc_verb_menu {
15807            self.state.show_npc_verb_menu = false;
15808            self.state.npc_verb_target = None;
15809        }
15810        Ok(())
15811    }
15812
15813    pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
15814        self.seq += 1;
15815        self.session
15816            .submit_intent(Intent::TestDamage {
15817                entity_id: self.state.entity_id,
15818                amount,
15819                seq: self.seq,
15820            })
15821            .await?;
15822        self.state.intents_sent += 1;
15823        Ok(())
15824    }
15825
15826    pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
15827        self.cycle_combat_target_slot(1, reverse).await
15828    }
15829
15830    pub async fn cycle_combat_target_slot(
15831        &mut self,
15832        slot_index: u8,
15833        reverse: bool,
15834    ) -> anyhow::Result<()> {
15835        if !self.state.is_alive() {
15836            anyhow::bail!("you are dead");
15837        }
15838        let candidates = self.state.candidates_for_slot(slot_index);
15839        if candidates.is_empty() {
15840            anyhow::bail!("no targets nearby");
15841        }
15842        let current = self.state.target_for_slot(slot_index);
15843        let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
15844        let next_idx = match idx {
15845            None => 0,
15846            Some(i) if reverse => {
15847                if i == 0 {
15848                    candidates.len() - 1
15849                } else {
15850                    i - 1
15851                }
15852            }
15853            Some(i) => (i + 1) % candidates.len(),
15854        };
15855        if idx == Some(next_idx) && candidates.len() == 1 {
15856            self.clear_combat_target_slot(slot_index).await?;
15857            return Ok(());
15858        }
15859        let (target_id, label) = candidates[next_idx].clone();
15860        self.set_combat_target_slot(slot_index, target_id, &label)
15861            .await
15862    }
15863
15864    pub async fn set_combat_target_slot(
15865        &mut self,
15866        slot_index: u8,
15867        target_id: EntityId,
15868        label: &str,
15869    ) -> anyhow::Result<()> {
15870        if !self.state.is_alive() {
15871            anyhow::bail!("you are dead");
15872        }
15873        self.seq += 1;
15874        self.session
15875            .submit_intent(Intent::SetTargetSlot {
15876                entity_id: self.state.entity_id,
15877                slot_index,
15878                target_id,
15879                seq: self.seq,
15880            })
15881            .await?;
15882        self.state.intents_sent += 1;
15883        if slot_index == 1 {
15884            self.state.combat_target = Some(target_id);
15885            self.state.combat_target_label = Some(label.to_string());
15886        }
15887        self.state
15888            .push_log(format!("Slot {slot_index} target: {label}"));
15889        Ok(())
15890    }
15891
15892    pub async fn set_combat_target(
15893        &mut self,
15894        target_id: EntityId,
15895        label: &str,
15896    ) -> anyhow::Result<()> {
15897        self.set_combat_target_slot(1, target_id, label).await
15898    }
15899
15900    pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
15901        if slot_index == 1 && self.state.combat_target.is_none() {
15902            return Ok(());
15903        }
15904        self.seq += 1;
15905        self.session
15906            .submit_intent(Intent::ClearTargetSlot {
15907                entity_id: self.state.entity_id,
15908                slot_index,
15909                seq: self.seq,
15910            })
15911            .await?;
15912        if slot_index == 1 {
15913            self.state.combat_target = None;
15914            self.state.combat_target_label = None;
15915        }
15916        self.state.intents_sent += 1;
15917        self.state
15918            .push_log(format!("Slot {slot_index} target cleared"));
15919        Ok(())
15920    }
15921
15922    pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
15923        self.clear_combat_target_slot(1).await
15924    }
15925
15926    pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
15927        if !self.state.is_alive() {
15928            anyhow::bail!("you are dead");
15929        }
15930        self.seq += 1;
15931        self.session
15932            .submit_intent(Intent::AdvanceRotation {
15933                entity_id: self.state.entity_id,
15934                slot_index,
15935                seq: self.seq,
15936            })
15937            .await?;
15938        self.state.intents_sent += 1;
15939        Ok(())
15940    }
15941
15942    pub async fn assign_slot_preset(
15943        &mut self,
15944        slot_index: u8,
15945        preset_id: &str,
15946    ) -> anyhow::Result<()> {
15947        if !self.state.is_alive() {
15948            anyhow::bail!("you are dead");
15949        }
15950        self.seq += 1;
15951        self.session
15952            .submit_intent(Intent::AssignSlotPreset {
15953                entity_id: self.state.entity_id,
15954                slot_index,
15955                preset_id: preset_id.to_string(),
15956                seq: self.seq,
15957            })
15958            .await?;
15959        self.state.intents_sent += 1;
15960        if let Some(slot) = self
15961            .state
15962            .combat_slots
15963            .iter_mut()
15964            .find(|s| s.slot_index == slot_index)
15965        {
15966            slot.preset_id = Some(preset_id.to_string());
15967            if let Some(preset) = self
15968                .state
15969                .rotation_presets
15970                .iter()
15971                .find(|p| p.id == preset_id)
15972            {
15973                slot.preset_label = Some(preset.label.clone());
15974                slot.rotation = preset.abilities.clone();
15975                slot.rotation_index = 0;
15976            }
15977        }
15978        self.state
15979            .push_log(format!("T{slot_index} loadout → {preset_id}"));
15980        Ok(())
15981    }
15982
15983    pub async fn cast_ability(
15984        &mut self,
15985        ability_id: &str,
15986        target_id: Option<EntityId>,
15987    ) -> anyhow::Result<()> {
15988        if !self.state.is_alive() {
15989            anyhow::bail!("you are dead");
15990        }
15991        let allows_ground = self.state.ability_allows_ground(ability_id);
15992        let requires_ground = self.state.ability_requires_ground(ability_id);
15993        if requires_ground && self.state.ground_target.is_none() {
15994            anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
15995        }
15996        let (resolved_target_id, target_point) = if allows_ground {
15997            if let Some((x, y, z)) = self.state.ground_target {
15998                (
15999                    target_id.unwrap_or(self.state.entity_id),
16000                    Some(flatland_protocol::AimPoint { x, y, z }),
16001                )
16002            } else {
16003                (
16004                    target_id
16005                        .or_else(|| self.state.target_for_slot(2))
16006                        .or_else(|| self.state.target_for_slot(1))
16007                        .unwrap_or(self.state.entity_id),
16008                    None,
16009                )
16010            }
16011        } else {
16012            (
16013                target_id
16014                    .or_else(|| self.state.target_for_slot(2))
16015                    .or_else(|| self.state.target_for_slot(1))
16016                    .unwrap_or(self.state.entity_id),
16017                None,
16018            )
16019        };
16020        self.seq += 1;
16021        self.session
16022            .submit_intent(Intent::Cast {
16023                entity_id: self.state.entity_id,
16024                ability_id: ability_id.to_string(),
16025                target_id: resolved_target_id,
16026                target_point,
16027                seq: self.seq,
16028            })
16029            .await?;
16030        self.state.intents_sent += 1;
16031        match target_point {
16032            Some(point) => self.state.push_log(format!(
16033                "Cast {ability_id} → ({:.1}, {:.1})",
16034                point.x, point.y
16035            )),
16036            None => self
16037                .state
16038                .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
16039        }
16040        Ok(())
16041    }
16042
16043    pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
16044        self.seq += 1;
16045        self.session
16046            .submit_intent(Intent::UpsertRotationPreset {
16047                entity_id: self.state.entity_id,
16048                preset: preset.clone(),
16049                seq: self.seq,
16050            })
16051            .await?;
16052        self.state.intents_sent += 1;
16053        if let Some(existing) = self
16054            .state
16055            .rotation_presets
16056            .iter_mut()
16057            .find(|p| p.id == preset.id)
16058        {
16059            *existing = preset.clone();
16060        } else {
16061            self.state.rotation_presets.push(preset.clone());
16062        }
16063        for slot in &mut self.state.combat_slots {
16064            if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
16065                slot.preset_label = Some(preset.label.clone());
16066                slot.rotation = preset.abilities.clone();
16067            }
16068        }
16069        self.state
16070            .push_log(format!("Saved rotation: {}", preset.label));
16071        Ok(())
16072    }
16073
16074    pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
16075        self.seq += 1;
16076        self.session
16077            .submit_intent(Intent::DeleteRotationPreset {
16078                entity_id: self.state.entity_id,
16079                preset_id: preset_id.to_string(),
16080                seq: self.seq,
16081            })
16082            .await?;
16083        self.state.intents_sent += 1;
16084        self.state.rotation_presets.retain(|p| p.id != preset_id);
16085        for slot in &mut self.state.combat_slots {
16086            if slot.preset_id.as_deref() == Some(preset_id) {
16087                slot.preset_id = None;
16088                slot.preset_label = None;
16089                slot.rotation.clear();
16090                slot.rotation_index = 0;
16091            }
16092        }
16093        self.state
16094            .push_log(format!("Deleted rotation: {preset_id}"));
16095        Ok(())
16096    }
16097
16098    pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
16099        if !self.state.is_alive() {
16100            anyhow::bail!("you are dead");
16101        }
16102        let enabled = !self
16103            .state
16104            .combat_slots
16105            .iter()
16106            .find(|s| s.slot_index == slot_index)
16107            .map(|s| s.auto_enabled)
16108            .unwrap_or(false);
16109        self.seq += 1;
16110        self.session
16111            .submit_intent(Intent::SetAutoAttack {
16112                entity_id: self.state.entity_id,
16113                slot_index,
16114                enabled,
16115                seq: self.seq,
16116            })
16117            .await?;
16118        if slot_index == 1 {
16119            self.state.auto_attack = enabled;
16120        }
16121        self.state.intents_sent += 1;
16122        self.state.push_log(format!(
16123            "T{slot_index} auto {}",
16124            if enabled { "ON" } else { "OFF" }
16125        ));
16126        Ok(())
16127    }
16128
16129    pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
16130        if !self.state.connected {
16131            anyhow::bail!("not connected");
16132        }
16133        if !self.state.is_alive() {
16134            anyhow::bail!("you are dead");
16135        }
16136        let (px, py) = self.state.player_position();
16137        if self
16138            .state
16139            .ground_drops
16140            .iter()
16141            .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
16142        {
16143            anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
16144        }
16145        self.seq += 1;
16146        self.session
16147            .submit_intent(Intent::Pickup {
16148                entity_id: self.state.entity_id,
16149                drop_id: None,
16150                seq: self.seq,
16151            })
16152            .await?;
16153        self.state.intents_sent += 1;
16154        self.state.push_audio(crate::social::AudioCue::LootPickup);
16155        Ok(())
16156    }
16157
16158    pub async fn dodge(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16159        if !self.state.is_alive() {
16160            anyhow::bail!("you are dead");
16161        }
16162        // Only currently held WASD counts — sticky last_move would skip smart dodge after walking.
16163        self.seq += 1;
16164        self.session
16165            .submit_intent(Intent::Dodge {
16166                entity_id: self.state.entity_id,
16167                forward,
16168                strafe,
16169                seq: self.seq,
16170            })
16171            .await?;
16172        self.state.intents_sent += 1;
16173        self.state.push_log("Dodge!");
16174        self.state.push_audio(crate::social::AudioCue::CombatDodge);
16175        Ok(())
16176    }
16177
16178    pub async fn lunge(&mut self) -> anyhow::Result<()> {
16179        if !self.state.is_alive() {
16180            anyhow::bail!("you are dead");
16181        }
16182        let (forward, strafe) = self.last_move_axes();
16183        self.seq += 1;
16184        self.session
16185            .submit_intent(Intent::Lunge {
16186                entity_id: self.state.entity_id,
16187                forward,
16188                strafe,
16189                seq: self.seq,
16190            })
16191            .await?;
16192        self.state.intents_sent += 1;
16193        self.state.push_log("Lunge!");
16194        Ok(())
16195    }
16196
16197    pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16198        if !self.state.is_alive() {
16199            anyhow::bail!("you are dead");
16200        }
16201        self.seq += 1;
16202        self.session
16203            .submit_intent(Intent::DirectionalJump {
16204                entity_id: self.state.entity_id,
16205                forward,
16206                strafe,
16207                seq: self.seq,
16208            })
16209            .await?;
16210        self.state.intents_sent += 1;
16211        self.state.push_log("Jump!");
16212        Ok(())
16213    }
16214
16215    /// Remembered WASD axes for lunge when not currently moving.
16216    pub fn last_move_axes(&self) -> (f32, f32) {
16217        (self.last_move_forward, self.last_move_strafe)
16218    }
16219
16220    pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
16221        if !self.state.is_alive() {
16222            anyhow::bail!("you are dead");
16223        }
16224        self.seq += 1;
16225        self.session
16226            .submit_intent(Intent::Block {
16227                entity_id: self.state.entity_id,
16228                enabled,
16229                seq: self.seq,
16230            })
16231            .await?;
16232        self.state.intents_sent += 1;
16233        if enabled {
16234            self.state.push_log("Blocking");
16235            self.state.push_audio(crate::social::AudioCue::CombatBlock);
16236        }
16237        Ok(())
16238    }
16239
16240    pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
16241        if !self.state.is_alive() {
16242            anyhow::bail!("you are dead");
16243        }
16244        self.seq += 1;
16245        self.session
16246            .submit_intent(Intent::EquipMainhand {
16247                entity_id: self.state.entity_id,
16248                template_id,
16249                instance_id: None,
16250                seq: self.seq,
16251            })
16252            .await?;
16253        self.state.intents_sent += 1;
16254        Ok(())
16255    }
16256
16257    /// Equip paperdoll: activate selected slot (unequip filled, or equip first matching candidate).
16258    pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
16259        let idx = self.state.equip_menu_index;
16260        let slots = equip_paperdoll_rows(&self.state);
16261        let Some(row) = slots.get(idx) else {
16262            return Ok(());
16263        };
16264        match row {
16265            EquipPaperdollRow::Body { slot, filled } => {
16266                if *filled {
16267                    self.equip_worn(*slot, None).await
16268                } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
16269                    self.equip_worn(*slot, Some(inst)).await
16270                } else {
16271                    self.state
16272                        .push_log(format!("No item for {}", body_slot_label(*slot)));
16273                    Ok(())
16274                }
16275            }
16276            EquipPaperdollRow::Mainhand { filled } => {
16277                if *filled {
16278                    self.unequip_mainhand().await
16279                } else if let Some(tid) = first_inventory_weapon(&self.state) {
16280                    self.equip_mainhand(Some(tid)).await
16281                } else {
16282                    self.state.push_log("No weapon in inventory".to_string());
16283                    Ok(())
16284                }
16285            }
16286            EquipPaperdollRow::Offhand { filled, locked } => {
16287                if *locked {
16288                    self.state
16289                        .push_log("Offhand locked — two-handed weapon equipped".to_string());
16290                    Ok(())
16291                } else if *filled {
16292                    self.unequip_offhand().await
16293                } else if let Some(tid) = first_inventory_offhand(&self.state) {
16294                    self.equip_offhand(Some(tid)).await
16295                } else {
16296                    self.state
16297                        .push_log("No offhand item in inventory".to_string());
16298                    Ok(())
16299                }
16300            }
16301        }
16302    }
16303
16304    pub async fn say(
16305        &mut self,
16306        channel: flatland_protocol::ChatChannel,
16307        text: &str,
16308    ) -> anyhow::Result<()> {
16309        self.say_to(channel, text, None).await
16310    }
16311
16312    pub async fn say_to(
16313        &mut self,
16314        channel: flatland_protocol::ChatChannel,
16315        text: &str,
16316        to_entity: Option<EntityId>,
16317    ) -> anyhow::Result<()> {
16318        self.seq += 1;
16319        self.session
16320            .submit_intent(Intent::Say {
16321                entity_id: self.state.entity_id,
16322                channel,
16323                text: text.to_string(),
16324                to_entity,
16325                seq: self.seq,
16326            })
16327            .await?;
16328        self.state.intents_sent += 1;
16329        Ok(())
16330    }
16331
16332    pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
16333        let Some(peer) = self.state.player_verbs.target_entity else {
16334            return Ok(());
16335        };
16336        let label = self.state.player_verbs.target_label.clone();
16337        let choice = crate::social::PlayerVerbState::options()
16338            .get(self.state.player_verbs.index)
16339            .copied()
16340            .unwrap_or("Whisper");
16341        self.state.player_verbs.close();
16342        match choice {
16343            "Trade" => {
16344                // Only request — peer must accept (Y in chat). Do not auto-respond;
16345                // TradeRespond requires a pending inbound request from the peer.
16346                self.seq += 1;
16347                self.session
16348                    .submit_intent(Intent::TradeRequest {
16349                        entity_id: self.state.entity_id,
16350                        peer_entity_id: peer,
16351                        seq: self.seq,
16352                    })
16353                    .await?;
16354                self.state.intents_sent += 1;
16355                self.state.social_chat.push_system(format!(
16356                    "Trade request sent to {label} — waiting for accept"
16357                ));
16358            }
16359            "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
16360            _ => self.state.social_chat.focus_nearby(),
16361        }
16362        Ok(())
16363    }
16364
16365    pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
16366        let Some(pending) = self.state.social_chat.pending_trade.take() else {
16367            return Ok(());
16368        };
16369        self.seq += 1;
16370        self.session
16371            .submit_intent(Intent::TradeRespond {
16372                entity_id: self.state.entity_id,
16373                peer_entity_id: pending.from_entity,
16374                accept,
16375                seq: self.seq,
16376            })
16377            .await?;
16378        self.state.intents_sent += 1;
16379        if accept {
16380            self.state
16381                .social_chat
16382                .push_system(format!("Accepted trade with {}", pending.from_name));
16383        } else {
16384            self.state
16385                .social_chat
16386                .push_system(format!("Declined trade with {}", pending.from_name));
16387        }
16388        Ok(())
16389    }
16390
16391    pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
16392        let text = self.state.social_chat.buffer.trim().to_string();
16393        if text.is_empty() {
16394            return Ok(());
16395        }
16396        self.state.social_chat.buffer.clear();
16397        if crate::social::is_chat_slash_line(&text) {
16398            match crate::social::parse_chat_slash(&text) {
16399                Some(cmd) => return self.apply_chat_slash(cmd).await,
16400                None => {
16401                    self.state.social_chat.push_system(format!(
16402                        "Unknown command — {}",
16403                        crate::social::chat_slash_help_text()
16404                    ));
16405                    return Ok(());
16406                }
16407            }
16408        }
16409        let thread = self.state.social_chat.thread;
16410        let channel = thread.channel();
16411        let to = thread.to_entity();
16412        if let Some(peer) = to {
16413            let label = self.state.social_chat.peer_label.clone();
16414            self.state
16415                .social_chat
16416                .remember_whisper_peer(peer, &label, channel);
16417        }
16418        self.say_to(channel, &text, to).await
16419    }
16420
16421    async fn apply_chat_slash(
16422        &mut self,
16423        cmd: crate::social::ChatSlashCommand,
16424    ) -> anyhow::Result<()> {
16425        use crate::social::{chat_slash_help_text, ChatSlashCommand};
16426        match cmd {
16427            ChatSlashCommand::Help => {
16428                self.state
16429                    .social_chat
16430                    .push_system(chat_slash_help_text().to_string());
16431                Ok(())
16432            }
16433            ChatSlashCommand::Nearby { message } => {
16434                self.state.social_chat.focus_nearby();
16435                self.state
16436                    .social_chat
16437                    .push_system("Nearby speech — everyone close can hear");
16438                if let Some(msg) = message {
16439                    self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
16440                        .await
16441                } else {
16442                    Ok(())
16443                }
16444            }
16445            ChatSlashCommand::Reply { message } => {
16446                let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16447                    self.state
16448                        .social_chat
16449                        .push_system("No one to reply to — wait for a whisper, or /whisper Name");
16450                    return Ok(());
16451                };
16452                let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
16453                self.state
16454                    .social_chat
16455                    .set_whisper_thread(peer.entity_id, &peer.label, stone);
16456                self.state.social_chat.push_system(format!(
16457                    "Replying to {} — type and Enter · /nearby",
16458                    peer.label
16459                ));
16460                if let Some(msg) = message {
16461                    self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
16462                } else {
16463                    Ok(())
16464                }
16465            }
16466            ChatSlashCommand::Whisper { name, message } => {
16467                let (peer_id, label, stone) = if let Some(name) = name {
16468                    match self.resolve_whisper_target(&name) {
16469                        Ok(t) => t,
16470                        Err(err) => {
16471                            self.state.social_chat.push_system(err);
16472                            return Ok(());
16473                        }
16474                    }
16475                } else {
16476                    let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16477                        self.state.social_chat.push_system(
16478                            "Usage: /whisper Name [message] · or /reply after someone whispers you",
16479                        );
16480                        return Ok(());
16481                    };
16482                    (
16483                        peer.entity_id,
16484                        peer.label,
16485                        peer.channel == flatland_protocol::ChatChannel::WhisperStone,
16486                    )
16487                };
16488                self.state
16489                    .social_chat
16490                    .set_whisper_thread(peer_id, &label, stone);
16491                let channel = if stone {
16492                    flatland_protocol::ChatChannel::WhisperStone
16493                } else {
16494                    flatland_protocol::ChatChannel::Whisper
16495                };
16496                if let Some(msg) = message {
16497                    self.state
16498                        .social_chat
16499                        .push_system(format!("Whisper → {label}"));
16500                    self.say_to(channel, &msg, Some(peer_id)).await
16501                } else {
16502                    self.state.social_chat.push_system(format!(
16503                        "Whispering {label} — type and Enter · Esc / /nearby cancels"
16504                    ));
16505                    Ok(())
16506                }
16507            }
16508        }
16509    }
16510
16511    /// Resolve `/whisper Name` against AOI players (label match).
16512    fn resolve_whisper_target(&self, name: &str) -> Result<(EntityId, String, bool), String> {
16513        let needle = name.trim().to_ascii_lowercase();
16514        if needle.is_empty() {
16515            return Err("Usage: /whisper Name [message]".into());
16516        }
16517        let mut candidates: Vec<(EntityId, String)> = self
16518            .state
16519            .entities
16520            .iter()
16521            .filter(|e| e.id != self.state.entity_id)
16522            .filter(|e| !e.label.trim().is_empty())
16523            .filter(|e| e.vitals.is_some())
16524            .filter(|e| !self.state.npcs.iter().any(|n| n.id == e.id.to_string()))
16525            .filter(|e| !self.state.hired_workers.iter().any(|w| w.entity_id == e.id))
16526            .map(|e| (e.id, e.label.clone()))
16527            .collect();
16528
16529        // Also allow matching the last whisper peer by name even if they left AOI briefly.
16530        if let Some(last) = &self.state.social_chat.last_whisper_peer {
16531            if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
16532                candidates.push((last.entity_id, last.label.clone()));
16533            }
16534        }
16535
16536        let exact: Vec<_> = candidates
16537            .iter()
16538            .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
16539            .cloned()
16540            .collect();
16541        let pool = if exact.len() == 1 {
16542            exact
16543        } else if exact.len() > 1 {
16544            return Err(format!(
16545                "Several players named '{name}' nearby — move closer and try again"
16546            ));
16547        } else {
16548            let starts: Vec<_> = candidates
16549                .iter()
16550                .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
16551                .cloned()
16552                .collect();
16553            if starts.len() == 1 {
16554                starts
16555            } else if starts.len() > 1 {
16556                let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
16557                return Err(format!(
16558                    "Ambiguous name '{name}' — matches: {}",
16559                    names.join(", ")
16560                ));
16561            } else {
16562                let contains: Vec<_> = candidates
16563                    .iter()
16564                    .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
16565                    .cloned()
16566                    .collect();
16567                if contains.len() == 1 {
16568                    contains
16569                } else if contains.is_empty() {
16570                    return Err(format!(
16571                        "No player matching '{name}' in range — get closer or check the spelling"
16572                    ));
16573                } else {
16574                    let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
16575                    return Err(format!(
16576                        "Ambiguous name '{name}' — matches: {}",
16577                        names.join(", ")
16578                    ));
16579                }
16580            }
16581        };
16582
16583        let (id, label) = pool.into_iter().next().unwrap();
16584        let stone = self
16585            .state
16586            .social_chat
16587            .last_whisper_peer
16588            .as_ref()
16589            .is_some_and(|p| {
16590                p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone
16591            });
16592        Ok((id, label, stone))
16593    }
16594
16595    pub async fn trade_present_selected(
16596        &mut self,
16597        item_instance_id: uuid::Uuid,
16598    ) -> anyhow::Result<()> {
16599        self.trade_present_quantity(item_instance_id, None).await
16600    }
16601
16602    pub async fn trade_present_quantity(
16603        &mut self,
16604        item_instance_id: uuid::Uuid,
16605        quantity: Option<u32>,
16606    ) -> anyhow::Result<()> {
16607        self.seq += 1;
16608        self.session
16609            .submit_intent(Intent::TradePresent {
16610                entity_id: self.state.entity_id,
16611                item_instance_id,
16612                quantity,
16613                seq: self.seq,
16614            })
16615            .await?;
16616        self.state.intents_sent += 1;
16617        self.state.trade_ui.qty_entry = None;
16618        self.state.trade_ui.picking_inventory = false;
16619        Ok(())
16620    }
16621
16622    /// Confirm the trade quantity prompt (or present whole stack when qty==1).
16623    pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
16624        if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
16625            let qty = self.state.trade_ui.present_quantity();
16626            return self
16627                .trade_present_quantity(entry.item_instance_id, qty)
16628                .await;
16629        }
16630        if !self.state.trade_ui.picking_inventory {
16631            return Ok(());
16632        }
16633        let stacks = self.state.trade_presentable_stacks();
16634        let Some(stack) = stacks.get(self.state.trade_ui.inventory_index).copied() else {
16635            return Ok(());
16636        };
16637        let Some(id) = stack.item_instance_id else {
16638            return Ok(());
16639        };
16640        let label = stack
16641            .display_name
16642            .clone()
16643            .unwrap_or_else(|| stack.template_id.clone());
16644        if stack.quantity <= 1 {
16645            self.trade_present_quantity(id, Some(1)).await
16646        } else {
16647            self.state
16648                .trade_ui
16649                .begin_qty_entry(id, label, stack.quantity);
16650            Ok(())
16651        }
16652    }
16653
16654    pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
16655        self.seq += 1;
16656        self.session
16657            .submit_intent(Intent::TradeSetReady {
16658                entity_id: self.state.entity_id,
16659                ready,
16660                seq: self.seq,
16661            })
16662            .await?;
16663        self.state.intents_sent += 1;
16664        Ok(())
16665    }
16666
16667    pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
16668        self.seq += 1;
16669        self.session
16670            .submit_intent(Intent::TradeCancel {
16671                entity_id: self.state.entity_id,
16672                seq: self.seq,
16673            })
16674            .await?;
16675        self.state.intents_sent += 1;
16676        self.state.trade_ui.close();
16677        Ok(())
16678    }
16679
16680    pub async fn destroy_whisper_stone(
16681        &mut self,
16682        item_instance_id: uuid::Uuid,
16683    ) -> anyhow::Result<()> {
16684        self.seq += 1;
16685        self.session
16686            .submit_intent(Intent::DestroyWhisperStone {
16687                entity_id: self.state.entity_id,
16688                item_instance_id,
16689                seq: self.seq,
16690            })
16691            .await?;
16692        self.state.intents_sent += 1;
16693        Ok(())
16694    }
16695
16696    pub async fn stop(&mut self) -> anyhow::Result<()> {
16697        self.seq += 1;
16698        self.session
16699            .submit_intent(Intent::Stop {
16700                entity_id: self.state.entity_id,
16701                seq: self.seq,
16702            })
16703            .await?;
16704        self.state.intents_sent += 1;
16705        Ok(())
16706    }
16707
16708    pub fn disconnect(&self) {
16709        self.session.disconnect();
16710    }
16711}
16712
16713fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
16714    let dx = ax - bx;
16715    let dy = ay - by;
16716    (dx * dx + dy * dy).sqrt()
16717}
16718
16719#[cfg(test)]
16720mod tests {
16721    use std::collections::BTreeMap;
16722
16723    use super::*;
16724    use flatland_protocol::{
16725        BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
16726    };
16727
16728    fn sample_state() -> GameState {
16729        let mut state = GameState {
16730            session_id: 1,
16731            entity_id: 1,
16732            character_id: None,
16733            tick: 0,
16734            chunk_rev: 0,
16735            content_rev: 0,
16736            publish_rev: 0,
16737            entities: vec![EntityState {
16738                id: 1,
16739                label: "You".into(),
16740                transform: Transform {
16741                    position: WorldCoord::surface(128.0, 128.0),
16742                    yaw: 0.0,
16743                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
16744                },
16745                vitals: None,
16746                attributes: None,
16747                skills: None,
16748                inside_building: None,
16749                tile_id: None,
16750                paperdoll_ref: None,
16751                draw_scale: 1.0,
16752                presentation_state: None,
16753                sprite_mode: None,
16754                progression_xp: None,
16755                combat_cues: vec![],
16756                statuses: vec![],
16757            }],
16758            player: None,
16759            resource_nodes: vec![ResourceNodeView {
16760                id: "oak-1".into(),
16761                label: "Oak".into(),
16762                x: 130.0,
16763                y: 128.0,
16764                z: 0.0,
16765                item_template: "oak_log".into(),
16766                state: ResourceNodeState::Available,
16767                blocking: true,
16768                blocking_radius_m: 0.8,
16769                harvest_off: false,
16770                tile_id: None,
16771                yaw: 0.0,
16772                pitch: 0.0,
16773                roll: 0.0,
16774                draw_scale: 1.0,
16775                sprite_mode: None,
16776                growth_progress: None,
16777                presentation_state: None,
16778                channel_start_tick: None,
16779                channel_end_tick: None,
16780                harvest_drop_templates: vec![],
16781            }],
16782            ground_drops: vec![],
16783            placed_containers: vec![],
16784            buildings: vec![BuildingView {
16785                id: "broker-hut".into(),
16786                label: "Broker".into(),
16787                x: 148.0,
16788                y: 118.0,
16789                width_m: 8.0,
16790                depth_m: 6.0,
16791                interior_blueprint: Some("broker_hut".into()),
16792                tags: vec![],
16793                market_boundary_zone_ids: vec![],
16794                market_max_volume: None,
16795                wall_set: None,
16796                roof_set: None,
16797            }],
16798            doors: vec![flatland_protocol::DoorView {
16799                id: "door-1".into(),
16800                building_id: "broker-hut".into(),
16801                x: 148.0,
16802                y: 118.0,
16803                open: false,
16804                portal: Some("front".into()),
16805                locked: false,
16806                accessible: true,
16807                lock_id: None,
16808            }],
16809            interior_map: None,
16810            npcs: vec![],
16811            blueprints: vec![],
16812            building_materials: vec![],
16813            world_x0: 0.0,
16814            world_y0: 0.0,
16815            world_width_m: 256.0,
16816            world_height_m: 256.0,
16817            terrain_zones: Vec::new(),
16818            z_platforms: Vec::new(),
16819            z_transitions: Vec::new(),
16820            z_bands_outdoor_backup: None,
16821            world_clock: flatland_protocol::WorldClock::default(),
16822            inventory: std::collections::HashMap::new(),
16823            inventory_hints: std::collections::HashMap::new(),
16824            item_catalog: std::collections::HashMap::new(),
16825            logs: VecDeque::new(),
16826            intents_sent: 0,
16827            ticks_received: 0,
16828            connected: true,
16829            disconnect_reason: None,
16830            show_stats: false,
16831            hud_log_hidden: false,
16832            show_equip_menu: false,
16833            equip_menu_index: 0,
16834            show_craft_menu: false,
16835            show_plot_build_menu: false,
16836            plot_build_focus_wall: true,
16837            plot_build_wall_index: 0,
16838            plot_build_roof_index: 0,
16839            craft_menu_index: 0,
16840            craft_batch_quantity: 1,
16841            craft_tab: CraftTab::Ready,
16842            craft_filter: String::new(),
16843            craft_filter_focused: false,
16844            craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
16845            show_shop_menu: false,
16846            shop_catalog: None,
16847            bank_panel: None,
16848            bank_menu_index: 0,
16849            bank_ui_mode: BankUiMode::Menu,
16850            storage_panel: None,
16851            market_panel: None,
16852            market_menu_index: 0,
16853            market_filter: String::new(),
16854            market_filter_focused: false,
16855            market_category_filter: None,
16856            market_buy_confirm: None,
16857            market_ui_mode: MarketUiMode::Browse,
16858            storage_menu_index: 0,
16859            storage_ui_mode: StorageUiMode::Menu,
16860            shop_tab: ShopTab::default(),
16861            shop_menu_index: 0,
16862            shop_quantity: 1,
16863            shop_trade_log: VecDeque::new(),
16864            show_npc_verb_menu: false,
16865            npc_verb_target: None,
16866            npc_verb_index: 0,
16867            npc_verb_notice: None,
16868            player_verbs: crate::social::PlayerVerbState::default(),
16869            social_chat: crate::social::SocialChatState::default(),
16870            trade_ui: crate::social::TradeUiState::default(),
16871            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
16872            show_npc_chat: false,
16873            npc_chat: None,
16874            show_inventory_menu: false,
16875            inventory_menu_index: 0,
16876            inventory_tab: InventoryTab::OnPerson,
16877            inventory_filter: String::new(),
16878            inventory_filter_focused: false,
16879            show_move_picker: false,
16880            show_rename_prompt: false,
16881            rename_plot_id: None,
16882            highlighted_plot_id: None,
16883            show_worker_rename: false,
16884            rename_buffer: String::new(),
16885            move_picker_index: 0,
16886            move_picker: None,
16887            show_grant_picker: false,
16888            grant_picker_index: 0,
16889            grant_picker: None,
16890            show_destroy_picker: false,
16891            destroy_confirm_pending: false,
16892            destroy_picker: None,
16893            combat_target: None,
16894            combat_target_label: None,
16895            ground_target: None,
16896            combat_fx: Vec::new(),
16897            ground_hazards: Vec::new(),
16898            property_zones: Vec::new(),
16899            tax_zones: Vec::new(),
16900            growth_zones: Vec::new(),
16901            biome_zones: Vec::new(),
16902            terrain_kind_nav: Vec::new(),
16903            property_plots: Vec::new(),
16904            property_plot_settings: None,
16905            claim_mode: None,
16906            relocate_mode: None,
16907            sell_plot_confirm: None,
16908            sell_plot_armed_at: None,
16909            show_plant_menu: false,
16910            plant_menu_index: 0,
16911            show_farm_access: false,
16912            farm_access_name_draft: String::new(),
16913            farm_access_discount_bps: 0,
16914            farm_access_index: 0,
16915            plant_quantity: 1,
16916            in_combat: false,
16917            auto_attack: true,
16918            combat_has_los: false,
16919            attack_cd_ticks: 0,
16920            gcd_ticks: 0,
16921            weapon_ability_id: "unarmed".into(),
16922            mainhand_template_id: None,
16923            mainhand_label: None,
16924            mainhand_instance_id: None,
16925            offhand_template_id: None,
16926            offhand_label: None,
16927            offhand_instance_id: None,
16928            mainhand_hand_slots: 1,
16929            defense: None,
16930            worn: BTreeMap::new(),
16931            carry_mass: 0.0,
16932            carry_mass_max: 0.0,
16933            encumbrance: flatland_protocol::EncumbranceState::Light,
16934            move_speed_mps: 0.0,
16935            move_speed_mult: 0.0,
16936            inventory_stacks: Vec::new(),
16937            keychain_stacks: Vec::new(),
16938            whisper_pouch_stacks: Vec::new(),
16939            combat_target_detail: None,
16940            statuses: Vec::new(),
16941            cast_progress: None,
16942            timed_channel: None,
16943            plot_build_offer: None,
16944            ability_cooldowns: Vec::new(),
16945            blocking_active: false,
16946            max_target_slots: 1,
16947            combat_slots: Vec::new(),
16948            rotation_presets: Vec::new(),
16949            known_abilities: Vec::new(),
16950            ability_meta: std::collections::HashMap::new(),
16951            ability_mastery: std::collections::HashMap::new(),
16952            hotbar: vec![None; 9],
16953            max_abilities_per_rotation: 0,
16954            show_loadout_menu: false,
16955            show_keychain_menu: false,
16956            keychain_menu_index: 0,
16957            show_rotation_editor: false,
16958            loadout_menu_index: 0,
16959            loadout_hotbar_slot: 1,
16960            loadout_ability_index: 0,
16961            loadout_focus_presets: false,
16962            rotation_editor: RotationEditorState::default(),
16963            harvest_in_progress: false,
16964            harvest_started_at: None,
16965            pending_craft_ack: None,
16966            craft_channel_blueprint_id: None,
16967            pending_worker_job_ack: None,
16968            attending_worker_instance_id: None,
16969            quest_log: Vec::new(),
16970            interactables: Vec::new(),
16971            ledger: None,
16972            career: None,
16973            character_sheet_tab: CharacterSheetTab::Character,
16974            ledger_period: LedgerPeriod::Day,
16975            show_quest_offer: false,
16976            pending_quest_offers: Vec::new(),
16977                quest_offer_index: 0,
16978            show_quest_menu: false,
16979            quest_menu_index: 0,
16980            quest_withdraw_confirm: false,
16981            hired_workers: Vec::new(),
16982            show_workers_menu: false,
16983            workers_menu_index: 0,
16984            worker_dismiss_confirmation: None,
16985            workers_menu_compact: false,
16986            worker_step_display: BTreeMap::new(),
16987            worker_error_display: BTreeMap::new(),
16988            worker_health_ring_until: BTreeMap::new(),
16989            pending_worker_hire_since: None,
16990            show_worker_give_picker: false,
16991            worker_give_picker_index: 0,
16992            worker_give_picker: None,
16993            show_worker_give_target_picker: false,
16994            worker_give_target_picker_index: 0,
16995            worker_give_target_picker: None,
16996            show_worker_take_picker: false,
16997            worker_take_picker_index: 0,
16998            worker_take_picker: None,
16999            show_worker_teach_picker: false,
17000            worker_teach_picker_index: 0,
17001            worker_teach_picker: None,
17002            worker_route_editor: None,
17003            progression_curve: None,
17004        };
17005        state.player = state.entities.first().cloned();
17006        state
17007    }
17008
17009    #[test]
17010    fn template_display_name_uses_item_catalog_for_uuid_ids() {
17011        let mut state = sample_state();
17012        let id = "7ac81ed5-e3d3-4b7f-a7fb-11d6c34a0995";
17013        assert_eq!(state.template_display_name(id), "Unknown item");
17014        state.item_catalog.insert(
17015            id.into(),
17016            ItemCatalogEntryView {
17017                template_id: id.into(),
17018                display_name: "Emerald".into(),
17019                category: "resource".into(),
17020                seed_for: None,
17021            },
17022        );
17023        assert_eq!(state.template_display_name(id), "Emerald");
17024    }
17025
17026    #[test]
17027    fn whisper_cancels_when_peer_walks_out_of_range() {
17028        let mut state = sample_state();
17029        state.player = state.entities.first().cloned();
17030        let mut peer = state.entities[0].clone();
17031        peer.id = 2;
17032        peer.label = "Ada".into();
17033        peer.transform.position = WorldCoord::surface(129.0, 128.0); // 1m — in range
17034        state.entities.push(peer.clone());
17035        state.social_chat.focus_whisper(2, "Ada");
17036        state.refresh_whisper_range();
17037        assert!(matches!(
17038            state.social_chat.thread,
17039            crate::social::ChatThreadKind::Whisper { peer: 2 }
17040        ));
17041
17042        peer.transform.position = WorldCoord::surface(132.0, 128.0); // 4m — out of range
17043        state.entities[1] = peer;
17044        state.refresh_whisper_range();
17045        assert_eq!(
17046            state.social_chat.thread,
17047            crate::social::ChatThreadKind::Nearby
17048        );
17049        assert!(!state.social_chat.input_focused);
17050    }
17051
17052    #[test]
17053    fn probe_use_world_hired_worker_manage() {
17054        let mut state = sample_state();
17055        state
17056            .hired_workers
17057            .push(flatland_protocol::HiredWorkerView {
17058                instance_id: "worker-1".into(),
17059                entity_id: 42,
17060                def_id: "worker_laborer".into(),
17061                label: "Sam".into(),
17062                x: 129.0,
17063                y: 128.0,
17064                z: 0.0,
17065                mode: flatland_protocol::WorkerModeView::JobLoop,
17066                state: flatland_protocol::WorkerStateView::Working,
17067                step_label: "cultivate".into(),
17068                vitals: flatland_protocol::WorkerVitalsSummary {
17069                    health_pct: 100.0,
17070                    stamina_pct: 100.0,
17071                    mana_pct: 100.0,
17072                    hunger_pct: 100.0,
17073                    thirst_pct: 100.0,
17074                },
17075                carry_pct: 0.0,
17076                last_error: None,
17077                wage_copper_per_interval: 1,
17078                effective_wage_copper: 1,
17079                wage_meters_walked: 0.0,
17080                lodging_container_id: None,
17081                route: None,
17082                route_stop_index: None,
17083                known_blueprint_ids: Vec::new(),
17084                level: 1,
17085                worker_xp: 0.0,
17086                inventory: Vec::new(),
17087                equipment: flatland_protocol::WorkerEquipmentView::default(),
17088                issue_hint: None,
17089            });
17090        let probe = state.probe_use_world();
17091        let primary = probe.primary.expect("primary");
17092        assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17093        assert_eq!(primary.id, "worker-1");
17094        assert!(primary.hint_line().contains("Manage"));
17095        assert!(primary.hint_line().contains("Sam"));
17096        assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17097    }
17098
17099    fn sample_hired_worker(x: f32, y: f32) -> flatland_protocol::HiredWorkerView {
17100        flatland_protocol::HiredWorkerView {
17101            instance_id: "worker-1".into(),
17102            entity_id: 42,
17103            def_id: "worker_laborer".into(),
17104            label: "Sam".into(),
17105            x,
17106            y,
17107            z: 0.0,
17108            mode: flatland_protocol::WorkerModeView::JobLoop,
17109            state: flatland_protocol::WorkerStateView::Working,
17110            step_label: "follow".into(),
17111            vitals: flatland_protocol::WorkerVitalsSummary {
17112                health_pct: 100.0,
17113                stamina_pct: 100.0,
17114                mana_pct: 100.0,
17115                hunger_pct: 100.0,
17116                thirst_pct: 100.0,
17117            },
17118            carry_pct: 0.0,
17119            last_error: None,
17120            wage_copper_per_interval: 1,
17121            effective_wage_copper: 1,
17122            wage_meters_walked: 0.0,
17123            lodging_container_id: None,
17124            route: None,
17125            route_stop_index: None,
17126            known_blueprint_ids: Vec::new(),
17127            level: 1,
17128            worker_xp: 0.0,
17129            inventory: Vec::new(),
17130            equipment: flatland_protocol::WorkerEquipmentView::default(),
17131            issue_hint: None,
17132        }
17133    }
17134
17135    #[test]
17136    fn probe_harvest_beats_closer_hired_worker() {
17137        let mut state = sample_state();
17138        state.resource_nodes[0].x = 129.0;
17139        state.resource_nodes[0].y = 128.0;
17140        state.hired_workers.push(sample_hired_worker(128.2, 128.0));
17141        let probe = state.probe_use_world();
17142        let primary = probe.primary.expect("primary");
17143        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
17144        assert_eq!(primary.id, "oak-1");
17145        assert!(state.harvestable_node_in_range());
17146        assert_eq!(
17147            state.nearest_interact_target().as_deref(),
17148            Some("worker-1"),
17149            "harvest is not Interact — worker remains the interact target"
17150        );
17151    }
17152
17153    #[test]
17154    fn probe_door_beats_closer_hired_worker() {
17155        let mut state = sample_state();
17156        state.doors[0].x = 129.2;
17157        state.doors[0].y = 128.0;
17158        state.hired_workers.push(sample_hired_worker(128.3, 128.0));
17159        let probe = state.probe_use_world();
17160        let primary = probe.primary.expect("primary");
17161        assert!(
17162            matches!(
17163                primary.kind,
17164                crate::UseWorldKind::EnterDoor
17165                    | crate::UseWorldKind::OpenDoor
17166                    | crate::UseWorldKind::CloseDoor
17167                    | crate::UseWorldKind::ExitDoor
17168            ),
17169            "door should win over closer worker, got {:?}",
17170            primary.kind
17171        );
17172        assert_eq!(primary.id, "door-1");
17173        assert_eq!(state.nearest_interact_target().as_deref(), Some("door-1"));
17174    }
17175
17176    #[test]
17177    fn probe_indoor_exit_door_beats_lodging_chest_pickup() {
17178        // MadSin-style house: exit door + lodging beds within chest pickup radius.
17179        // `f` / probe must prefer the door (cascade stage 0) over ChestPickup (stage 2).
17180        let mut state = sample_state();
17181        state.entities[0].inside_building = Some("player_house".into());
17182        state.entities[0].transform.position = WorldCoord::surface(5.0, 2.0);
17183        state.player = state.entities.first().cloned();
17184        state.buildings = vec![BuildingView {
17185            id: "player_house".into(),
17186            label: "MadSin's house".into(),
17187            x: 100.0,
17188            y: 100.0,
17189            width_m: 10.0,
17190            depth_m: 8.0,
17191            interior_blueprint: Some("player_house".into()),
17192            tags: vec!["player_built".into()],
17193            market_boundary_zone_ids: vec![],
17194            market_max_volume: None,
17195            wall_set: None,
17196            roof_set: None,
17197        }];
17198        state.doors = vec![flatland_protocol::DoorView {
17199            id: "house_exit".into(),
17200            building_id: "player_house".into(),
17201            x: 5.0,
17202            y: 1.0,
17203            open: true,
17204            portal: Some("front".into()),
17205            locked: false,
17206            accessible: true,
17207            lock_id: None,
17208        }];
17209        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17210            id: "lodging_bed".into(),
17211            template_id: "camp_bed".into(),
17212            display_name: "Camp bed".into(),
17213            x: 5.5,
17214            y: 2.4,
17215            z: 0.0,
17216            locked: false,
17217            accessible: true,
17218            owner_character_id: None,
17219            contents: vec![],
17220            lock_id: None,
17221            capacity_volume: None,
17222            item_instance_id: Some(uuid::Uuid::from_u128(99)),
17223            tile_id: None,
17224            worker_lodging_capacity: Some(1),
17225            blocking: false,
17226            blocking_radius_m: 0.0,
17227            building_id: Some("player_house".into()),
17228        }];
17229        // Worker assigned to the bed but standing far away — occupancy only.
17230        let mut worker = sample_hired_worker(40.0, 40.0);
17231        worker.lodging_container_id = Some("lodging_bed".into());
17232        state.hired_workers.push(worker);
17233
17234        let probe = state.probe_use_world();
17235        let primary = probe.primary.expect("primary");
17236        assert!(
17237            matches!(
17238                primary.kind,
17239                crate::UseWorldKind::ExitDoor
17240                    | crate::UseWorldKind::OpenDoor
17241                    | crate::UseWorldKind::CloseDoor
17242                    | crate::UseWorldKind::EnterDoor
17243            ),
17244            "indoor exit must beat lodging ChestPickup, got {:?}",
17245            primary.kind
17246        );
17247        assert_eq!(primary.id, "house_exit");
17248        assert_eq!(primary.kind.cascade_stage(), 0);
17249        assert!(
17250            probe
17251                .candidates
17252                .iter()
17253                .any(|c| c.kind == crate::UseWorldKind::ChestPickup && c.in_range),
17254            "lodging bed should still be an in-range chest candidate"
17255        );
17256        assert_eq!(
17257            state.nearest_interact_target().as_deref(),
17258            Some("house_exit"),
17259            "use_nearest interact path should target the door"
17260        );
17261        assert!(state.lodging_is_occupied("lodging_bed"));
17262    }
17263
17264    #[test]
17265    fn probe_worker_when_no_resource_or_door_in_range() {
17266        let mut state = sample_state();
17267        // oak stays at 130,128 (2m, out of harvest); door stays at hut (~22m)
17268        state.hired_workers.push(sample_hired_worker(129.0, 128.0));
17269        let probe = state.probe_use_world();
17270        let primary = probe.primary.expect("primary");
17271        assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17272        assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17273        assert!(!state.harvestable_node_in_range());
17274    }
17275
17276    #[test]
17277    fn market_clerk_verb_options_include_market() {
17278        let mut state = sample_state();
17279        state.npcs.push(flatland_protocol::NpcView {
17280            id: "mira_market".into(),
17281            label: "Mira".into(),
17282            role: "market_clerk".into(),
17283            x: 129.0,
17284            y: 128.0,
17285            building_id: Some("town_market".into()),
17286            entity_id: None,
17287            life_state: None,
17288            hp_pct: None,
17289            can_trade: false,
17290            buy_templates: vec![],
17291            tile_id: None,
17292            behavior_state: None,
17293            presentation_state: None,
17294            sprite_mode: None,
17295            paperdoll_ref: None,
17296            draw_scale: 1.0,
17297            yaw: None,
17298            perception_fov_deg: None,
17299            perception_sight_m: None,
17300            perception_hear_m: None,
17301            quest_verbs: Vec::new(),
17302        });
17303        state.npc_verb_target = Some("mira_market".into());
17304        assert_eq!(
17305            state
17306                .npc_verb_options()
17307                .iter()
17308                .map(|v| v.label.as_str())
17309                .collect::<Vec<_>>(),
17310            vec!["Market", "Talk"]
17311        );
17312    }
17313
17314    #[test]
17315    fn butcher_verb_options_include_turn_in_for_give_item() {
17316        let mut state = sample_state();
17317        state.npcs.push(flatland_protocol::NpcView {
17318            id: "town_butcher_1".into(),
17319            label: "Brutus".into(),
17320            role: "butcher".into(),
17321            x: 129.0,
17322            y: 128.0,
17323            building_id: None,
17324            entity_id: None,
17325            life_state: None,
17326            hp_pct: None,
17327            can_trade: true,
17328            buy_templates: vec!["raw_venison".into()],
17329            tile_id: None,
17330            behavior_state: None,
17331            presentation_state: None,
17332            sprite_mode: None,
17333            paperdoll_ref: None,
17334            draw_scale: 1.0,
17335            yaw: None,
17336            perception_fov_deg: None,
17337            perception_sight_m: None,
17338            perception_hear_m: None,
17339            quest_verbs: Vec::new(),
17340        });
17341        state.quest_log.push(flatland_protocol::QuestLogEntry {
17342            quest_id: "deer_threat".into(),
17343            title: "Deer threat".into(),
17344            description: String::new(),
17345            status: flatland_protocol::QuestStatusView::Active,
17346            current_step_id: Some("deliver".into()),
17347            current_step_title: "Deliver venison".into(),
17348            current_step_index: 0,
17349            objectives: vec![flatland_protocol::QuestObjectiveProgress {
17350                label: "Give 3 Raw venison to Brutus".into(),
17351                current: 0,
17352                required: 3,
17353                done: false,
17354                kind: "give_item".into(),
17355                npc_ref: Some("town_butcher_1".into()),
17356                item_template: Some("raw_venison".into()),
17357                blueprint_id: None,
17358                building_id: None,
17359            }],
17360            current_step_reward: flatland_protocol::QuestRewardView::default(),
17361            completion_reward: flatland_protocol::QuestRewardView::default(),
17362            steps: Vec::new(),
17363            is_tracked: true,
17364            can_withdraw: true,
17365        });
17366        state.npc_verb_target = Some("town_butcher_1".into());
17367        assert_eq!(
17368            state
17369                .npc_verb_options()
17370                .iter()
17371                .map(|v| v.label.as_str())
17372                .collect::<Vec<_>>(),
17373            vec!["Turn in: Deer threat", "Talk", "Trade"]
17374        );
17375    }
17376
17377    #[test]
17378    fn ada_verb_options_include_quest_offer() {
17379        let mut state = sample_state();
17380        state.npcs.push(flatland_protocol::NpcView {
17381            id: "ada_broker".into(),
17382            label: "Ada".into(),
17383            role: "broker".into(),
17384            x: 129.0,
17385            y: 128.0,
17386            building_id: None,
17387            entity_id: None,
17388            life_state: None,
17389            hp_pct: None,
17390            can_trade: true,
17391            buy_templates: vec![],
17392            tile_id: None,
17393            behavior_state: None,
17394            presentation_state: None,
17395            sprite_mode: None,
17396            paperdoll_ref: Some("ada_broker".into()),
17397            draw_scale: 1.0,
17398            yaw: None,
17399            perception_fov_deg: None,
17400            perception_sight_m: None,
17401            perception_hear_m: None,
17402            quest_verbs: vec![flatland_protocol::NpcQuestVerb {
17403                quest_id: "ada_goblin_hunt".into(),
17404                label: "Ask about goblins".into(),
17405                kind: flatland_protocol::NpcQuestVerb::KIND_OFFER.into(),
17406            }],
17407        });
17408        state.npc_verb_target = Some("ada_broker".into());
17409        assert_eq!(
17410            state
17411                .npc_verb_options()
17412                .iter()
17413                .map(|v| v.label.as_str())
17414                .collect::<Vec<_>>(),
17415            vec!["Ask about goblins", "Talk", "Trade"]
17416        );
17417    }
17418
17419    #[test]
17420    fn market_list_excludes_currency_stacks() {
17421        let mut state = sample_state();
17422        state.inventory_stacks = vec![
17423            flatland_protocol::ItemStack {
17424                template_id: "copper_coin".into(),
17425                quantity: 50,
17426                item_instance_id: Some(uuid::Uuid::from_u128(10)),
17427                display_name: Some("Copper Coin".into()),
17428                ..Default::default()
17429            },
17430            flatland_protocol::ItemStack {
17431                template_id: "oak_log".into(),
17432                quantity: 2,
17433                item_instance_id: Some(uuid::Uuid::from_u128(11)),
17434                display_name: Some("Oak Log".into()),
17435                ..Default::default()
17436            },
17437            flatland_protocol::ItemStack {
17438                template_id: "whisper_stone".into(),
17439                quantity: 1,
17440                item_instance_id: Some(uuid::Uuid::from_u128(12)),
17441                display_name: Some("Whisper Stone".into()),
17442                category: Some("quest".into()),
17443                listable: Some(false),
17444                ..Default::default()
17445            },
17446        ];
17447        let opts = state.market_list_item_options(&MarketListSourceKind::Person);
17448        assert_eq!(opts.len(), 1);
17449        assert!(opts[0].label.contains("Oak"));
17450    }
17451
17452    #[test]
17453    fn market_browse_filters_by_category_and_search() {
17454        let mut state = sample_state();
17455        state.market_panel = Some(flatland_protocol::MarketPanel {
17456            npc_id: "mira_market".into(),
17457            npc_label: "Mira".into(),
17458            building_id: "town_market".into(),
17459            building_label: "Town Market".into(),
17460            used_volume: 0.0,
17461            max_volume: 100.0,
17462            listings: vec![
17463                flatland_protocol::MarketListingView {
17464                    listing_id: uuid::Uuid::from_u128(1),
17465                    seller_character_id: uuid::Uuid::from_u128(2),
17466                    seller_label: "Ada".into(),
17467                    hall_building_id: "town_market".into(),
17468                    hall_label: "Town Market".into(),
17469                    template_id: "oak_log".into(),
17470                    display_name: "Oak Log".into(),
17471                    category: "resource".into(),
17472                    quantity: 3,
17473                    unit_price_copper: 10,
17474                    line_total_copper: 30,
17475                    npc_price: false,
17476                    npc_dump_unit_copper: None,
17477                    mine: false,
17478                },
17479                flatland_protocol::MarketListingView {
17480                    listing_id: uuid::Uuid::from_u128(3),
17481                    seller_character_id: uuid::Uuid::from_u128(2),
17482                    seller_label: "Ada".into(),
17483                    hall_building_id: "town_market".into(),
17484                    hall_label: "Town Market".into(),
17485                    template_id: "short_sword".into(),
17486                    display_name: "Short Sword".into(),
17487                    category: "weapon".into(),
17488                    quantity: 1,
17489                    unit_price_copper: 100,
17490                    line_total_copper: 100,
17491                    npc_price: false,
17492                    npc_dump_unit_copper: None,
17493                    mine: false,
17494                },
17495            ],
17496            tax_bps: 0,
17497            tax_flat_copper: 0,
17498            list_vaults: vec![],
17499        });
17500        assert_eq!(state.market_filtered_listing_indices().len(), 2);
17501        state.market_category_filter = Some("Weapons");
17502        let weapons = state.market_filtered_listing_indices();
17503        assert_eq!(weapons.len(), 1);
17504        assert_eq!(
17505            state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
17506            "Short Sword"
17507        );
17508        state.market_category_filter = None;
17509        state.market_filter = "oak".into();
17510        let oak = state.market_filtered_listing_indices();
17511        assert_eq!(oak.len(), 1);
17512        assert_eq!(
17513            state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
17514            "Oak Log"
17515        );
17516    }
17517
17518    #[test]
17519    fn market_list_source_includes_person_and_vaults() {
17520        let mut state = sample_state();
17521        let item_id = uuid::Uuid::from_u128(1);
17522        state.inventory_stacks = vec![flatland_protocol::ItemStack {
17523            template_id: "oak_log".into(),
17524            quantity: 2,
17525            item_instance_id: Some(item_id),
17526            display_name: Some("Oak Log".into()),
17527            ..Default::default()
17528        }];
17529        state.market_panel = Some(flatland_protocol::MarketPanel {
17530            npc_id: "mira_market".into(),
17531            npc_label: "Mira".into(),
17532            building_id: "town_market".into(),
17533            building_label: "Town Market".into(),
17534            used_volume: 0.0,
17535            max_volume: 100.0,
17536            listings: vec![],
17537            tax_bps: 0,
17538            tax_flat_copper: 0,
17539            list_vaults: vec![flatland_protocol::MarketListVault {
17540                building_id: "town_storage".into(),
17541                building_label: "Town Storage".into(),
17542                contents: vec![flatland_protocol::ItemStack {
17543                    template_id: "lumber".into(),
17544                    quantity: 1,
17545                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
17546                    display_name: Some("Lumber".into()),
17547                    ..Default::default()
17548                }],
17549            }],
17550        });
17551        let sources = state.market_list_source_options();
17552        assert_eq!(sources.len(), 2);
17553        assert!(matches!(sources[0].0, MarketListSourceKind::Person));
17554        assert!(matches!(
17555            sources[1].0,
17556            MarketListSourceKind::TownStorage { .. }
17557        ));
17558        assert!(sources[1].1.contains("Town Storage"));
17559    }
17560
17561    #[test]
17562    fn npc_market_dump_estimate_from_town_storage_vault() {
17563        let mut state = sample_state();
17564        state.market_panel = Some(flatland_protocol::MarketPanel {
17565            npc_id: "mira_market".into(),
17566            npc_label: "Mira".into(),
17567            building_id: "town_market".into(),
17568            building_label: "Town Market".into(),
17569            used_volume: 0.0,
17570            max_volume: 100.0,
17571            listings: vec![],
17572            tax_bps: 0,
17573            tax_flat_copper: 0,
17574            list_vaults: vec![flatland_protocol::MarketListVault {
17575                building_id: "town_storage".into(),
17576                building_label: "Town Storage".into(),
17577                contents: vec![flatland_protocol::ItemStack {
17578                    template_id: "lumber".into(),
17579                    quantity: 3,
17580                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
17581                    display_name: Some("Lumber".into()),
17582                    base_value_copper: Some(20),
17583                    ..Default::default()
17584                }],
17585            }],
17586        });
17587        assert_eq!(
17588            state.npc_market_dump_unit_estimate("lumber"),
17589            Some(9),
17590            "vault stack base_value should enable NPC price estimate"
17591        );
17592    }
17593
17594    #[test]
17595    fn probe_use_world_npc_beats_nearby_loot() {
17596        let mut state = sample_state();
17597        state.npcs.push(flatland_protocol::NpcView {
17598            id: "ada".into(),
17599            label: "Ada".into(),
17600            role: "broker".into(),
17601            x: 129.0,
17602            y: 128.0,
17603            building_id: None,
17604            entity_id: None,
17605            life_state: None,
17606            hp_pct: None,
17607            can_trade: true,
17608            buy_templates: vec!["lumber".into()],
17609            tile_id: None,
17610            behavior_state: None,
17611            presentation_state: None,
17612            sprite_mode: None,
17613            paperdoll_ref: None,
17614            draw_scale: 1.0,
17615            yaw: None,
17616            perception_fov_deg: None,
17617            perception_sight_m: None,
17618            perception_hear_m: None,
17619            quest_verbs: Vec::new(),
17620        });
17621        state.ground_drops.push(flatland_protocol::GroundDropView {
17622            id: "d1".into(),
17623            template_id: "lumber".into(),
17624            quantity: 1,
17625            x: 128.5,
17626            y: 128.0,
17627            z: 0.0,
17628            tile_id: None,
17629            display_name: None,
17630            yaw: 0.0,
17631            pitch: 0.0,
17632            roll: 0.0,
17633            draw_scale: 1.0,
17634            item_instance_id: None,
17635            props: Default::default(),
17636            status_bindings: Vec::new(),
17637        });
17638        let probe = state.probe_use_world();
17639        let primary = probe.primary.expect("primary");
17640        assert_eq!(primary.kind, crate::UseWorldKind::Npc);
17641        assert_eq!(primary.id, "ada");
17642    }
17643
17644    #[test]
17645    fn probe_use_world_harvest_when_in_range() {
17646        let state = sample_state(); // oak at 130,128 — player 128,128 → dist 2 > 1.5
17647        let probe = state.probe_use_world();
17648        assert!(
17649            probe.primary.is_none(),
17650            "oak is 2m away, out of harvest range"
17651        );
17652        assert!(probe
17653            .candidates
17654            .iter()
17655            .any(|c| c.kind == crate::UseWorldKind::Harvest));
17656
17657        let mut state = sample_state();
17658        state.resource_nodes[0].x = 129.0;
17659        let probe = state.probe_use_world();
17660        let primary = probe.primary.expect("primary");
17661        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
17662    }
17663
17664    #[test]
17665    fn probe_use_world_door_uses_building_label() {
17666        let mut state = sample_state();
17667        state.doors[0].x = 129.0;
17668        state.doors[0].y = 128.0;
17669        let probe = state.probe_use_world();
17670        let primary = probe.primary.expect("primary");
17671        assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
17672        assert_eq!(primary.label, "Broker");
17673        assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
17674    }
17675
17676    #[test]
17677    fn empty_entity_tick_preserves_welcome_snapshot() {
17678        let mut state = sample_state();
17679        state.inventory.insert("carrot".into(), 3);
17680        let delta = TickDelta {
17681            tick: 1,
17682            entities: vec![],
17683            resource_nodes: vec![],
17684            ground_drops: vec![],
17685            placed_containers: vec![],
17686            buildings: vec![],
17687            doors: vec![],
17688            interior_map: None,
17689            npcs: vec![],
17690            inventory: vec![],
17691            blueprints: vec![],
17692            building_materials: vec![],
17693            world_clock: flatland_protocol::WorldClock::default(),
17694            combat: None,
17695            quest_log: vec![],
17696            hired_workers: Vec::new(),
17697            interactables: vec![],
17698            ledger: None,
17699            career: None,
17700            combat_fx: Vec::new(),
17701            ground_hazards: Vec::new(),
17702            property_plots: Vec::new(),
17703            terrain_overlays: Vec::new(),
17704        };
17705
17706        state.apply_tick_fields(&delta, 1);
17707
17708        assert_eq!(state.entities.len(), 1);
17709        assert!(state.player.is_some());
17710        assert_eq!(state.inventory.get("carrot"), Some(&3));
17711        assert_eq!(state.resource_nodes.len(), 1);
17712    }
17713
17714    #[test]
17715    fn tick_preserves_world_layers_when_delta_omits_them() {
17716        let mut state = sample_state();
17717        let delta = TickDelta {
17718            tick: 1,
17719            entities: state.entities.clone(),
17720            resource_nodes: vec![],
17721            ground_drops: vec![],
17722            placed_containers: vec![],
17723            buildings: vec![],
17724            doors: vec![],
17725            interior_map: None,
17726            npcs: vec![],
17727            inventory: vec![],
17728            blueprints: vec![],
17729            building_materials: vec![],
17730            world_clock: flatland_protocol::WorldClock::default(),
17731            combat: None,
17732            quest_log: vec![],
17733            hired_workers: Vec::new(),
17734            interactables: vec![],
17735            ledger: None,
17736            career: None,
17737            combat_fx: Vec::new(),
17738            ground_hazards: Vec::new(),
17739            property_plots: Vec::new(),
17740            terrain_overlays: Vec::new(),
17741        };
17742
17743        state.apply_tick_fields(&delta, 1);
17744
17745        assert_eq!(state.resource_nodes.len(), 1);
17746        assert_eq!(state.buildings.len(), 1);
17747        assert_eq!(state.doors.len(), 1);
17748    }
17749
17750    #[test]
17751    fn tick_updates_resource_nodes_when_server_sends_them() {
17752        let mut state = sample_state();
17753        let delta = TickDelta {
17754            tick: 1,
17755            entities: state.entities.clone(),
17756            resource_nodes: vec![ResourceNodeView {
17757                id: "oak-1".into(),
17758                label: "Oak".into(),
17759                x: 130.0,
17760                y: 128.0,
17761                z: 0.0,
17762                item_template: "oak_log".into(),
17763                state: ResourceNodeState::Cooldown,
17764                blocking: true,
17765                blocking_radius_m: 0.8,
17766                harvest_off: false,
17767                tile_id: None,
17768                yaw: 0.0,
17769                pitch: 0.0,
17770                roll: 0.0,
17771                draw_scale: 1.0,
17772                sprite_mode: None,
17773                growth_progress: None,
17774                presentation_state: None,
17775                channel_start_tick: None,
17776                channel_end_tick: None,
17777                harvest_drop_templates: vec![],
17778            }],
17779            buildings: vec![],
17780            doors: vec![],
17781            interior_map: None,
17782            npcs: vec![],
17783            inventory: vec![],
17784            blueprints: vec![],
17785            building_materials: vec![],
17786            world_clock: flatland_protocol::WorldClock::default(),
17787            ground_drops: vec![],
17788            placed_containers: vec![],
17789            combat: None,
17790            quest_log: vec![],
17791            hired_workers: Vec::new(),
17792            interactables: vec![],
17793            ledger: None,
17794            career: None,
17795            combat_fx: Vec::new(),
17796            ground_hazards: Vec::new(),
17797            property_plots: Vec::new(),
17798            terrain_overlays: Vec::new(),
17799        };
17800
17801        state.apply_tick_fields(&delta, 1);
17802
17803        assert!(matches!(
17804            state.resource_nodes[0].state,
17805            ResourceNodeState::Cooldown
17806        ));
17807    }
17808
17809    #[test]
17810    fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
17811        let mut state = GameState {
17812            session_id: 1,
17813            entity_id: 1,
17814            character_id: None,
17815            tick: 0,
17816            chunk_rev: 0,
17817            content_rev: 0,
17818            publish_rev: 0,
17819            entities: vec![EntityState {
17820                id: 1,
17821                label: "You".into(),
17822                transform: Transform {
17823                    position: WorldCoord::surface(4.5, 2.0),
17824                    yaw: 0.0,
17825                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
17826                },
17827                vitals: None,
17828                attributes: None,
17829                skills: None,
17830                inside_building: Some("broker_hut".into()),
17831                tile_id: None,
17832                paperdoll_ref: None,
17833                draw_scale: 1.0,
17834                presentation_state: None,
17835                sprite_mode: None,
17836                progression_xp: None,
17837                combat_cues: vec![],
17838                statuses: vec![],
17839            }],
17840            player: None,
17841            resource_nodes: vec![],
17842            ground_drops: vec![],
17843            placed_containers: vec![],
17844            buildings: vec![BuildingView {
17845                id: "broker_hut".into(),
17846                label: "Broker".into(),
17847                x: 158.0,
17848                y: 124.0,
17849                width_m: 8.0,
17850                depth_m: 6.0,
17851                interior_blueprint: Some("broker_hut".into()),
17852                tags: vec![],
17853                market_boundary_zone_ids: vec![],
17854                market_max_volume: None,
17855                wall_set: None,
17856                roof_set: None,
17857            }],
17858            doors: vec![flatland_protocol::DoorView {
17859                id: "broker_hut_exit".into(),
17860                building_id: "broker_hut".into(),
17861                x: 4.3,
17862                y: 0.9,
17863                open: true,
17864                portal: Some("front".into()),
17865                locked: false,
17866                accessible: true,
17867                lock_id: None,
17868            }],
17869            interior_map: None,
17870            npcs: vec![flatland_protocol::NpcView {
17871                id: "ada_broker".into(),
17872                label: "Ada".into(),
17873                x: 4.5,
17874                y: 2.0,
17875                building_id: Some("broker_hut".into()),
17876                role: "broker".into(),
17877                entity_id: None,
17878                life_state: None,
17879                hp_pct: None,
17880                can_trade: true,
17881                buy_templates: vec!["lumber".into()],
17882                tile_id: None,
17883                behavior_state: None,
17884                presentation_state: None,
17885                sprite_mode: None,
17886                paperdoll_ref: None,
17887                draw_scale: 1.0,
17888                yaw: None,
17889                perception_fov_deg: None,
17890                perception_sight_m: None,
17891                perception_hear_m: None,
17892                quest_verbs: Vec::new(),
17893            }],
17894            blueprints: vec![],
17895            building_materials: vec![],
17896            world_x0: 0.0,
17897            world_y0: 0.0,
17898            world_width_m: 256.0,
17899            world_height_m: 256.0,
17900            terrain_zones: Vec::new(),
17901            z_platforms: Vec::new(),
17902            z_transitions: Vec::new(),
17903            z_bands_outdoor_backup: None,
17904            world_clock: flatland_protocol::WorldClock::default(),
17905            inventory: std::collections::HashMap::new(),
17906            inventory_hints: std::collections::HashMap::new(),
17907            item_catalog: std::collections::HashMap::new(),
17908            logs: VecDeque::new(),
17909            intents_sent: 0,
17910            ticks_received: 0,
17911            connected: true,
17912            disconnect_reason: None,
17913            show_stats: false,
17914            hud_log_hidden: false,
17915            show_equip_menu: false,
17916            equip_menu_index: 0,
17917            show_craft_menu: false,
17918            show_plot_build_menu: false,
17919            plot_build_focus_wall: true,
17920            plot_build_wall_index: 0,
17921            plot_build_roof_index: 0,
17922            craft_menu_index: 0,
17923            craft_batch_quantity: 1,
17924            craft_tab: CraftTab::Ready,
17925            craft_filter: String::new(),
17926            craft_filter_focused: false,
17927            craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
17928            show_shop_menu: false,
17929            shop_catalog: None,
17930            bank_panel: None,
17931            bank_menu_index: 0,
17932            bank_ui_mode: BankUiMode::Menu,
17933            storage_panel: None,
17934            market_panel: None,
17935            market_menu_index: 0,
17936            market_filter: String::new(),
17937            market_filter_focused: false,
17938            market_category_filter: None,
17939            market_buy_confirm: None,
17940            market_ui_mode: MarketUiMode::Browse,
17941            storage_menu_index: 0,
17942            storage_ui_mode: StorageUiMode::Menu,
17943            shop_tab: ShopTab::default(),
17944            shop_menu_index: 0,
17945            shop_quantity: 1,
17946            shop_trade_log: VecDeque::new(),
17947            show_npc_verb_menu: false,
17948            npc_verb_target: None,
17949            npc_verb_index: 0,
17950            npc_verb_notice: None,
17951            player_verbs: crate::social::PlayerVerbState::default(),
17952            social_chat: crate::social::SocialChatState::default(),
17953            trade_ui: crate::social::TradeUiState::default(),
17954            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
17955            show_npc_chat: false,
17956            npc_chat: None,
17957            show_inventory_menu: false,
17958            inventory_menu_index: 0,
17959            inventory_tab: InventoryTab::OnPerson,
17960            inventory_filter: String::new(),
17961            inventory_filter_focused: false,
17962            show_move_picker: false,
17963            show_rename_prompt: false,
17964            rename_plot_id: None,
17965            highlighted_plot_id: None,
17966            show_worker_rename: false,
17967            rename_buffer: String::new(),
17968            move_picker_index: 0,
17969            move_picker: None,
17970            show_grant_picker: false,
17971            grant_picker_index: 0,
17972            grant_picker: None,
17973            show_destroy_picker: false,
17974            destroy_confirm_pending: false,
17975            destroy_picker: None,
17976            combat_target: None,
17977            combat_target_label: None,
17978            ground_target: None,
17979            combat_fx: Vec::new(),
17980            ground_hazards: Vec::new(),
17981            property_zones: Vec::new(),
17982            tax_zones: Vec::new(),
17983            growth_zones: Vec::new(),
17984            biome_zones: Vec::new(),
17985            terrain_kind_nav: Vec::new(),
17986            property_plots: Vec::new(),
17987            property_plot_settings: None,
17988            claim_mode: None,
17989            relocate_mode: None,
17990            sell_plot_confirm: None,
17991            sell_plot_armed_at: None,
17992            show_plant_menu: false,
17993            plant_menu_index: 0,
17994            show_farm_access: false,
17995            farm_access_name_draft: String::new(),
17996            farm_access_discount_bps: 0,
17997            farm_access_index: 0,
17998            plant_quantity: 1,
17999            in_combat: false,
18000            auto_attack: true,
18001            combat_has_los: false,
18002            attack_cd_ticks: 0,
18003            gcd_ticks: 0,
18004            weapon_ability_id: "unarmed".into(),
18005            mainhand_template_id: None,
18006            mainhand_label: None,
18007            mainhand_instance_id: None,
18008            offhand_template_id: None,
18009            offhand_label: None,
18010            offhand_instance_id: None,
18011            mainhand_hand_slots: 1,
18012            defense: None,
18013            worn: BTreeMap::new(),
18014            carry_mass: 0.0,
18015            carry_mass_max: 0.0,
18016            encumbrance: flatland_protocol::EncumbranceState::Light,
18017            move_speed_mps: 0.0,
18018            move_speed_mult: 0.0,
18019            inventory_stacks: Vec::new(),
18020            keychain_stacks: Vec::new(),
18021            whisper_pouch_stacks: Vec::new(),
18022            combat_target_detail: None,
18023            statuses: Vec::new(),
18024            cast_progress: None,
18025            timed_channel: None,
18026            plot_build_offer: None,
18027            ability_cooldowns: Vec::new(),
18028            blocking_active: false,
18029            max_target_slots: 1,
18030            combat_slots: Vec::new(),
18031            rotation_presets: Vec::new(),
18032            known_abilities: Vec::new(),
18033            ability_meta: std::collections::HashMap::new(),
18034            ability_mastery: std::collections::HashMap::new(),
18035            hotbar: vec![None; 9],
18036            max_abilities_per_rotation: 0,
18037            show_loadout_menu: false,
18038            show_keychain_menu: false,
18039            keychain_menu_index: 0,
18040            show_rotation_editor: false,
18041            loadout_menu_index: 0,
18042            loadout_hotbar_slot: 1,
18043            loadout_ability_index: 0,
18044            loadout_focus_presets: false,
18045            rotation_editor: RotationEditorState::default(),
18046            harvest_in_progress: false,
18047            harvest_started_at: None,
18048            pending_craft_ack: None,
18049            craft_channel_blueprint_id: None,
18050            pending_worker_job_ack: None,
18051            attending_worker_instance_id: None,
18052            quest_log: Vec::new(),
18053            interactables: Vec::new(),
18054            ledger: None,
18055            career: None,
18056            character_sheet_tab: CharacterSheetTab::Character,
18057            ledger_period: LedgerPeriod::Day,
18058            show_quest_offer: false,
18059            pending_quest_offers: Vec::new(),
18060                quest_offer_index: 0,
18061            show_quest_menu: false,
18062            quest_menu_index: 0,
18063            quest_withdraw_confirm: false,
18064            hired_workers: Vec::new(),
18065            show_workers_menu: false,
18066            workers_menu_index: 0,
18067            worker_dismiss_confirmation: None,
18068            workers_menu_compact: false,
18069            worker_step_display: BTreeMap::new(),
18070            worker_error_display: BTreeMap::new(),
18071            worker_health_ring_until: BTreeMap::new(),
18072            pending_worker_hire_since: None,
18073            show_worker_give_picker: false,
18074            worker_give_picker_index: 0,
18075            worker_give_picker: None,
18076            show_worker_give_target_picker: false,
18077            worker_give_target_picker_index: 0,
18078            worker_give_target_picker: None,
18079            show_worker_take_picker: false,
18080            worker_take_picker_index: 0,
18081            worker_take_picker: None,
18082            show_worker_teach_picker: false,
18083            worker_teach_picker_index: 0,
18084            worker_teach_picker: None,
18085            worker_route_editor: None,
18086            progression_curve: None,
18087        };
18088        state.player = state.entities.first().cloned();
18089        assert_eq!(
18090            state.nearest_interact_target().as_deref(),
18091            Some("ada_broker")
18092        );
18093    }
18094
18095    #[test]
18096    fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
18097        let mut state = sample_state();
18098        // Player is at (128, 128) per sample_state(). One chest just inside
18099        // CONTAINER_RANGE_M, one clearly beyond it.
18100        state.placed_containers = vec![
18101            flatland_protocol::PlacedContainerView {
18102                id: "near".into(),
18103                template_id: "wooden_chest_small".into(),
18104                display_name: "Wooden Chest".into(),
18105                x: 130.0,
18106                y: 128.0,
18107                z: 0.0,
18108                locked: true,
18109                accessible: true,
18110                owner_character_id: None,
18111                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
18112                lock_id: None,
18113                capacity_volume: None,
18114                item_instance_id: Some(uuid::Uuid::from_u128(1)),
18115                tile_id: None,
18116                worker_lodging_capacity: None,
18117                blocking: false,
18118                blocking_radius_m: 0.0,
18119                building_id: None,
18120            },
18121            flatland_protocol::PlacedContainerView {
18122                id: "far".into(),
18123                template_id: "wooden_chest_small".into(),
18124                display_name: "Distant Chest".into(),
18125                x: 128.0 + CONTAINER_RANGE_M + 5.0,
18126                y: 128.0,
18127                z: 0.0,
18128                locked: false,
18129                accessible: true,
18130                owner_character_id: None,
18131                contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
18132                lock_id: None,
18133                capacity_volume: None,
18134                item_instance_id: Some(uuid::Uuid::from_u128(2)),
18135                tile_id: None,
18136                worker_lodging_capacity: None,
18137                blocking: false,
18138                blocking_radius_m: 0.0,
18139                building_id: None,
18140            },
18141        ];
18142
18143        let nearby = state.nearby_containers();
18144        assert_eq!(
18145            nearby.len(),
18146            1,
18147            "far chest must not appear once out of range"
18148        );
18149        assert_eq!(nearby[0].view.id, "near");
18150        assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
18151        assert!(nearby[0].rows[0].is_chest_shell);
18152
18153        // The same chest, but locked and inaccessible (no key held), must hide
18154        // contents but still show the selectable chest shell row.
18155        state.placed_containers[0].accessible = false;
18156        let nearby = state.nearby_containers();
18157        assert_eq!(nearby.len(), 1);
18158        assert_eq!(nearby[0].rows.len(), 1);
18159        assert!(nearby[0].rows[0].is_chest_shell);
18160    }
18161
18162    #[test]
18163    fn chest_pickup_destinations_offer_person_and_worn_bag() {
18164        let mut state = sample_state();
18165        let back_id = uuid::Uuid::from_u128(42);
18166        state.worn.insert(
18167            BodySlot::Back,
18168            flatland_protocol::ItemStack {
18169                template_id: "travel_backpack".into(),
18170                quantity: 1,
18171                item_instance_id: Some(back_id),
18172                props: Default::default(),
18173                status_bindings: Vec::new(),
18174                contents: Vec::new(),
18175                display_name: Some("Travel Backpack".into()),
18176                category: Some("container".into()),
18177                base_mass: Some(2.5),
18178                base_volume: Some(12.0),
18179                capacity_volume: Some(80.0),
18180                stackable: Some(false),
18181                world_placeable: Some(false),
18182                worker_lodging_capacity: None,
18183                equip_slot: None,
18184                armor_physical: None,
18185                resists: vec![],
18186                hand_slots: None,
18187                listable: None,
18188                ..Default::default()
18189            },
18190        );
18191        let opts = state.chest_pickup_destinations("chest-1");
18192        assert!(matches!(
18193            opts.first().map(|o| &o.kind),
18194            Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
18195        ));
18196        assert!(opts.iter().any(|o| matches!(
18197            &o.kind,
18198            MoveOptionKind::PickupPlaced {
18199                nest_parent_instance_id: None,
18200                ..
18201            }
18202        )));
18203        assert!(opts.iter().any(|o| matches!(
18204            &o.kind,
18205            MoveOptionKind::PickupPlaced {
18206                nest_parent_instance_id: Some(id),
18207                ..
18208            } if *id == back_id
18209        )));
18210        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
18211    }
18212
18213    #[test]
18214    fn placed_container_public_label_hides_owner_custom_name() {
18215        let owner = uuid::Uuid::from_u128(99);
18216        let mut state = sample_state();
18217        state.character_id = Some(uuid::Uuid::from_u128(1));
18218        state.inventory_hints.insert(
18219            "wooden_chest_medium".into(),
18220            InventoryHint {
18221                display_name: "Medium Wooden Chest".into(),
18222                category: "container".into(),
18223                base_mass: None,
18224                base_volume: None,
18225                capacity_volume: None,
18226                stackable: false,
18227                listable: true,
18228                base_value_copper: None,
18229            },
18230        );
18231        let chest = flatland_protocol::PlacedContainerView {
18232            id: "c1".into(),
18233            template_id: "wooden_chest_medium".into(),
18234            display_name: "Barry's Loot #a3f2".into(),
18235            x: 128.0,
18236            y: 128.0,
18237            z: 0.0,
18238            locked: false,
18239            accessible: true,
18240            owner_character_id: Some(owner),
18241            contents: vec![],
18242            lock_id: None,
18243            capacity_volume: None,
18244            item_instance_id: None,
18245            tile_id: None,
18246            worker_lodging_capacity: None,
18247            blocking: false,
18248            blocking_radius_m: 0.0,
18249            building_id: None,
18250        };
18251        assert_eq!(
18252            state.placed_container_public_label(&chest),
18253            "Medium Wooden Chest"
18254        );
18255        state.character_id = Some(owner);
18256        assert_eq!(
18257            state.placed_container_public_label(&chest),
18258            "Barry's Loot #a3f2"
18259        );
18260    }
18261
18262    #[test]
18263    fn location_context_shows_crop_growth_percent_not_depleted() {
18264        let mut state = sample_state();
18265        state.player = state.entities.first().cloned();
18266        state.resource_nodes[0].label = "Carrot (growing)".into();
18267        state.resource_nodes[0].x = 128.2;
18268        state.resource_nodes[0].y = 128.0;
18269        state.resource_nodes[0].state = ResourceNodeState::Cooldown;
18270        state.resource_nodes[0].growth_progress = Some(0.47);
18271        let lines = state.location_context_lines();
18272        let line = lines
18273            .iter()
18274            .find(|l| l.text.contains("Carrot"))
18275            .map(|l| l.text.as_str())
18276            .unwrap_or("");
18277        assert!(
18278            line.contains("(growing, 47%)"),
18279            "expected growth percent, got: {line}"
18280        );
18281        assert!(
18282            !line.contains("depleted"),
18283            "growing crop should not show depleted: {line}"
18284        );
18285    }
18286
18287    #[test]
18288    fn resource_node_near_action_suffix_prefers_growth() {
18289        let node = ResourceNodeView {
18290            id: "crop".into(),
18291            label: "Wheat".into(),
18292            x: 0.0,
18293            y: 0.0,
18294            z: 0.0,
18295            item_template: "wheat".into(),
18296            state: ResourceNodeState::Cooldown,
18297            blocking: false,
18298            blocking_radius_m: 0.0,
18299            harvest_off: false,
18300            tile_id: None,
18301            yaw: 0.0,
18302            pitch: 0.0,
18303            roll: 0.0,
18304            draw_scale: 1.0,
18305            sprite_mode: None,
18306            growth_progress: Some(0.12),
18307            presentation_state: None,
18308            channel_start_tick: None,
18309            channel_end_tick: None,
18310            harvest_drop_templates: vec![],
18311        };
18312        assert_eq!(resource_node_near_action_suffix(&node), " (growing, 12%)");
18313    }
18314
18315    #[test]
18316    fn location_context_lists_nearby_resource_node() {
18317        let mut state = sample_state();
18318        state.player = state.entities.first().cloned();
18319        state.resource_nodes[0].x = 128.2;
18320        state.resource_nodes[0].y = 128.0;
18321        let lines = state.location_context_lines();
18322        assert!(
18323            lines
18324                .iter()
18325                .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
18326            "expected resource node in context: {:?}",
18327            lines
18328        );
18329    }
18330
18331    #[test]
18332    fn quest_board_usable_within_board_radius() {
18333        let mut state = sample_state();
18334        state.player = state.entities.first().cloned();
18335        state.interactables = vec![flatland_protocol::InteractableView {
18336            id: "board-1".into(),
18337            kind: "quest_board".into(),
18338            label: "Town Quest Board".into(),
18339            x: 130.5,
18340            y: 128.0,
18341            z: 0.0,
18342            board_id: Some("starter_town_board".into()),
18343        }];
18344        // ~2.5m away — outside the old 1.5m interact radius, inside the 3.0m board radius.
18345        assert_eq!(
18346            state.nearest_interact_target().as_deref(),
18347            Some("board-1"),
18348            "quest board should be selectable at ~2.5m"
18349        );
18350        let lines = state.location_context_lines();
18351        assert!(
18352            lines
18353                .iter()
18354                .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
18355            "HUD should advertise f when board is in range: {:?}",
18356            lines
18357        );
18358    }
18359
18360    #[test]
18361    fn quest_board_keeps_multiple_offers() {
18362        let mut state = sample_state();
18363        let offer = |id: &str, title: &str| flatland_protocol::QuestOffer {
18364            quest_id: id.into(),
18365            title: title.into(),
18366            description: format!("{title} desc"),
18367            step_count: 2,
18368        };
18369        state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18370        state.push_quest_offer(offer("daily_20695_1", "Town board: Deer threat"));
18371        state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18372        assert_eq!(state.pending_quest_offers.len(), 2);
18373        assert_eq!(
18374            state.selected_quest_offer().unwrap().quest_id,
18375            "ada_goblin_hunt"
18376        );
18377        state.move_quest_offer_selection(1);
18378        assert_eq!(
18379            state.selected_quest_offer().unwrap().quest_id,
18380            "daily_20695_1"
18381        );
18382        state.remove_quest_offer("daily_20695_1");
18383        assert_eq!(state.pending_quest_offers.len(), 1);
18384        assert!(state.show_quest_offer);
18385        state.remove_quest_offer("ada_goblin_hunt");
18386        assert!(!state.show_quest_offer);
18387        assert!(state.pending_quest_offers.is_empty());
18388    }
18389
18390    #[test]
18391    fn inventory_selectable_rows_excludes_equipped_shells_but_keeps_bag_contents() {
18392        let mut state = sample_state();
18393        state.worn.insert(
18394            BodySlot::Back,
18395            flatland_protocol::ItemStack {
18396                template_id: "travel_backpack".into(),
18397                quantity: 1,
18398                item_instance_id: Some(uuid::Uuid::from_u128(3)),
18399                props: Default::default(),
18400                status_bindings: Vec::new(),
18401                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
18402                display_name: None,
18403                category: None,
18404                base_mass: None,
18405                base_volume: None,
18406                capacity_volume: None,
18407                stackable: None,
18408                world_placeable: None,
18409                worker_lodging_capacity: None,
18410                equip_slot: None,
18411                armor_physical: None,
18412                resists: vec![],
18413                hand_slots: None,
18414                listable: None,
18415                ..Default::default()
18416            },
18417        );
18418        state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
18419        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18420            id: "chest-1".into(),
18421            template_id: "wooden_chest_small".into(),
18422            display_name: "Wooden Chest".into(),
18423            x: 129.0,
18424            y: 128.0,
18425            z: 0.0,
18426            locked: false,
18427            accessible: true,
18428            owner_character_id: None,
18429            contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
18430            lock_id: None,
18431            capacity_volume: None,
18432            item_instance_id: Some(uuid::Uuid::from_u128(4)),
18433            tile_id: None,
18434            worker_lodging_capacity: None,
18435            blocking: false,
18436            blocking_radius_m: 0.0,
18437            building_id: None,
18438        }];
18439
18440        state.inventory_tab = InventoryTab::OnPerson;
18441        let rows = state.inventory_selectable_rows();
18442        let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
18443        assert_eq!(
18444            sections,
18445            vec![
18446                InventorySection::Person, // iron_ore carried in backpack
18447                InventorySection::Person, // lumber
18448            ]
18449        );
18450        assert_eq!(rows[0].stack.template_id, "iron_ore");
18451        assert_eq!(rows[0].depth, 0);
18452        assert!(!rows[0].is_equip_shell);
18453        assert_eq!(rows[1].stack.template_id, "lumber");
18454
18455        let lines = state.inventory_browser_lines();
18456        assert!(lines.iter().any(|l| matches!(
18457            l,
18458            InventoryBrowserLine::Section(s) if s.contains("carried bags")
18459        )));
18460        assert!(lines.iter().any(|l| matches!(
18461            l,
18462            InventoryBrowserLine::Item { text, .. } if text.contains("iron_ore")
18463        )));
18464        assert!(!lines.iter().any(|l| matches!(
18465            l,
18466            InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
18467        )));
18468        assert!(!lines.iter().any(|l| matches!(
18469            l,
18470            InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
18471        )));
18472
18473        state.inventory_tab = InventoryTab::Nearby;
18474        let nearby_rows = state.inventory_selectable_rows();
18475        assert_eq!(nearby_rows.len(), 2);
18476        assert!(nearby_rows[0].is_chest_shell);
18477        assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
18478        let nearby_lines = state.inventory_browser_lines();
18479        assert!(nearby_lines.iter().any(|l| matches!(
18480            l,
18481            InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
18482        )));
18483    }
18484
18485    #[test]
18486    fn give_worker_notice_does_not_put_item_back_in_bag() {
18487        let mut state = sample_state();
18488        let id = uuid::Uuid::from_u128(42);
18489        let mut saw = flatland_protocol::ItemStack::simple("handsaw", 1);
18490        saw.item_instance_id = Some(id);
18491        saw.display_name = Some("Handsaw".into());
18492        state.sync_inventory_from_stacks(&[saw]);
18493        assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 1);
18494
18495        state.remove_carried_instance(id, None);
18496        assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 0);
18497        assert!(state.inventory_stacks.is_empty());
18498
18499        state.apply_interaction_notice(&flatland_protocol::InteractionNotice {
18500            target_id: "worker-1".into(),
18501            message: "Gave 1x Handsaw to Laborer".into(),
18502            coins_delta: 0,
18503            inventory_delta: vec![flatland_protocol::ItemStack::simple("handsaw", 1)],
18504        });
18505        assert_eq!(
18506            state.inventory.get("handsaw").copied().unwrap_or(0),
18507            0,
18508            "Gave notice must not restore the handed stack"
18509        );
18510    }
18511
18512    #[test]
18513    fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
18514        let mut state = sample_state();
18515        let back_id = uuid::Uuid::from_u128(5);
18516        state.worn.insert(
18517            BodySlot::Back,
18518            flatland_protocol::ItemStack {
18519                template_id: "travel_backpack".into(),
18520                quantity: 1,
18521                item_instance_id: Some(back_id),
18522                props: Default::default(),
18523                status_bindings: Vec::new(),
18524                contents: Vec::new(),
18525                display_name: None,
18526                category: Some("container".into()),
18527                base_mass: None,
18528                base_volume: None,
18529                capacity_volume: Some(80.0),
18530                stackable: None,
18531                world_placeable: None,
18532                worker_lodging_capacity: None,
18533                equip_slot: None,
18534                armor_physical: None,
18535                resists: vec![],
18536                hand_slots: None,
18537                listable: None,
18538                ..Default::default()
18539            },
18540        );
18541        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18542            id: "chest-1".into(),
18543            template_id: "wooden_chest_small".into(),
18544            display_name: "Wooden Chest".into(),
18545            x: 129.0,
18546            y: 128.0,
18547            z: 0.0,
18548            locked: false,
18549            accessible: true,
18550            owner_character_id: None,
18551            contents: Vec::new(),
18552            lock_id: None,
18553            capacity_volume: None,
18554            item_instance_id: Some(uuid::Uuid::from_u128(6)),
18555            tile_id: None,
18556            worker_lodging_capacity: None,
18557            blocking: false,
18558            blocking_radius_m: 0.0,
18559            building_id: None,
18560        }];
18561
18562        // Item currently sitting loose on the person (Root): backpack + nearby
18563        // chest should both be offered, plus Drop/Cancel, but not "Root" itself.
18564        let opts = state.move_destinations_for(
18565            &flatland_protocol::InventoryLocation::Root,
18566            None,
18567            None,
18568            "lumber",
18569        );
18570        assert!(!opts.iter().any(|o| matches!(
18571            &o.kind,
18572            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
18573        )));
18574        assert!(opts.iter().any(|o| matches!(
18575            &o.kind,
18576            MoveOptionKind::Move { location, parent_instance_id, .. }
18577                if *location == flatland_protocol::InventoryLocation::Worn {
18578                    slot: BodySlot::Back,
18579                } && *parent_instance_id == Some(back_id)
18580        )));
18581        assert!(opts.iter().any(|o| matches!(
18582            &o.kind,
18583            MoveOptionKind::Move { location, .. }
18584                if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
18585        )));
18586        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
18587        assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
18588
18589        // Item currently inside the worn backpack: the backpack itself must be
18590        // excluded from its own destination list (can't move an item into the
18591        // container it's already in).
18592        let from_backpack = flatland_protocol::InventoryLocation::Worn {
18593            slot: BodySlot::Back,
18594        };
18595        let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
18596        assert!(!opts.iter().any(|o| matches!(
18597            &o.kind,
18598            MoveOptionKind::Move { location, parent_instance_id, .. }
18599                if *location == from_backpack && *parent_instance_id == Some(back_id)
18600        )));
18601        assert!(opts.iter().any(|o| matches!(
18602            &o.kind,
18603            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
18604        )));
18605    }
18606
18607    #[test]
18608    fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
18609        let mut state = sample_state();
18610        // Insert out of display order — BTreeMap iteration must still yield the
18611        // canonical Head/Body/Arms/Legs/Feet/Back/Waist order regardless.
18612        state.worn.insert(
18613            BodySlot::Waist,
18614            flatland_protocol::ItemStack {
18615                template_id: "simple_belt".into(),
18616                quantity: 1,
18617                item_instance_id: Some(uuid::Uuid::from_u128(10)),
18618                props: Default::default(),
18619                status_bindings: Vec::new(),
18620                contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
18621                display_name: None,
18622                category: Some("container".into()),
18623                base_mass: None,
18624                base_volume: None,
18625                capacity_volume: None,
18626                stackable: None,
18627                world_placeable: None,
18628                worker_lodging_capacity: None,
18629                equip_slot: None,
18630                armor_physical: None,
18631                resists: vec![],
18632                hand_slots: None,
18633                listable: None,
18634                ..Default::default()
18635            },
18636        );
18637        state.worn.insert(
18638            BodySlot::Head,
18639            flatland_protocol::ItemStack {
18640                template_id: "cloth_cap".into(),
18641                quantity: 1,
18642                item_instance_id: Some(uuid::Uuid::from_u128(11)),
18643                props: Default::default(),
18644                status_bindings: Vec::new(),
18645                contents: Vec::new(),
18646                display_name: None,
18647                category: Some("armor".into()),
18648                base_mass: None,
18649                base_volume: None,
18650                capacity_volume: None,
18651                stackable: None,
18652                world_placeable: None,
18653                worker_lodging_capacity: None,
18654                equip_slot: None,
18655                armor_physical: None,
18656                resists: vec![],
18657                hand_slots: None,
18658                listable: None,
18659                ..Default::default()
18660            },
18661        );
18662        state.worn.insert(
18663            BodySlot::Back,
18664            flatland_protocol::ItemStack {
18665                template_id: "travel_backpack".into(),
18666                quantity: 1,
18667                item_instance_id: Some(uuid::Uuid::from_u128(12)),
18668                props: Default::default(),
18669                status_bindings: Vec::new(),
18670                contents: Vec::new(),
18671                display_name: None,
18672                category: Some("container".into()),
18673                base_mass: None,
18674                base_volume: None,
18675                capacity_volume: None,
18676                stackable: None,
18677                world_placeable: None,
18678                worker_lodging_capacity: None,
18679                equip_slot: None,
18680                armor_physical: None,
18681                resists: vec![],
18682                hand_slots: None,
18683                listable: None,
18684                ..Default::default()
18685            },
18686        );
18687
18688        let rows = state.worn_rows();
18689        // Head, then Back, then Waist (+ nested pouch) — enum declaration order.
18690        assert_eq!(rows.len(), 4);
18691        assert_eq!(rows[0].stack.template_id, "cloth_cap");
18692        assert!(rows[0].is_equip_shell);
18693        assert_eq!(rows[1].stack.template_id, "travel_backpack");
18694        assert!(rows[1].is_equip_shell);
18695        assert_eq!(rows[2].stack.template_id, "simple_belt");
18696        assert!(rows[2].is_equip_shell);
18697        assert_eq!(rows[3].stack.template_id, "leather_pouch");
18698        assert_eq!(rows[3].depth, 1);
18699        assert!(!rows[3].is_equip_shell);
18700    }
18701
18702    #[test]
18703    fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
18704        let mut state = sample_state();
18705        state.worn.insert(
18706            BodySlot::Waist,
18707            flatland_protocol::ItemStack {
18708                template_id: "simple_belt".into(),
18709                quantity: 1,
18710                item_instance_id: Some(uuid::Uuid::from_u128(20)),
18711                props: Default::default(),
18712                status_bindings: Vec::new(),
18713                contents: Vec::new(),
18714                display_name: Some("Simple Belt".into()),
18715                category: Some("container".into()),
18716                base_mass: None,
18717                base_volume: None,
18718                capacity_volume: None,
18719                stackable: None,
18720                world_placeable: None,
18721                worker_lodging_capacity: None,
18722                equip_slot: None,
18723                armor_physical: None,
18724                resists: vec![],
18725                hand_slots: None,
18726                listable: None,
18727                ..Default::default()
18728            },
18729        );
18730        state.worn.insert(
18731            BodySlot::Head,
18732            flatland_protocol::ItemStack {
18733                template_id: "cloth_cap".into(),
18734                quantity: 1,
18735                item_instance_id: Some(uuid::Uuid::from_u128(21)),
18736                props: Default::default(),
18737                status_bindings: Vec::new(),
18738                contents: Vec::new(),
18739                display_name: Some("Cloth Cap".into()),
18740                category: Some("armor".into()),
18741                base_mass: None,
18742                base_volume: None,
18743                capacity_volume: None,
18744                stackable: None,
18745                world_placeable: None,
18746                worker_lodging_capacity: None,
18747                equip_slot: None,
18748                armor_physical: None,
18749                resists: vec![],
18750                hand_slots: None,
18751                listable: None,
18752                ..Default::default()
18753            },
18754        );
18755
18756        let opts = state.move_destinations_for(
18757            &flatland_protocol::InventoryLocation::Root,
18758            None,
18759            None,
18760            "leather_pouch",
18761        );
18762        assert!(
18763            opts.iter().any(|o| matches!(
18764                &o.kind,
18765                MoveOptionKind::Move { location, .. }
18766                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18767            )),
18768            "belt loop must be offered when moving a pouch"
18769        );
18770        assert!(
18771            !opts.iter().any(|o| matches!(
18772                &o.kind,
18773                MoveOptionKind::Move { location, .. }
18774                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
18775            )),
18776            "armor slots can't hold other items and must not appear as move destinations"
18777        );
18778        let belt_opt = opts
18779            .iter()
18780            .find(|o| matches!(
18781                &o.kind,
18782                MoveOptionKind::Move { location, .. }
18783                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18784            ))
18785            .unwrap();
18786        assert!(belt_opt.label.contains("belt loop"));
18787
18788        let opts = state.move_destinations_for(
18789            &flatland_protocol::InventoryLocation::Root,
18790            None,
18791            None,
18792            "lumber",
18793        );
18794        assert!(
18795            !opts.iter().any(|o| o.label.contains("belt loop")),
18796            "loose materials must not target the belt shell — only nested pouches"
18797        );
18798    }
18799
18800    #[test]
18801    fn move_destinations_for_offers_dimensional_pouch_on_belt() {
18802        let mut state = sample_state();
18803        let belt_id = uuid::Uuid::from_u128(30);
18804        let pouch_id = uuid::Uuid::from_u128(31);
18805        state.worn.insert(
18806            BodySlot::Waist,
18807            flatland_protocol::ItemStack {
18808                template_id: "simple_belt".into(),
18809                quantity: 1,
18810                item_instance_id: Some(belt_id),
18811                props: Default::default(),
18812                status_bindings: Vec::new(),
18813                world_placeable: None,
18814                worker_lodging_capacity: None,
18815                equip_slot: None,
18816                armor_physical: None,
18817                resists: vec![],
18818                hand_slots: None,
18819                contents: vec![flatland_protocol::ItemStack {
18820                    template_id: "dimensional_pouch".into(),
18821                    quantity: 1,
18822                    item_instance_id: Some(pouch_id),
18823                    props: Default::default(),
18824                    status_bindings: Vec::new(),
18825                    contents: Vec::new(),
18826                    display_name: Some("Dimensional Pouch".into()),
18827                    category: Some("container".into()),
18828                    base_mass: None,
18829                    base_volume: None,
18830                    capacity_volume: Some(200.0),
18831                    stackable: None,
18832                    world_placeable: None,
18833                    worker_lodging_capacity: None,
18834                    equip_slot: None,
18835                    armor_physical: None,
18836                    resists: vec![],
18837                    hand_slots: None,
18838                    listable: None,
18839                    ..Default::default()
18840                }],
18841                display_name: Some("Simple Belt".into()),
18842                category: Some("container".into()),
18843                base_mass: None,
18844                base_volume: None,
18845                capacity_volume: None,
18846                stackable: None,
18847                listable: None,
18848                ..Default::default()
18849            },
18850        );
18851
18852        let opts = state.move_destinations_for(
18853            &flatland_protocol::InventoryLocation::Root,
18854            None,
18855            None,
18856            "iron_ore",
18857        );
18858        assert!(
18859            opts.iter().any(|o| matches!(
18860                &o.kind,
18861                MoveOptionKind::Move {
18862                    location,
18863                    parent_instance_id,
18864                    ..
18865                } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18866                    && *parent_instance_id == Some(pouch_id)
18867            )),
18868            "dimensional pouch clipped on belt must accept loose items"
18869        );
18870        assert!(
18871            opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
18872            "destination label should name the pouch"
18873        );
18874    }
18875
18876    #[test]
18877    fn container_volume_label_on_placed_chest_shell() {
18878        let mut state = sample_state();
18879        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18880            id: "chest-1".into(),
18881            template_id: "wooden_chest_small".into(),
18882            display_name: "Camp Chest".into(),
18883            x: 129.0,
18884            y: 128.0,
18885            z: 0.0,
18886            locked: false,
18887            accessible: true,
18888            owner_character_id: None,
18889            contents: vec![flatland_protocol::ItemStack {
18890                template_id: "iron_ore".into(),
18891                quantity: 2,
18892                item_instance_id: None,
18893                props: Default::default(),
18894                status_bindings: Vec::new(),
18895                contents: Vec::new(),
18896                display_name: None,
18897                category: None,
18898                base_mass: None,
18899                base_volume: Some(2.0),
18900                capacity_volume: None,
18901                stackable: None,
18902                world_placeable: None,
18903                worker_lodging_capacity: None,
18904                equip_slot: None,
18905                armor_physical: None,
18906                resists: vec![],
18907                hand_slots: None,
18908                listable: None,
18909                ..Default::default()
18910            }],
18911            lock_id: None,
18912            capacity_volume: Some(60.0),
18913            item_instance_id: Some(uuid::Uuid::from_u128(4)),
18914            tile_id: None,
18915            worker_lodging_capacity: None,
18916            blocking: false,
18917            blocking_radius_m: 0.0,
18918            building_id: None,
18919        }];
18920        let nearby = state.nearby_containers();
18921        let label = state.container_volume_label(&nearby[0].rows[0]);
18922        assert!(
18923            label.contains("vol 4/60"),
18924            "expected used/cap in label, got {label}"
18925        );
18926        assert!(
18927            label.contains("56 free"),
18928            "expected free space, got {label}"
18929        );
18930    }
18931
18932    #[test]
18933    fn key_pair_chest_label_from_placed_lock_id() {
18934        let mut state = sample_state();
18935        let owner = uuid::Uuid::from_u128(77);
18936        state.character_id = Some(owner);
18937        let lock = uuid::Uuid::from_u128(99).to_string();
18938        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18939            id: "chest-1".into(),
18940            template_id: "wooden_chest_small".into(),
18941            display_name: "Barry's Loot #a3f2".into(),
18942            x: 129.0,
18943            y: 128.0,
18944            z: 0.0,
18945            locked: true,
18946            accessible: true,
18947            owner_character_id: Some(owner),
18948            contents: Vec::new(),
18949            lock_id: Some(lock.clone()),
18950            capacity_volume: None,
18951            item_instance_id: Some(uuid::Uuid::from_u128(4)),
18952            tile_id: None,
18953            worker_lodging_capacity: None,
18954            blocking: false,
18955            blocking_radius_m: 0.0,
18956            building_id: None,
18957        }];
18958        let key_id = uuid::Uuid::from_u128(5);
18959        let key = flatland_protocol::ItemStack {
18960            template_id: KEY_TEMPLATE.into(),
18961            quantity: 1,
18962            item_instance_id: Some(key_id),
18963            props: BTreeMap::from([
18964                (PROP_OPENS_LOCK_ID.into(), lock),
18965                (
18966                    PROP_OPENS_CONTAINER_NAME.into(),
18967                    "Barry's Loot #a3f2".into(),
18968                ),
18969            ]),
18970            status_bindings: Vec::new(),
18971            contents: Vec::new(),
18972            display_name: Some("Container Key".into()),
18973            category: Some("key".into()),
18974            base_mass: None,
18975            base_volume: None,
18976            capacity_volume: None,
18977            stackable: None,
18978            world_placeable: None,
18979            worker_lodging_capacity: None,
18980            equip_slot: None,
18981            armor_physical: None,
18982            resists: vec![],
18983            hand_slots: None,
18984            listable: None,
18985            ..Default::default()
18986        };
18987        state.inventory_stacks = vec![key.clone()];
18988        assert_eq!(
18989            state.key_pair_chest_label(&key).as_deref(),
18990            Some("Barry's Loot #a3f2")
18991        );
18992        assert!(state.key_drop_blocked(&key));
18993    }
18994
18995    #[test]
18996    fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
18997        let mut state = sample_state();
18998        let lock = uuid::Uuid::from_u128(101).to_string();
18999        let key = flatland_protocol::ItemStack {
19000            template_id: KEY_TEMPLATE.into(),
19001            quantity: 1,
19002            item_instance_id: Some(uuid::Uuid::from_u128(7)),
19003            props: BTreeMap::from([
19004                (PROP_OPENS_LOCK_ID.into(), lock),
19005                (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
19006            ]),
19007            status_bindings: Vec::new(),
19008            contents: Vec::new(),
19009            display_name: None,
19010            category: Some("key".into()),
19011            base_mass: None,
19012            base_volume: None,
19013            capacity_volume: None,
19014            stackable: None,
19015            world_placeable: None,
19016            worker_lodging_capacity: None,
19017            equip_slot: None,
19018            armor_physical: None,
19019            resists: vec![],
19020            hand_slots: None,
19021            listable: None,
19022            ..Default::default()
19023        };
19024        state.placed_containers.clear();
19025        assert_eq!(
19026            state.key_pair_chest_label(&key).as_deref(),
19027            Some("Camp Stash")
19028        );
19029    }
19030
19031    #[test]
19032    fn key_drop_allowed_when_paired_chest_unlocked() {
19033        let mut state = sample_state();
19034        let lock = uuid::Uuid::from_u128(100).to_string();
19035        let key_id = uuid::Uuid::from_u128(6);
19036        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19037            id: "chest-1".into(),
19038            template_id: "wooden_chest_small".into(),
19039            display_name: "Camp Chest".into(),
19040            x: 129.0,
19041            y: 128.0,
19042            z: 0.0,
19043            locked: false,
19044            accessible: true,
19045            owner_character_id: None,
19046            contents: Vec::new(),
19047            lock_id: Some(lock.clone()),
19048            capacity_volume: None,
19049            item_instance_id: None,
19050            tile_id: None,
19051            worker_lodging_capacity: None,
19052            blocking: false,
19053            blocking_radius_m: 0.0,
19054            building_id: None,
19055        }];
19056        let key = flatland_protocol::ItemStack {
19057            template_id: KEY_TEMPLATE.into(),
19058            quantity: 1,
19059            item_instance_id: Some(key_id),
19060            props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
19061            status_bindings: Vec::new(),
19062            contents: Vec::new(),
19063            display_name: None,
19064            category: Some("key".into()),
19065            base_mass: None,
19066            base_volume: None,
19067            capacity_volume: None,
19068            stackable: None,
19069            world_placeable: None,
19070            worker_lodging_capacity: None,
19071            equip_slot: None,
19072            armor_physical: None,
19073            resists: vec![],
19074            hand_slots: None,
19075            listable: None,
19076            ..Default::default()
19077        };
19078        state.inventory_stacks = vec![key.clone()];
19079        assert!(!state.key_drop_blocked(&key));
19080        let opts = state.move_destinations_for(
19081            &flatland_protocol::InventoryLocation::Root,
19082            None,
19083            Some(key_id),
19084            KEY_TEMPLATE,
19085        );
19086        assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
19087    }
19088
19089    #[test]
19090    fn combat_hud_refreshes_progression_xp_when_entity_stale() {
19091        use flatland_protocol::{CombatHud, ProgressionCurve, ProgressionXp};
19092
19093        let mut state = sample_state();
19094        let curve = ProgressionCurve::default();
19095        let bootstrap =
19096            ProgressionXp::bootstrap_new(curve.baseline_display, curve.xp_base, curve.xp_growth);
19097        let mut fresh = bootstrap.clone();
19098        fresh.strength += 0.08;
19099        if let Some(player) = state.player.as_mut() {
19100            player.progression_xp = Some(bootstrap);
19101        }
19102
19103        let combat = CombatHud {
19104            progression_xp: Some(fresh.clone()),
19105            progression_baseline: curve.baseline_display,
19106            progression_xp_base: curve.xp_base,
19107            progression_xp_growth: curve.xp_growth,
19108            attributes: state.player.as_ref().and_then(|p| p.attributes),
19109            skills: state.player.as_ref().and_then(|p| p.skills.clone()),
19110            ..CombatHud::default()
19111        };
19112        state.apply_combat_hud(&combat);
19113
19114        let xp = state
19115            .player
19116            .as_ref()
19117            .and_then(|p| p.progression_xp.as_ref())
19118            .expect("xp");
19119        assert!((xp.strength - fresh.strength).abs() < 0.001);
19120        assert!(state.progression_curve.is_some());
19121    }
19122
19123    #[test]
19124    fn combat_hud_syncs_known_abilities_and_hotbar() {
19125        use flatland_protocol::CombatHud;
19126
19127        let mut state = sample_state();
19128        let combat = CombatHud {
19129            known_abilities: vec!["unarmed".into(), "fireball".into()],
19130            hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
19131            max_abilities_per_rotation: 4,
19132            ability_id: "short_sword_slash".into(),
19133            ..CombatHud::default()
19134        };
19135        state.apply_combat_hud(&combat);
19136
19137        assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
19138        assert_eq!(state.hotbar_ability(1), Some("fireball"));
19139        assert_eq!(state.hotbar_ability(2), None);
19140        assert_eq!(state.hotbar_ability(3), Some("unarmed"));
19141        assert_eq!(state.max_abilities_per_rotation, 4);
19142        let choices = state.loadout_ability_choices();
19143        assert!(choices.iter().any(|a| a == "short_sword_slash"));
19144        assert!(choices.iter().any(|a| a == "fireball"));
19145    }
19146
19147    #[test]
19148    fn loadout_hotbar_choices_include_inventory_consumables() {
19149        let mut state = sample_state();
19150        state.known_abilities = vec!["unarmed".into()];
19151        state.weapon_ability_id = "unarmed".into();
19152        state.inventory_stacks = vec![flatland_protocol::ItemStack {
19153            template_id: "empty_bottle".into(),
19154            quantity: 1,
19155            item_instance_id: Some(uuid::Uuid::from_u128(9)),
19156            display_name: Some("Glass Bottle of Water".into()),
19157            category: Some("container".into()),
19158            props: [
19159                ("serving".into(), "1".into()),
19160                ("liquid_vessel".into(), "1".into()),
19161                ("serving_holds".into(), "liquid".into()),
19162            ]
19163            .into_iter()
19164            .collect(),
19165            ..Default::default()
19166        }];
19167        state.inventory.insert("empty_bottle".into(), 1);
19168        state.inventory_hints.insert(
19169            "empty_bottle".into(),
19170            InventoryHint {
19171                display_name: "Glass Bottle".into(),
19172                category: "container".into(),
19173                ..Default::default()
19174            },
19175        );
19176
19177        let choices = state.loadout_hotbar_choices();
19178        assert!(choices.iter().any(|c| c.binding == "unarmed"));
19179        let water = choices
19180            .iter()
19181            .find(|c| c.binding == "item:empty_bottle")
19182            .expect("serving bottle binding");
19183        assert_eq!(water.meta.as_deref(), Some("use"));
19184        assert!(water.label.contains("Glass Bottle of Water"));
19185        assert_eq!(state.hotbar_slot_label(1), None, "unbound until set");
19186        state.hotbar = vec![None, None, None, None, Some("item:empty_bottle".into())];
19187        assert_eq!(
19188            state.hotbar_slot_label(5).as_deref(),
19189            Some("Glass Bottle×1")
19190        );
19191    }
19192
19193    #[test]
19194    fn loadout_hotbar_choices_exclude_blueprint_scrolls() {
19195        let mut state = sample_state();
19196        state.known_abilities = vec!["unarmed".into()];
19197        state.weapon_ability_id = "unarmed".into();
19198        state.inventory_stacks = vec![
19199            flatland_protocol::ItemStack {
19200                template_id: "carrot".into(),
19201                quantity: 2,
19202                display_name: Some("Wild Carrot".into()),
19203                category: Some("consumable".into()),
19204                ..Default::default()
19205            },
19206            flatland_protocol::ItemStack {
19207                template_id: "blueprint_dimensional_pouch".into(),
19208                quantity: 1,
19209                display_name: Some("Blueprint — Dimensional Pouch".into()),
19210                category: Some("consumable".into()),
19211                props: [("teaches_blueprint".into(), "craft_dimensional_pouch".into())]
19212                    .into_iter()
19213                    .collect(),
19214                ..Default::default()
19215            },
19216        ];
19217        state.inventory.insert("carrot".into(), 2);
19218        state.inventory.insert("blueprint_dimensional_pouch".into(), 1);
19219        state.inventory_hints.insert(
19220            "carrot".into(),
19221            InventoryHint {
19222                display_name: "Wild Carrot".into(),
19223                category: "consumable".into(),
19224                ..Default::default()
19225            },
19226        );
19227        state.inventory_hints.insert(
19228            "blueprint_dimensional_pouch".into(),
19229            InventoryHint {
19230                display_name: "Blueprint — Dimensional Pouch".into(),
19231                category: "consumable".into(),
19232                ..Default::default()
19233            },
19234        );
19235
19236        let choices = state.loadout_hotbar_choices();
19237        assert!(choices.iter().any(|c| c.binding == "item:carrot"));
19238        assert!(
19239            choices
19240                .iter()
19241                .all(|c| c.binding != "item:blueprint_dimensional_pouch"),
19242            "recipe scrolls must not appear on the hotbar picker: {choices:?}"
19243        );
19244    }
19245
19246    #[test]
19247    fn storage_store_options_excludes_hand_equipped() {
19248        let mut state = sample_state();
19249        let sword_id = uuid::Uuid::from_u128(11);
19250        let ore_id = uuid::Uuid::from_u128(22);
19251        state.inventory_stacks = vec![
19252            flatland_protocol::ItemStack {
19253                template_id: "short_sword".into(),
19254                quantity: 1,
19255                item_instance_id: Some(sword_id),
19256                display_name: Some("Short Sword".into()),
19257                category: Some("weapon".into()),
19258                ..Default::default()
19259            },
19260            flatland_protocol::ItemStack {
19261                template_id: "iron_ore".into(),
19262                quantity: 5,
19263                item_instance_id: Some(ore_id),
19264                display_name: Some("Iron Ore".into()),
19265                category: Some("resource".into()),
19266                ..Default::default()
19267            },
19268        ];
19269        state.mainhand_template_id = Some("short_sword".into());
19270        state.mainhand_instance_id = Some(sword_id);
19271
19272        let opts = state.storage_store_options();
19273        assert_eq!(opts.len(), 1);
19274        assert_eq!(opts[0].item_instance_id, ore_id);
19275        assert!(state.hand_equipped_instance_ids().contains(&sword_id));
19276    }
19277
19278    #[test]
19279    fn loose_consumable_move_picker_offers_use_and_storage() {
19280        let mut state = sample_state();
19281        let inst = uuid::Uuid::from_u128(77);
19282        state.inventory_stacks = vec![flatland_protocol::ItemStack {
19283            template_id: "carrot".into(),
19284            quantity: 2,
19285            item_instance_id: Some(inst),
19286            props: Default::default(),
19287            status_bindings: Vec::new(),
19288            contents: Vec::new(),
19289            display_name: Some("Wild Carrot".into()),
19290            category: Some("consumable".into()),
19291            base_mass: None,
19292            base_volume: None,
19293            capacity_volume: None,
19294            stackable: Some(true),
19295            world_placeable: None,
19296            worker_lodging_capacity: None,
19297            equip_slot: None,
19298            armor_physical: None,
19299            resists: vec![],
19300            hand_slots: None,
19301            listable: None,
19302            ..Default::default()
19303        }];
19304        state.inventory_hints.insert(
19305            "carrot".into(),
19306            InventoryHint {
19307                display_name: "Wild Carrot".into(),
19308                category: "consumable".into(),
19309                base_mass: Some(0.15),
19310                base_volume: Some(0.3),
19311                capacity_volume: None,
19312                stackable: true,
19313                listable: true,
19314                base_value_copper: None,
19315            },
19316        );
19317        state.show_inventory_menu = true;
19318        state.inventory_menu_index = 0;
19319
19320        let row = state.inventory_selected_row().expect("carrot row");
19321        let mut options = state.move_destinations_for(
19322            &row.from,
19323            row.from_parent_instance_id,
19324            row.stack.item_instance_id,
19325            &row.stack.template_id,
19326        );
19327        if row.from == flatland_protocol::InventoryLocation::Root
19328            && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
19329        {
19330            options.insert(
19331                0,
19332                MoveOption {
19333                    label: "Use (eat / drink)".into(),
19334                    kind: MoveOptionKind::Use,
19335                },
19336            );
19337        }
19338
19339        assert_eq!(
19340            options.first().map(|o| &o.label),
19341            Some(&"Use (eat / drink)".into())
19342        );
19343        assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
19344        assert!(options
19345            .iter()
19346            .any(|o| matches!(o.kind, MoveOptionKind::Drop)));
19347    }
19348
19349    #[test]
19350    fn inventory_category_group_order_is_stable() {
19351        assert_eq!(inventory_category_group("weapon").0, "Weapons");
19352        assert_eq!(inventory_category_group("armor").0, "Armor");
19353        assert_eq!(inventory_category_group("consumable").0, "Consumables");
19354        assert_eq!(inventory_category_group("liquid").0, "Consumables");
19355        assert_eq!(inventory_category_group("resource").0, "Resources");
19356        assert_eq!(inventory_category_group("container").0, "Containers");
19357        assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
19358        assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
19359    }
19360
19361    #[test]
19362    fn page_list_index_clamps_without_wrap() {
19363        assert_eq!(page_list_index(0, -1, 25), 0);
19364        assert_eq!(page_list_index(0, 1, 25), 10);
19365        assert_eq!(page_list_index(12, 1, 25), 22);
19366        assert_eq!(page_list_index(22, 1, 25), 24);
19367        assert_eq!(page_list_index(5, 1, 0), 0);
19368        assert_eq!(page_list_index(3, -1, 8), 0);
19369    }
19370
19371    #[test]
19372    fn inventory_filter_hides_non_matching_person_items() {
19373        let mut state = sample_state();
19374        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
19375        sword.display_name = Some("Iron Sword".into());
19376        sword.category = Some("weapon".into());
19377        let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
19378        herb.display_name = Some("Wild Herb".into());
19379        herb.category = Some("consumable".into());
19380        state.inventory_stacks = vec![sword, herb];
19381        state.inventory_tab = InventoryTab::OnPerson;
19382        state.inventory_filter = "sword".into();
19383
19384        let rows = state.inventory_selectable_rows();
19385        assert_eq!(rows.len(), 1);
19386        assert_eq!(rows[0].stack.template_id, "iron_sword");
19387
19388        let lines = state.inventory_browser_lines();
19389        assert!(lines.iter().any(|l| matches!(
19390            l,
19391            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
19392        )));
19393        assert!(!lines.iter().any(|l| matches!(
19394            l,
19395            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
19396        )));
19397    }
19398
19399    #[test]
19400    fn list_filter_chars_reject_mac_arrow_glyphs() {
19401        assert!(is_list_filter_char('a'));
19402        assert!(is_list_filter_char(' '));
19403        assert!(is_list_filter_char('-'));
19404        assert!(!is_list_filter_char('\u{F700}'));
19405        assert!(!is_list_filter_char('\u{F701}'));
19406        assert!(!is_list_filter_char('\n'));
19407    }
19408
19409    #[test]
19410    fn ready_tab_keeps_in_progress_craft_after_inputs_spent() {
19411        let mut state = sample_state();
19412        state.craft_tab = CraftTab::Ready;
19413        state.blueprints = vec![BlueprintView {
19414            id: "plank".into(),
19415            label: "Plank".into(),
19416            craft_tier: 1,
19417            craft_ticks: 30,
19418            output: "wood_plank".into(),
19419            output_qty: 1,
19420            output_display_name: "Wood Plank".into(),
19421            station: None,
19422            category: None,
19423            inputs: vec![flatland_protocol::BlueprintIngredientView {
19424                template_id: "oak_log".into(),
19425                quantity: 1,
19426                consumed: true,
19427                display_name: "Oak Log".into(),
19428            }],
19429            required_tools: vec![],
19430            skill: None,
19431            failure_chance: 0.0,
19432            worker_train_copper: 0,
19433        }];
19434        // Materials already consumed at craft begin — would drop off Ready without the pin.
19435        state.inventory.clear();
19436        state.craft_channel_blueprint_id = Some("plank".into());
19437        state.timed_channel = Some(flatland_protocol::TimedChannelHud {
19438            label: "Crafting Plank".into(),
19439            channel: flatland_protocol::TimedChannelKind::Craft,
19440            ticks_remaining: 20,
19441            ticks_total: 30,
19442            ..Default::default()
19443        });
19444
19445        let idxs = state.craft_filtered_indices();
19446        assert_eq!(idxs, vec![0]);
19447        assert!(state.craft_blueprint_in_channel("plank"));
19448
19449        // Channel finished → clear pin → Ready empty.
19450        state.timed_channel = None;
19451        state.craft_channel_blueprint_id = None;
19452        assert!(state.craft_filtered_indices().is_empty());
19453    }
19454
19455    #[test]
19456    fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
19457        let mut state = sample_state();
19458        let id_a = uuid::Uuid::from_u128(0xa1);
19459        let id_b = uuid::Uuid::from_u128(0xb2);
19460        let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
19461        sword_a.display_name = Some("Iron Sword".into());
19462        sword_a.category = Some("weapon".into());
19463        sword_a.item_instance_id = Some(id_a);
19464        let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
19465        sword_b.display_name = Some("Iron Sword".into());
19466        sword_b.category = Some("weapon".into());
19467        sword_b.item_instance_id = Some(id_b);
19468        state.inventory_stacks = vec![sword_a, sword_b];
19469        state.inventory_tab = InventoryTab::OnPerson;
19470
19471        let lines = state.inventory_browser_lines();
19472        let items: Vec<_> = lines
19473            .iter()
19474            .filter_map(|l| match l {
19475                InventoryBrowserLine::Item {
19476                    title,
19477                    instance_tooltip,
19478                    ..
19479                } => Some((title.clone(), instance_tooltip.clone())),
19480                _ => None,
19481            })
19482            .collect();
19483        assert_eq!(items.len(), 2);
19484        for (title, tip) in &items {
19485            assert!(
19486                !title.contains('#'),
19487                "title should not show instance suffix: {title}"
19488            );
19489            assert!(
19490                tip.is_some(),
19491                "two identical rows should expose instance on hover"
19492            );
19493        }
19494
19495        state.inventory_stacks.pop();
19496        let lines = state.inventory_browser_lines();
19497        let one = lines.iter().find_map(|l| match l {
19498            InventoryBrowserLine::Item {
19499                title,
19500                instance_tooltip,
19501                ..
19502            } => Some((title.clone(), instance_tooltip.clone())),
19503            _ => None,
19504        });
19505        let (title, tip) = one.expect("one sword row");
19506        assert!(!title.contains('#'));
19507        assert!(tip.is_none(), "single row should not need instance tooltip");
19508    }
19509
19510    #[test]
19511    fn inventory_person_rows_group_by_category() {
19512        let mut state = sample_state();
19513        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
19514        sword.category = Some("weapon".into());
19515        sword.display_name = Some("Iron Sword".into());
19516        let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
19517        ore.category = Some("resource".into());
19518        ore.display_name = Some("Iron Ore".into());
19519        let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
19520        potion.category = Some("consumable".into());
19521        potion.display_name = Some("Health Potion".into());
19522        state.inventory_stacks = vec![ore, potion, sword];
19523        state.inventory_tab = InventoryTab::OnPerson;
19524
19525        let lines = state.inventory_browser_lines();
19526        let labels: Vec<&str> = lines
19527            .iter()
19528            .filter_map(|l| match l {
19529                InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
19530                _ => None,
19531            })
19532            .collect();
19533        assert!(
19534            labels.iter().any(|s| s.contains("Weapons")),
19535            "expected Weapons group: {labels:?}"
19536        );
19537        assert!(labels.iter().any(|s| s.contains("Consumables")));
19538        assert!(labels.iter().any(|s| s.contains("Resources")));
19539
19540        let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
19541        let consumable_pos = labels
19542            .iter()
19543            .position(|s| s.contains("Consumables"))
19544            .unwrap();
19545        let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
19546        assert!(weapon_pos < consumable_pos);
19547        assert!(consumable_pos < resource_pos);
19548    }
19549
19550    #[test]
19551    fn inventory_tab_cycle_resets_selection() {
19552        let mut state = sample_state();
19553        state.inventory_tab = InventoryTab::OnPerson;
19554        state.inventory_menu_index = 3;
19555        state.inventory_tab = state.inventory_tab.cycle(true);
19556        assert_eq!(state.inventory_tab, InventoryTab::Nearby);
19557        // Client method resets index; enum cycle alone does not — verify cycle labels.
19558        assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
19559        assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
19560        assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
19561        assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
19562    }
19563
19564    #[test]
19565    fn parse_bank_copper_amount_blank_and_zero_mean_all() {
19566        assert_eq!(parse_bank_copper_amount(""), Some(0));
19567        assert_eq!(parse_bank_copper_amount("  "), Some(0));
19568        assert_eq!(parse_bank_copper_amount("0"), Some(0));
19569        assert_eq!(parse_bank_copper_amount("250"), Some(250));
19570        assert_eq!(parse_bank_copper_amount("nope"), None);
19571    }
19572
19573    #[test]
19574    fn parse_storage_quantity_blank_and_zero_mean_all() {
19575        assert_eq!(parse_storage_quantity(""), Some(None));
19576        assert_eq!(parse_storage_quantity("  "), Some(None));
19577        assert_eq!(parse_storage_quantity("0"), Some(None));
19578        assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
19579        assert_eq!(parse_storage_quantity("nope"), None);
19580    }
19581
19582    #[test]
19583    fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
19584        assert!(worker_error_is_hud_noise("path stuck — repathing"));
19585        assert!(worker_error_is_hud_noise(
19586            "path stuck — nudged clear, repathing"
19587        ));
19588        assert!(worker_error_is_hud_noise(
19589            "returned to lodging after path failures"
19590        ));
19591        // Real problem: player may need to place lodging / fix assignment.
19592        assert!(!worker_error_is_hud_noise(
19593            "path stuck — no lodging to reset to"
19594        ));
19595        assert!(!worker_error_is_hud_noise(
19596            "cannot reach Eli — idling"
19597        ));
19598    }
19599
19600    #[test]
19601    fn leaving_building_restores_outdoor_z_bands() {
19602        use flatland_protocol::{InteriorMapView, ZPlatformView};
19603
19604        let mut state = sample_state();
19605        state.z_platforms.clear();
19606        state.z_transitions.clear();
19607        state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
19608        state.interior_map = Some(InteriorMapView {
19609            building_id: "broker_hut".into(),
19610            blueprint_id: "broker_hut".into(),
19611            background_color: "#000".into(),
19612            default_floor_color: None,
19613            floor_height_m: 3.0,
19614            z_platforms: vec![ZPlatformView {
19615                id: "floor_0".into(),
19616                z: 0.0,
19617                x0: 0.0,
19618                y0: 0.0,
19619                x1: 8.0,
19620                y1: 8.0,
19621            }],
19622            z_transitions: vec![],
19623            rooms: vec![],
19624            room_doors: vec![],
19625        });
19626        state.sync_interior_map_context();
19627        assert_eq!(
19628            state.z_platforms.len(),
19629            1,
19630            "indoors installs interior platforms"
19631        );
19632        assert!(state.z_bands_outdoor_backup.is_some());
19633
19634        state.player.as_mut().unwrap().inside_building = None;
19635        state.sync_interior_map_context();
19636        assert!(
19637            state.z_platforms.is_empty(),
19638            "leaving must restore outdoor bands (empty), not leave interior platforms"
19639        );
19640        assert!(state.z_bands_outdoor_backup.is_none());
19641        assert!(state.interior_map.is_none());
19642    }
19643
19644    #[test]
19645    fn resource_node_route_label_prefers_friendly_label_with_suffix() {
19646        let node = ResourceNodeView {
19647            id: "crop-carrot-1_copy10".into(),
19648            label: "crop-carrot-1_copy10".into(),
19649            x: 0.0,
19650            y: 0.0,
19651            z: 0.0,
19652            item_template: "carrot".into(),
19653            state: ResourceNodeState::Available,
19654            blocking: false,
19655            blocking_radius_m: 0.5,
19656            harvest_off: false,
19657            tile_id: None,
19658            yaw: 0.0,
19659            pitch: 0.0,
19660            roll: 0.0,
19661            draw_scale: 1.0,
19662            sprite_mode: None,
19663            growth_progress: None,
19664            presentation_state: None,
19665            channel_start_tick: None,
19666            channel_end_tick: None,
19667            harvest_drop_templates: vec![],
19668        };
19669        let label = super::resource_node_route_label(&node);
19670        assert!(label.starts_with("Carrot ("), "got {label}");
19671        assert!(label.ends_with(')'), "got {label}");
19672
19673        let mut named = node;
19674        named.label = "Sweet Pad".into();
19675        named.id = "crop-carrot-a3f2b1c0".into();
19676        assert_eq!(super::resource_node_route_label(&named), "Sweet Pad (b1c0)");
19677    }
19678
19679    #[test]
19680    fn plot_public_label_uses_owner_zone_and_label() {
19681        let plot = flatland_protocol::PropertyPlotView {
19682            plot_id: uuid::Uuid::nil(),
19683            property_zone_id: "zone_a".into(),
19684            zone_label: Some("Starter Town East 1".into()),
19685            deed_instance_id: uuid::Uuid::nil(),
19686            x0: 0.0,
19687            y0: 0.0,
19688            x1: 4.0,
19689            y1: 4.0,
19690            upkeep_copper_per_day: 1,
19691            arrears_days: 0,
19692            is_mine: true,
19693            may_farm: true,
19694            purchase_basis_copper: 0,
19695            farm_public: false,
19696            public_tax_discount_bps: 0,
19697            farm_allow: vec![],
19698            owner_character_id: None,
19699            owner_label: Some("Madsin".into()),
19700            building_id: None,
19701            plot_code: "xyz1234a".into(),
19702            label: "Food Pad".into(),
19703        };
19704        assert_eq!(
19705            super::plot_public_label(&plot),
19706            "Madsin — Starter Town East 1 — Food Pad"
19707        );
19708    }
19709
19710    #[test]
19711    fn plot_public_label_uses_size_when_label_and_code_blank() {
19712        let plot = flatland_protocol::PropertyPlotView {
19713            plot_id: uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap(),
19714            property_zone_id: String::new(),
19715            zone_label: None,
19716            deed_instance_id: uuid::Uuid::nil(),
19717            x0: 10.0,
19718            y0: 20.0,
19719            x1: 18.0,
19720            y1: 28.0,
19721            upkeep_copper_per_day: 1,
19722            arrears_days: 0,
19723            is_mine: true,
19724            may_farm: true,
19725            purchase_basis_copper: 0,
19726            farm_public: false,
19727            public_tax_discount_bps: 0,
19728            farm_allow: vec![],
19729            owner_character_id: None,
19730            owner_label: None,
19731            building_id: None,
19732            plot_code: String::new(),
19733            label: String::new(),
19734        };
19735        assert_eq!(super::plot_public_label(&plot), "Homestead — Plot (8×8 m)");
19736        assert!(!super::plot_public_label(&plot).contains("19fe35f"));
19737    }
19738
19739    #[test]
19740    fn plot_stop_label_prefers_view_over_hex() {
19741        let plot_id = uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap();
19742        let plot = flatland_protocol::PropertyPlotView {
19743            plot_id,
19744            property_zone_id: "zone_a".into(),
19745            zone_label: Some("Starter Town East".into()),
19746            deed_instance_id: uuid::Uuid::nil(),
19747            x0: 0.0,
19748            y0: 0.0,
19749            x1: 4.0,
19750            y1: 4.0,
19751            upkeep_copper_per_day: 1,
19752            arrears_days: 0,
19753            is_mine: true,
19754            may_farm: true,
19755            purchase_basis_copper: 0,
19756            farm_public: false,
19757            public_tax_discount_bps: 0,
19758            farm_allow: vec![],
19759            owner_character_id: None,
19760            owner_label: Some("Madsin".into()),
19761            building_id: None,
19762            plot_code: "xyz1234a".into(),
19763            label: "Food Pad".into(),
19764        };
19765        assert_eq!(
19766            super::plot_stop_label(&[plot.clone()], plot_id),
19767            "Madsin — Starter Town East — Food Pad"
19768        );
19769        let missing = uuid::Uuid::parse_str("1a001590-0000-4000-8000-000000000002").unwrap();
19770        assert_eq!(super::plot_stop_label(&[plot], missing), "plot 1a001590");
19771    }
19772}