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    pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
6268        self.quest_log
6269            .iter()
6270            .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
6271            .or_else(|| {
6272                self.quest_log
6273                    .iter()
6274                    .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
6275            })
6276    }
6277
6278    /// True when a lockable exterior door is within interact range (prefer over loadout `l`).
6279    pub fn nearby_lockable_door(&self) -> bool {
6280        let (px, py) = self.player_position();
6281        self.doors
6282            .iter()
6283            .any(|d| d.lock_id.is_some() && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M)
6284    }
6285
6286    /// True when an open player-house door is in range for Enter-to-go-inside.
6287    pub fn nearby_open_player_door(&self) -> bool {
6288        if self.effective_inside_building().is_some() {
6289            return false;
6290        }
6291        let (px, py) = self.player_position();
6292        self.doors.iter().any(|d| {
6293            if !d.open || d.locked {
6294                return false;
6295            }
6296            let player_house = self
6297                .buildings
6298                .iter()
6299                .find(|b| b.id == d.building_id)
6300                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6301            player_house && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M
6302        })
6303    }
6304
6305    /// True when inside a player house near the exterior portal (Enter exits freely).
6306    pub fn nearby_player_exit_door(&self) -> bool {
6307        let Some(bid) = self.effective_inside_building() else {
6308            return false;
6309        };
6310        let (px, py) = self.player_position();
6311        self.doors.iter().any(|d| {
6312            if d.building_id != bid || d.portal.is_none() {
6313                return false;
6314            }
6315            let player_house = self
6316                .buildings
6317                .iter()
6318                .find(|b| b.id == d.building_id)
6319                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6320            player_house && (d.x - px).hypot(d.y - py) <= 1.5
6321        })
6322    }
6323
6324    /// Nearest interactable target for `f` (doors, NPCs, quest boards).
6325    pub fn nearest_interact_target(&self) -> Option<String> {
6326        let (px, py) = self.player_position();
6327        let inside = self.effective_inside_building();
6328
6329        #[derive(Clone, Copy, PartialEq, Eq)]
6330        enum Kind {
6331            Player,
6332            Npc,
6333            HiredWorker,
6334            QuestBoard,
6335            ExitDoor,
6336            EnterDoor,
6337        }
6338
6339        fn kind_class(kind: Kind) -> u8 {
6340            match kind {
6341                Kind::EnterDoor => 0,
6342                Kind::QuestBoard => 1,
6343                Kind::Player | Kind::Npc => 2,
6344                Kind::ExitDoor => 3,
6345                Kind::HiredWorker => 4,
6346            }
6347        }
6348
6349        fn kind_priority(kind: Kind) -> u8 {
6350            match kind {
6351                Kind::EnterDoor => 0,
6352                Kind::QuestBoard => 1,
6353                Kind::Player | Kind::Npc => 2,
6354                Kind::ExitDoor => 3,
6355                Kind::HiredWorker => 4,
6356            }
6357        }
6358
6359        let mut best: Option<(f32, Kind, String)> = None;
6360
6361        let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
6362            if dist > max {
6363                return;
6364            }
6365            let replace = match best {
6366                None => true,
6367                Some((_, bk, _)) if kind_class(kind) < kind_class(bk) => true,
6368                Some((bd, bk, _))
6369                    if kind_class(kind) == kind_class(bk) && dist < bd - 0.05 =>
6370                {
6371                    true
6372                }
6373                Some((bd, bk, _))
6374                    if kind_class(kind) == kind_class(bk) && (dist - bd).abs() <= 0.05 =>
6375                {
6376                    kind_priority(kind) < kind_priority(bk)
6377                }
6378                _ => false,
6379            };
6380            if replace {
6381                best = Some((dist, kind, id));
6382            }
6383        };
6384
6385        for npc in &self.npcs {
6386            consider(
6387                distance(px, py, npc.x, npc.y),
6388                INTERACTION_RADIUS_M,
6389                Kind::Npc,
6390                npc.id.clone(),
6391            );
6392        }
6393
6394        for worker in &self.hired_workers {
6395            consider(
6396                distance(px, py, worker.x, worker.y),
6397                INTERACTION_RADIUS_M,
6398                Kind::HiredWorker,
6399                worker.instance_id.clone(),
6400            );
6401        }
6402
6403        for entity in &self.entities {
6404            if entity.id == self.entity_id
6405                || entity.vitals.is_none()
6406                || entity.label.trim().is_empty()
6407            {
6408                continue;
6409            }
6410            // Hired workers are NPCs with their own UI — not Whisper/Trade peers.
6411            if self.hired_workers.iter().any(|w| w.entity_id == entity.id) {
6412                continue;
6413            }
6414            consider(
6415                distance(
6416                    px,
6417                    py,
6418                    entity.transform.position.x,
6419                    entity.transform.position.y,
6420                ),
6421                INTERACTION_RADIUS_M,
6422                Kind::Player,
6423                entity.id.to_string(),
6424            );
6425        }
6426
6427        for door in &self.doors {
6428            if let Some(ref bid) = inside {
6429                if door.building_id != *bid {
6430                    continue;
6431                }
6432                let is_exit = door.portal.is_some();
6433                let max = if is_exit {
6434                    INTERACTION_RADIUS_M
6435                } else {
6436                    DOOR_INTERACTION_RADIUS_M
6437                };
6438                let kind = if is_exit {
6439                    Kind::ExitDoor
6440                } else {
6441                    Kind::EnterDoor
6442                };
6443                consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
6444                continue;
6445            }
6446            consider(
6447                distance(px, py, door.x, door.y),
6448                DOOR_INTERACTION_RADIUS_M,
6449                Kind::EnterDoor,
6450                door.id.clone(),
6451            );
6452        }
6453
6454        if inside.is_none() {
6455            for inter in &self.interactables {
6456                if inter.kind == "quest_board" {
6457                    consider(
6458                        distance(px, py, inter.x, inter.y),
6459                        QUEST_BOARD_INTERACTION_RADIUS_M,
6460                        Kind::QuestBoard,
6461                        inter.id.clone(),
6462                    );
6463                }
6464            }
6465        }
6466
6467        best.map(|(_, _, id)| id)
6468    }
6469
6470    /// Nearest quest board and distance (any distance), for out-of-range feedback.
6471    pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
6472        if self.effective_inside_building().is_some() {
6473            return None;
6474        }
6475        let (px, py) = self.player_position();
6476        self.interactables
6477            .iter()
6478            .filter(|i| i.kind == "quest_board")
6479            .map(|i| {
6480                let label = if i.label.is_empty() {
6481                    "Quest board".to_string()
6482                } else {
6483                    i.label.clone()
6484                };
6485                (label, distance(px, py, i.x, i.y))
6486            })
6487            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
6488    }
6489
6490    /// Human-readable template name from inventory hints, then the synced item catalog.
6491    pub fn template_display_name(&self, template_id: &str) -> String {
6492        if let Some(name) = self
6493            .inventory_hints
6494            .get(template_id)
6495            .map(|h| h.display_name.clone())
6496            .filter(|n| !n.is_empty())
6497        {
6498            return name;
6499        }
6500        if let Some(entry) = self.item_catalog.get(template_id) {
6501            if !entry.display_name.trim().is_empty() {
6502                return entry.display_name.clone();
6503            }
6504        }
6505        humanize_template_id(template_id)
6506    }
6507
6508    pub fn catalog_entry(&self, template_id: &str) -> Option<&ItemCatalogEntryView> {
6509        self.item_catalog.get(template_id)
6510    }
6511
6512    /// Prefer blueprint-view display name, then inventory hints / humanized id.
6513    pub fn blueprint_item_label(&self, template_id: &str, display_name: &str) -> String {
6514        if !display_name.is_empty() {
6515            display_name.to_string()
6516        } else {
6517            self.template_display_name(template_id)
6518        }
6519    }
6520
6521    pub fn blueprint_output_label(&self, blueprint: &BlueprintView) -> String {
6522        self.blueprint_item_label(&blueprint.output, &blueprint.output_display_name)
6523    }
6524
6525    pub fn blueprint_ingredient_label(
6526        &self,
6527        input: &flatland_protocol::BlueprintIngredientView,
6528    ) -> String {
6529        self.blueprint_item_label(&input.template_id, &input.display_name)
6530    }
6531
6532    pub fn blueprint_tool_label(&self, tool: &flatland_protocol::ToolRequirementView) -> String {
6533        self.blueprint_item_label(&tool.item, &tool.display_name)
6534    }
6535
6536    /// Harvest picker rows for the worker route editor — distance/sort from rest bed, not player.
6537    pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
6538        use crate::worker_route_editor::{
6539            node_candidates, node_candidates_stable, route_editor_lodging_anchor,
6540        };
6541        let lodging = self
6542            .worker_route_editor
6543            .as_ref()
6544            .and_then(|ed| ed.lodging_container_id.as_deref());
6545        match route_editor_lodging_anchor(lodging, &self.placed_containers) {
6546            Some((ax, ay)) => node_candidates(&self.resource_nodes, ax, ay),
6547            None => node_candidates_stable(&self.resource_nodes),
6548        }
6549    }
6550
6551    pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
6552        if dist_m.is_nan() {
6553            return "—".into();
6554        }
6555        let from_bed = self
6556            .worker_route_editor
6557            .as_ref()
6558            .and_then(|ed| ed.lodging_container_id.as_deref())
6559            .and_then(|id| {
6560                self.placed_containers
6561                    .iter()
6562                    .find(|c| c.id == id)
6563                    .map(|c| c.display_name.clone())
6564            });
6565        match from_bed {
6566            Some(bed) => format!("{dist_m:.0}m from {bed}"),
6567            None => format!("{dist_m:.0}m"),
6568        }
6569    }
6570
6571    /// Chest label for the location panel — generic type unless this player owns it.
6572    pub fn placed_container_public_label(
6573        &self,
6574        c: &flatland_protocol::PlacedContainerView,
6575    ) -> String {
6576        let is_owner = match (self.character_id, c.owner_character_id) {
6577            (Some(me), Some(owner)) => me == owner,
6578            _ => false,
6579        };
6580        if is_owner {
6581            c.display_name.clone()
6582        } else {
6583            self.template_display_name(&c.template_id)
6584        }
6585    }
6586
6587    /// Keys on person and stowed on the keychain for the keychain overlay.
6588    pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
6589        let mut out = Vec::new();
6590        for stack in &self.inventory_stacks {
6591            if stack.template_id == KEY_TEMPLATE {
6592                out.push(KeychainEntry {
6593                    stack: stack.clone(),
6594                    stowed: false,
6595                });
6596            }
6597        }
6598        for stack in &self.keychain_stacks {
6599            if stack.template_id == KEY_TEMPLATE {
6600                out.push(KeychainEntry {
6601                    stack: stack.clone(),
6602                    stowed: true,
6603                });
6604            }
6605        }
6606        out
6607    }
6608
6609    /// Display name of the chest a `container_key` opens, when known on the client.
6610    pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
6611        if stack.template_id != KEY_TEMPLATE {
6612            return None;
6613        }
6614        if let Some(name) = stack
6615            .props
6616            .get(PROP_OPENS_CONTAINER_NAME)
6617            .filter(|n| !n.is_empty())
6618        {
6619            return Some(name.clone());
6620        }
6621        let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
6622        self.container_name_for_lock_id(opens)
6623    }
6624
6625    /// Keys always show the catalog name — never a chest rename or stray `custom_name`.
6626    pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
6627        if stack.template_id == KEY_TEMPLATE {
6628            self.template_display_name(KEY_TEMPLATE)
6629        } else {
6630            stack
6631                .display_name
6632                .clone()
6633                .unwrap_or_else(|| stack.template_id.clone())
6634        }
6635    }
6636
6637    /// Hint suffix for a key row (`[key for …]` or `[key — unpaired]`).
6638    pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
6639        if stack.template_id != KEY_TEMPLATE {
6640            return String::new();
6641        }
6642        match self.key_pair_chest_label(stack) {
6643            Some(chest) if self.key_drop_blocked(stack) => {
6644                format!(" [key for {chest} — can't drop while locked]")
6645            }
6646            Some(chest) => format!(" [key for {chest}]"),
6647            None => " [key — unpaired]".into(),
6648        }
6649    }
6650
6651    /// Resolve a lock id to a container label (placed chest or one still on your person).
6652    pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
6653        for c in &self.placed_containers {
6654            if c.lock_id.as_deref() == Some(lock) {
6655                return Some(c.display_name.clone());
6656            }
6657        }
6658        Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
6659            self.worn
6660                .values()
6661                .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
6662        })
6663    }
6664
6665    /// Keys cannot be dropped while their paired chest is locked.
6666    pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
6667        if stack.template_id != KEY_TEMPLATE {
6668            return false;
6669        }
6670        let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
6671            return false;
6672        };
6673        for c in &self.placed_containers {
6674            if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
6675                return true;
6676            }
6677        }
6678        if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
6679            return true;
6680        }
6681        self.worn
6682            .values()
6683            .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
6684    }
6685
6686    /// Property deeds cannot be dropped or destroyed (store or trade only).
6687    pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
6688        stack.template_id == PROPERTY_DEED_TEMPLATE
6689    }
6690
6691    pub fn is_property_deed_template(template_id: &str) -> bool {
6692        template_id == PROPERTY_DEED_TEMPLATE
6693    }
6694
6695    pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
6696        stack
6697            .props
6698            .get("plot_id")
6699            .and_then(|s| uuid::Uuid::parse_str(s).ok())
6700    }
6701
6702    /// World position (cell center) of the plot cell the player is standing on, if tillable.
6703    pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
6704        let (px, py) = self.player_position();
6705        let (cx, cy) = self.farm_plot_cell_under_player()?;
6706        let tx = cx as f32 + 0.5;
6707        let ty = cy as f32 + 0.5;
6708        if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
6709            return None;
6710        }
6711        let kind = self.terrain_at(tx, ty).or_else(|| self.terrain_at(px, py));
6712        if kind == Some(TerrainKindView::Tilled) {
6713            return None;
6714        }
6715        if matches!(
6716            kind,
6717            Some(TerrainKindView::ShallowWater)
6718                | Some(TerrainKindView::DeepWater)
6719                | Some(TerrainKindView::Rock)
6720        ) {
6721            return None;
6722        }
6723        Some((tx, ty))
6724    }
6725
6726    fn container_name_in_stacks(
6727        stacks: &[flatland_protocol::ItemStack],
6728        lock: &str,
6729    ) -> Option<String> {
6730        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
6731            for s in stacks {
6732                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
6733                    return Some(GameState::stack_container_label(s));
6734                }
6735                if let Some(name) = walk(&s.contents, lock) {
6736                    return Some(name);
6737                }
6738            }
6739            None
6740        }
6741        walk(stacks, lock)
6742    }
6743
6744    fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
6745        stack
6746            .props
6747            .get(PROP_CUSTOM_NAME)
6748            .cloned()
6749            .or_else(|| stack.display_name.clone())
6750            .unwrap_or_else(|| stack.template_id.clone())
6751    }
6752
6753    fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6754        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6755            for s in stacks {
6756                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
6757                    return true;
6758                }
6759                if walk(&s.contents, lock) {
6760                    return true;
6761                }
6762            }
6763            false
6764        }
6765        walk(stacks, lock)
6766    }
6767
6768    fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
6769        if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
6770            return Some(stack.clone());
6771        }
6772        for worn in self.worn.values() {
6773            if worn.item_instance_id == Some(instance_id) {
6774                return Some(worn.clone());
6775            }
6776            if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
6777                return Some(stack.clone());
6778            }
6779        }
6780        None
6781    }
6782
6783    /// Highest-`z_order` property zone covering `(x, y)`.
6784    pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
6785        self.property_zones
6786            .iter()
6787            .enumerate()
6788            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
6789            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
6790            .map(|(_, z)| z)
6791    }
6792
6793    /// Highest-`z_order` tax zone covering `(x, y)`.
6794    pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
6795        self.tax_zones
6796            .iter()
6797            .enumerate()
6798            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
6799            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
6800            .map(|(_, z)| z)
6801    }
6802
6803    /// Max tax `rate_bps` across 1 m cell centers of the claim rect (matches server).
6804    pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
6805        let mut max_bps = 0u32;
6806        let mut y = y0 + 0.5;
6807        while y < y1 {
6808            let mut x = x0 + 0.5;
6809            while x < x1 {
6810                if let Some(tz) = self.tax_zone_at(x, y) {
6811                    max_bps = max_bps.max(tz.rate_bps);
6812                }
6813                x += 1.0;
6814            }
6815            y += 1.0;
6816        }
6817        max_bps
6818    }
6819
6820    /// Claim footprint as half-open world AABB `(x0, y0, x1, y1)`.
6821    pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
6822        let mode = self.claim_mode.as_ref()?;
6823        let w = mode.width_m.max(1) as f32;
6824        let h = mode.height_m.max(1) as f32;
6825        Some((
6826            mode.anchor_x,
6827            mode.anchor_y,
6828            mode.anchor_x + w,
6829            mode.anchor_y + h,
6830        ))
6831    }
6832
6833    /// Relocate ghost as half-open 1×1 world AABB `(x0, y0, x1, y1)`.
6834    pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
6835        let mode = self.relocate_mode.as_ref()?;
6836        let x0 = mode.cursor_x.floor();
6837        let y0 = mode.cursor_y.floor();
6838        Some((x0, y0, x0 + 1.0, y0 + 1.0))
6839    }
6840
6841    /// Client quote matching server `quote_plot`:
6842    /// `(purchase, upkeep, area, premium, can_afford, valid, reason)`.
6843    pub fn claim_quote(&self) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
6844        let mode = self.claim_mode.as_ref()?;
6845        let zone = self.property_zones.iter().find(|z| z.id == mode.zone_id)?;
6846        let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
6847        let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
6848        let zone_area = zone_view_area_m2(zone).max(1.0);
6849        let area_frac = (area / zone_area).clamp(0.0, 1.0);
6850        let weight = self
6851            .property_plot_settings
6852            .as_ref()
6853            .map(|s| s.tax_premium_weight)
6854            .unwrap_or(0.5)
6855            .max(0.0);
6856        let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
6857        let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
6858        let purchase = ((zone.crown_price_copper as f64) * (area_frac as f64) * (premium as f64))
6859            .ceil()
6860            .max(0.0) as u64;
6861        let upkeep = if zone.upkeep_copper_per_day == 0 {
6862            0
6863        } else {
6864            ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
6865                .ceil()
6866                .max(1.0) as u64
6867        };
6868        let copper = crate::currency::copper_from_counts(&self.inventory);
6869        let can_afford = copper >= purchase;
6870        let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
6871        Some((purchase, upkeep, area, premium, can_afford, valid, reason))
6872    }
6873
6874    fn validate_claim_footprint(
6875        &self,
6876        zone: &flatland_protocol::PropertyZoneView,
6877        x0: f32,
6878        y0: f32,
6879        x1: f32,
6880        y1: f32,
6881        area: f32,
6882    ) -> (bool, String) {
6883        let min_area = self
6884            .property_plot_settings
6885            .as_ref()
6886            .map(|s| s.min_plot_area_m2)
6887            .unwrap_or(4.0);
6888        if area + f32::EPSILON < min_area {
6889            return (false, "plot too small".into());
6890        }
6891        if zone.max_area_m2.is_some_and(|m| area > m) {
6892            return (false, "plot exceeds max area".into());
6893        }
6894        if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
6895            return (false, "plot must lie inside the property zone".into());
6896        }
6897        if self
6898            .property_plots
6899            .iter()
6900            .any(|p| rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1))
6901        {
6902            return (false, "plot overlaps an existing claim".into());
6903        }
6904        (true, String::new())
6905    }
6906
6907    /// Standing in a property zone on a free cell (not already claimed).
6908    pub fn free_property_zone_under_player(&self) -> Option<&flatland_protocol::PropertyZoneView> {
6909        let (px, py) = self.player_position();
6910        let zone = self.property_zone_at(px, py)?;
6911        if self.property_plots.iter().any(|p| point_in_plot(px, py, p)) {
6912            return None;
6913        }
6914        Some(zone)
6915    }
6916
6917    /// Own plot covering the player (`is_mine`).
6918    pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
6919        let (px, py) = self.player_position();
6920        self.property_plots
6921            .iter()
6922            .find(|p| p.is_mine && point_in_plot(px, py, p))
6923    }
6924
6925    /// Plot under the player that they may farm (deed, grant, or public).
6926    pub fn farmable_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 || p.may_farm) && point_in_plot(px, py, p))
6931    }
6932
6933    /// Grid cell under the player on a farmable plot (`[cx,cx+1)` × `[cy,cy+1)` contains feet).
6934    pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
6935        if self.farmable_plot_under_player().is_none() {
6936            return None;
6937        }
6938        let (px, py) = self.player_position();
6939        Some((px.floor() as i32, py.floor() as i32))
6940    }
6941
6942    fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
6943        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6944        self.resource_nodes.iter().any(|n| {
6945            let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
6946            ncx == cx && ncy == cy || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
6947        })
6948    }
6949
6950    fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
6951        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6952        let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
6953            || self
6954                .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
6955                .is_some_and(|z| z.kind == TerrainKindView::Tilled);
6956        if !tilled {
6957            return false;
6958        }
6959        !self.resource_node_occupies_farm_cell(cx, cy)
6960    }
6961
6962    /// Empty tilled soil on the cell the player is standing on (no crop node).
6963    pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
6964        let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
6965            return false;
6966        };
6967        self.free_tilled_plant_slot_at(cx, cy)
6968    }
6969
6970    /// True when a free tilled cell (no resource node) is within interact range.
6971    pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
6972        let (px, py) = self.player_position();
6973        for dy in -2..=2 {
6974            for dx in -2..=2 {
6975                let cx = px.floor() as i32 + dx;
6976                let cy = py.floor() as i32 + dy;
6977                let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6978                if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
6979                    continue;
6980                }
6981                if self.free_tilled_plant_slot_at(cx, cy) {
6982                    return true;
6983                }
6984            }
6985        }
6986        false
6987    }
6988
6989    fn stack_is_farm_seed(&self, stack: &flatland_protocol::ItemStack) -> bool {
6990        if stack.quantity == 0 {
6991            return false;
6992        }
6993        if stack.props.contains_key("seed_for") {
6994            return true;
6995        }
6996        if let Some(entry) = self.item_catalog.get(&stack.template_id) {
6997            if entry.is_farm_seed() {
6998                return true;
6999            }
7000        }
7001        stack.template_id.ends_with("_seed")
7002    }
7003
7004    /// Farm seed stacks in inventory (template_id, qty, display label), sorted by label.
7005    pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
7006        let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
7007        fn walk(
7008            stacks: &[flatland_protocol::ItemStack],
7009            state: &GameState,
7010            counts: &mut std::collections::HashMap<String, u32>,
7011        ) {
7012            for s in stacks {
7013                if state.stack_is_farm_seed(s) {
7014                    *counts.entry(s.template_id.clone()).or_default() += s.quantity;
7015                }
7016                walk(&s.contents, state, counts);
7017            }
7018        }
7019        walk(&self.inventory_stacks, self, &mut counts);
7020        for worn in self.worn.values() {
7021            walk(std::slice::from_ref(worn), self, &mut counts);
7022        }
7023        let mut out: Vec<_> = counts
7024            .into_iter()
7025            .map(|(template_id, quantity)| {
7026                let label = self.template_display_name(&template_id);
7027                (template_id, quantity, label)
7028            })
7029            .collect();
7030        out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
7031        out
7032    }
7033
7034    /// First farm seed template in inventory (root + nested bags).
7035    pub fn first_farm_seed_template(&self) -> Option<String> {
7036        self.farm_seed_entries()
7037            .into_iter()
7038            .next()
7039            .map(|(id, _, _)| id)
7040    }
7041
7042    pub fn clamp_plant_menu(&mut self) {
7043        let n = self.farm_seed_entries().len();
7044        if n == 0 {
7045            self.plant_menu_index = 0;
7046            self.plant_quantity = 1;
7047            return;
7048        }
7049        self.plant_menu_index = self.plant_menu_index.min(n - 1);
7050        let max_qty = self
7051            .farm_seed_entries()
7052            .get(self.plant_menu_index)
7053            .map(|(_, q, _)| *q)
7054            .unwrap_or(1)
7055            .max(1);
7056        self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
7057    }
7058
7059    pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
7060        let entries = self.farm_seed_entries();
7061        let (id, max, label) = entries.get(self.plant_menu_index)?;
7062        let qty = self.plant_quantity.min(*max).max(1);
7063        Some((id.clone(), qty, label.clone()))
7064    }
7065
7066    /// Terrain, interactables, and map objects near the player for the HUD location panel.
7067    pub fn location_context_lines(&self) -> Vec<ContextLine> {
7068        let (px, py) = self.player_position();
7069        let inside = self.effective_inside_building();
7070        let mut lines = Vec::new();
7071
7072        if let Some(kind) = self.terrain_at(px, py) {
7073            lines.push(ContextLine {
7074                on_top: true,
7075                text: format!("Terrain: {}", terrain_kind_label(kind)),
7076            });
7077        }
7078
7079        if let Some(id) = inside.as_ref() {
7080            if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
7081                lines.push(ContextLine {
7082                    on_top: true,
7083                    text: format!("Inside: {}", b.label),
7084                });
7085            }
7086        }
7087
7088        let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
7089
7090        for node in &self.resource_nodes {
7091            if node.id.starts_with("preview:") {
7092                continue;
7093            }
7094            let dist = distance(px, py, node.x, node.y);
7095            if dist > NEARBY_SCAN_M {
7096                continue;
7097            }
7098            let on_top = dist <= ON_TOP_RADIUS_M;
7099            let prefix = if on_top { "On" } else { "Near" };
7100            let name = resource_node_near_display_label(&node.label);
7101            let action = resource_node_near_action_suffix(node);
7102            nearby.push((
7103                dist,
7104                ContextLine {
7105                    on_top,
7106                    text: format!("{prefix}: {name} ({dist:.1}m){action}"),
7107                },
7108            ));
7109        }
7110
7111        for drop in &self.ground_drops {
7112            let dist = distance(px, py, drop.x, drop.y);
7113            if dist > INTERACTION_RADIUS_M {
7114                continue;
7115            }
7116            let on_top = dist <= ON_TOP_RADIUS_M;
7117            let name = self.template_display_name(&drop.template_id);
7118            let prefix = if on_top { "On" } else { "Near" };
7119            let qty = if drop.quantity > 1 {
7120                format!(" ×{}", drop.quantity)
7121            } else {
7122                String::new()
7123            };
7124            nearby.push((
7125                dist,
7126                ContextLine {
7127                    on_top,
7128                    text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
7129                },
7130            ));
7131        }
7132
7133        for c in &self.placed_containers {
7134            if !self.placed_container_in_current_space(c) {
7135                continue;
7136            }
7137            let dist = distance(px, py, c.x, c.y);
7138            if dist > CONTAINER_RANGE_M {
7139                continue;
7140            }
7141            let on_top = dist <= ON_TOP_RADIUS_M;
7142            let name = self.placed_container_public_label(c);
7143            let lock = if c.locked { " [locked]" } else { "" };
7144            let prefix = if on_top { "On" } else { "Near" };
7145            nearby.push((
7146                dist,
7147                ContextLine {
7148                    on_top,
7149                    text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
7150                },
7151            ));
7152        }
7153
7154        for npc in &self.npcs {
7155            let dist = distance(px, py, npc.x, npc.y);
7156            if dist > NEARBY_SCAN_M {
7157                continue;
7158            }
7159            let on_top = dist <= ON_TOP_RADIUS_M;
7160            let prefix = if on_top { "On" } else { "Near" };
7161            nearby.push((
7162                dist,
7163                ContextLine {
7164                    on_top,
7165                    text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
7166                },
7167            ));
7168        }
7169
7170        for door in &self.doors {
7171            let dist = distance(px, py, door.x, door.y);
7172            if dist > DOOR_INTERACTION_RADIUS_M {
7173                continue;
7174            }
7175            let building = self
7176                .buildings
7177                .iter()
7178                .find(|b| b.id == door.building_id)
7179                .map(|b| b.label.as_str())
7180                .unwrap_or(door.building_id.as_str());
7181            let player_house = self
7182                .buildings
7183                .iter()
7184                .find(|b| b.id == door.building_id)
7185                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
7186            let action = if inside.is_some() && door.portal.is_some() {
7187                if player_house {
7188                    if door.locked {
7189                        "locked — l unlock · Enter exit".to_string()
7190                    } else if door.open {
7191                        "close · Enter exit · l lock".to_string()
7192                    } else {
7193                        "open · Enter exit · l lock".to_string()
7194                    }
7195                } else {
7196                    "exit".to_string()
7197                }
7198            } else if player_house {
7199                if door.locked {
7200                    "locked — l unlock".to_string()
7201                } else if door.open {
7202                    "close · Enter go inside · l lock".to_string()
7203                } else {
7204                    "open · l lock".to_string()
7205                }
7206            } else {
7207                "enter".to_string()
7208            };
7209            nearby.push((
7210                dist,
7211                ContextLine {
7212                    on_top: dist <= ON_TOP_RADIUS_M,
7213                    text: format!("{building} door ({dist:.1}m) — f {action}"),
7214                },
7215            ));
7216        }
7217
7218        if inside.is_none() {
7219            for inter in &self.interactables {
7220                if inter.kind != "quest_board" {
7221                    continue;
7222                }
7223                let dist = distance(px, py, inter.x, inter.y);
7224                if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
7225                    continue;
7226                }
7227                let on_top = dist <= ON_TOP_RADIUS_M;
7228                let prefix = if on_top { "On" } else { "Near" };
7229                let label = if inter.label.is_empty() {
7230                    "Quest board".to_string()
7231                } else {
7232                    inter.label.clone()
7233                };
7234                nearby.push((
7235                    dist,
7236                    ContextLine {
7237                        on_top,
7238                        text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
7239                    },
7240                ));
7241            }
7242        }
7243
7244        if self.near_liquid_fill_source() {
7245            let on_water = matches!(
7246                self.terrain_at(px, py),
7247                Some(TerrainKindView::ShallowWater | TerrainKindView::DeepWater)
7248            );
7249            let well = self.buildings.iter().find(|b| {
7250                b.tags.iter().any(|t| t == "well") && {
7251                    let hw = b.width_m * 0.5;
7252                    let hd = b.depth_m * 0.5;
7253                    let nx = px.clamp(b.x - hw, b.x + hw);
7254                    let ny = py.clamp(b.y - hd, b.y + hd);
7255                    let dx = px - nx;
7256                    let dy = py - ny;
7257                    dx * dx + dy * dy <= INTERACTION_RADIUS_M * INTERACTION_RADIUS_M
7258                }
7259            });
7260            if let Some(well) = well {
7261                let name = if well.label.trim().is_empty() {
7262                    "Well"
7263                } else {
7264                    well.label.as_str()
7265                };
7266                nearby.push((
7267                    0.0,
7268                    ContextLine {
7269                        on_top: true,
7270                        text: format!("{name} — Use a vessel from inventory to fill"),
7271                    },
7272                ));
7273            } else if on_water {
7274                if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
7275                    line.text
7276                        .push_str(" — Use a vessel from inventory to fill");
7277                }
7278            } else {
7279                nearby.push((
7280                    0.0,
7281                    ContextLine {
7282                        on_top: true,
7283                        text: "Water nearby — Use a vessel from inventory to fill".into(),
7284                    },
7285                ));
7286            }
7287        }
7288
7289        if self.claim_mode.is_some() {
7290            nearby.push((
7291                0.0,
7292                ContextLine {
7293                    on_top: true,
7294                    text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
7295                        .into(),
7296                },
7297            ));
7298        } else if let Some(plot) = self.my_plot_under_player() {
7299            let name = plot_public_label(plot);
7300            let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
7301                format!("{name} — f again to sell to crown")
7302            } else {
7303                format!(
7304                    "{name} — Shift+c till · p plant · f harvest · B build · l door lock · o farm access · Shift+n rename"
7305                )
7306            };
7307            nearby.push((
7308                0.0,
7309                ContextLine {
7310                    on_top: true,
7311                    text: prompt,
7312                },
7313            ));
7314        } else if let Some(plot) = self.farmable_plot_under_player() {
7315            let name = plot_public_label(plot);
7316            let disc = if plot.farm_public {
7317                plot.public_tax_discount_bps / 100
7318            } else {
7319                plot.farm_allow
7320                    .iter()
7321                    .find(|g| Some(g.character_id) == self.character_id)
7322                    .map(|g| g.tax_discount_bps / 100)
7323                    .unwrap_or(0)
7324            };
7325            nearby.push((
7326                0.0,
7327                ContextLine {
7328                    on_top: true,
7329                    text: format!(
7330                        "{name} (farming · tax −{disc}%) — Shift+c till · p plant · f harvest"
7331                    ),
7332                },
7333            ));
7334        } else if let Some(zone) = self.free_property_zone_under_player() {
7335            let label = zone
7336                .label
7337                .as_deref()
7338                .filter(|s| !s.trim().is_empty())
7339                .unwrap_or(zone.id.as_str());
7340            nearby.push((
7341                0.0,
7342                ContextLine {
7343                    on_top: true,
7344                    text: format!("Claimable land: {label} — k buy plot"),
7345                },
7346            ));
7347        }
7348
7349        for entity in &self.entities {
7350            if entity.id == self.entity_id {
7351                continue;
7352            }
7353            let dist = distance(
7354                px,
7355                py,
7356                entity.transform.position.x,
7357                entity.transform.position.y,
7358            );
7359            if dist > NEARBY_SCAN_M {
7360                continue;
7361            }
7362            let label = if entity.label.is_empty() {
7363                format!("entity {}", entity.id)
7364            } else {
7365                entity.label.clone()
7366            };
7367            nearby.push((
7368                dist,
7369                ContextLine {
7370                    on_top: dist <= ON_TOP_RADIUS_M,
7371                    text: format!("Near: {label} ({dist:.1}m)"),
7372                },
7373            ));
7374        }
7375
7376        nearby.sort_by(|a, b| {
7377            a.0.partial_cmp(&b.0)
7378                .unwrap_or(std::cmp::Ordering::Equal)
7379                .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
7380        });
7381        lines.extend(nearby.into_iter().map(|(_, l)| l));
7382
7383        if lines.is_empty() {
7384            lines.push(ContextLine {
7385                on_top: false,
7386                text: "(nothing notable nearby)".into(),
7387            });
7388        }
7389
7390        lines
7391    }
7392}
7393
7394/// HUD line for the location / nearby panel.
7395#[derive(Debug, Clone)]
7396pub struct ContextLine {
7397    pub on_top: bool,
7398    pub text: String,
7399}
7400
7401const ON_TOP_RADIUS_M: f32 = 0.65;
7402const NEARBY_SCAN_M: f32 = 5.0;
7403
7404/// Display name for a resource node in location/nearby HUD (strips redundant growing tag).
7405pub fn resource_node_near_display_label(label: &str) -> String {
7406    label
7407        .strip_suffix(" (growing)")
7408        .unwrap_or(label)
7409        .to_string()
7410}
7411
7412fn resource_label_looks_like_raw_id(label: &str, id: &str) -> bool {
7413    let t = label.trim();
7414    if t.is_empty() || t == id {
7415        return true;
7416    }
7417    let lower = t.to_ascii_lowercase();
7418    if lower.contains("_copy") {
7419        return true;
7420    }
7421    false
7422}
7423
7424fn humanize_item_template_label(template: &str) -> String {
7425    let base = template.rsplit('/').next().unwrap_or(template).trim();
7426    if base.is_empty() {
7427        return "Resource".into();
7428    }
7429    let stripped = base
7430        .strip_prefix("crop-")
7431        .or_else(|| base.strip_prefix("crop_"))
7432        .unwrap_or(base);
7433    stripped
7434        .split(|c: char| c == '-' || c == '_')
7435        .filter(|p| !p.is_empty())
7436        .map(|p| {
7437            let mut chars = p.chars();
7438            match chars.next() {
7439                Some(c) => format!("{}{}", c.to_ascii_uppercase(), chars.as_str()),
7440                None => String::new(),
7441            }
7442        })
7443        .collect::<Vec<_>>()
7444        .join(" ")
7445}
7446
7447/// Last 4 alphanumeric chars of an id (for disambiguation in route UI).
7448pub fn resource_node_id_suffix(id: &str) -> String {
7449    let chars: Vec<char> = id
7450        .chars()
7451        .rev()
7452        .filter(|c| c.is_ascii_alphanumeric())
7453        .take(4)
7454        .collect();
7455    chars.into_iter().rev().collect()
7456}
7457
7458/// Friendly harvest/route label: prefer human label, else template; always append `(xxxx)`.
7459pub fn resource_node_route_label(node: &flatland_protocol::ResourceNodeView) -> String {
7460    resource_node_route_label_parts(&node.id, &node.label, &node.item_template)
7461}
7462
7463pub fn resource_node_route_label_parts(id: &str, label: &str, item_template: &str) -> String {
7464    let cleaned = resource_node_near_display_label(label);
7465    let friendly = if !resource_label_looks_like_raw_id(&cleaned, id) {
7466        cleaned
7467    } else if !item_template.trim().is_empty() {
7468        humanize_item_template_label(item_template)
7469    } else {
7470        id.to_string()
7471    };
7472    let suffix = resource_node_id_suffix(id);
7473    if suffix.is_empty() {
7474        friendly
7475    } else {
7476        format!("{friendly} ({suffix})")
7477    }
7478}
7479
7480/// Action / state suffix for a resource node line (`growth_progress` wins for farm crops).
7481pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
7482    use flatland_protocol::ResourceNodeState;
7483    if node.harvest_off {
7484        return " (decorative)".to_string();
7485    }
7486    if let Some(p) = node.growth_progress {
7487        if p < 1.0 - f32::EPSILON {
7488            let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
7489            return format!(" (growing, {pct}%)");
7490        }
7491        return " — f harvest".to_string();
7492    }
7493    match node.state {
7494        ResourceNodeState::Available => " — f harvest".to_string(),
7495        ResourceNodeState::Harvesting => " (being harvested)".to_string(),
7496        ResourceNodeState::Cooldown => " (depleted)".to_string(),
7497    }
7498}
7499
7500fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
7501    use flatland_protocol::TerrainKindView;
7502    match kind {
7503        TerrainKindView::Grass => "Grass",
7504        TerrainKindView::Dirt => "Dirt",
7505        TerrainKindView::Tilled => "Tilled",
7506        TerrainKindView::Desert => "Desert",
7507        TerrainKindView::Hill => "Hills",
7508        TerrainKindView::Bog => "Bog",
7509        TerrainKindView::Beach => "Beach",
7510        TerrainKindView::ShallowWater => "Shallow water",
7511        TerrainKindView::DeepWater => "Deep water",
7512        TerrainKindView::Trail => "Trail",
7513        TerrainKindView::Road => "Road",
7514        TerrainKindView::Rock => "Rock",
7515    }
7516}
7517
7518fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
7519    crate::world_zones::zone_rects_contain(rects, x, y)
7520}
7521
7522fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
7523    zone.rects
7524        .iter()
7525        .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
7526        .sum()
7527}
7528
7529fn claim_rect_fully_inside_zone(
7530    zone: &flatland_protocol::PropertyZoneView,
7531    x0: f32,
7532    y0: f32,
7533    x1: f32,
7534    y1: f32,
7535) -> bool {
7536    let mut y = y0 + 0.5;
7537    while y < y1 {
7538        let mut x = x0 + 0.5;
7539        while x < x1 {
7540            if !zone_rects_contain(&zone.rects, x, y) {
7541                return false;
7542            }
7543            x += 1.0;
7544        }
7545        y += 1.0;
7546    }
7547    true
7548}
7549
7550fn rects_overlap_half_open(
7551    ax0: f32,
7552    ay0: f32,
7553    ax1: f32,
7554    ay1: f32,
7555    bx0: f32,
7556    by0: f32,
7557    bx1: f32,
7558    by1: f32,
7559) -> bool {
7560    ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
7561}
7562
7563fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
7564    x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
7565}
7566
7567fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
7568    plot_public_label(p)
7569}
7570
7571fn plot_size_fallback_label(p: &flatland_protocol::PropertyPlotView) -> String {
7572    let w = (p.x1 - p.x0).abs();
7573    let d = (p.y1 - p.y0).abs();
7574    format!("Plot ({w:.0}×{d:.0} m)")
7575}
7576
7577/// Canonical plot name: `{owner} — {zone} — {label}`.
7578pub fn plot_public_label(p: &flatland_protocol::PropertyPlotView) -> String {
7579    let zone = p
7580        .zone_label
7581        .as_deref()
7582        .filter(|s| !s.trim().is_empty())
7583        .unwrap_or_else(|| {
7584            if p.property_zone_id.is_empty() {
7585                "Homestead"
7586            } else {
7587                p.property_zone_id.as_str()
7588            }
7589        });
7590    let label = if !p.label.trim().is_empty() {
7591        p.label.clone()
7592    } else if !p.plot_code.trim().is_empty() {
7593        p.plot_code.clone()
7594    } else {
7595        plot_size_fallback_label(p)
7596    };
7597    match p
7598        .owner_label
7599        .as_deref()
7600        .map(str::trim)
7601        .filter(|s| !s.is_empty())
7602    {
7603        Some(owner) => format!("{owner} — {zone} — {label}"),
7604        None => format!("{zone} — {label}"),
7605    }
7606}
7607
7608/// Resolve a farm-plot stop target for player-facing route UI.
7609///
7610/// Uses [`plot_public_label`] when the plot is in the observer's view; otherwise a
7611/// short constructed fallback (never the full UUID).
7612pub fn plot_stop_label(
7613    plots: &[flatland_protocol::PropertyPlotView],
7614    plot_id: uuid::Uuid,
7615) -> String {
7616    plots
7617        .iter()
7618        .find(|p| p.plot_id == plot_id)
7619        .map(plot_public_label)
7620        .unwrap_or_else(|| {
7621            let s = plot_id.to_string();
7622            format!("plot {}", s.get(..8).unwrap_or(s.as_str()))
7623        })
7624}
7625
7626/// Match `flatland_sim::economy_zones::snap_claim_rect`.
7627fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
7628    let a = x0.min(x1).floor();
7629    let b = y0.min(y1).floor();
7630    let mut c = x0.max(x1).ceil();
7631    let mut d = y0.max(y1).ceil();
7632    if (c - a) < 1.0 {
7633        c = a + 1.0;
7634    }
7635    if (d - b) < 1.0 {
7636        d = b + 1.0;
7637    }
7638    (a, b, c, d)
7639}
7640
7641fn humanize_template_id(template_id: &str) -> String {
7642    // UUID-style ids have no friendly words to title-case; avoid dumping them in UI.
7643    if looks_like_template_uuid(template_id) {
7644        return "Unknown item".into();
7645    }
7646    template_id
7647        .split('_')
7648        .map(|word| {
7649            let mut chars = word.chars();
7650            match chars.next() {
7651                None => String::new(),
7652                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
7653            }
7654        })
7655        .collect::<Vec<_>>()
7656        .join(" ")
7657}
7658
7659fn looks_like_template_uuid(template_id: &str) -> bool {
7660    let bytes = template_id.as_bytes();
7661    if bytes.len() != 36 {
7662        return false;
7663    }
7664    let is_hex = |b: u8| b.is_ascii_hexdigit();
7665    let groups = [8usize, 4, 4, 4, 12];
7666    let mut i = 0;
7667    for (gi, &len) in groups.iter().enumerate() {
7668        if gi > 0 {
7669            if bytes.get(i) != Some(&b'-') {
7670                return false;
7671            }
7672            i += 1;
7673        }
7674        for _ in 0..len {
7675            if !bytes.get(i).copied().is_some_and(is_hex) {
7676                return false;
7677            }
7678            i += 1;
7679        }
7680    }
7681    true
7682}
7683
7684/// Keep in sync with `flatland_sim::INTERACTION_RADIUS_M`.
7685const HARVEST_RANGE_M: f32 = 1.5;
7686
7687pub struct GameClient<S: PlayConnection> {
7688    session: S,
7689    seq: Seq,
7690    pub state: GameState,
7691    last_move_forward: f32,
7692    last_move_strafe: f32,
7693}
7694
7695impl<S: PlayConnection> GameClient<S> {
7696    pub fn new(session: S) -> Self {
7697        let session_id = session.session_id();
7698        let entity_id = session.entity_id();
7699        let mut client = Self {
7700            session,
7701            seq: 0,
7702            last_move_forward: 0.0,
7703            last_move_strafe: 0.0,
7704            state: GameState {
7705                session_id,
7706                entity_id,
7707                character_id: None,
7708                tick: 0,
7709                chunk_rev: 0,
7710                content_rev: 0,
7711                publish_rev: 0,
7712                entities: Vec::new(),
7713                player: None,
7714                resource_nodes: Vec::new(),
7715                ground_drops: Vec::new(),
7716                placed_containers: Vec::new(),
7717                buildings: Vec::new(),
7718                doors: Vec::new(),
7719                interior_map: None,
7720                npcs: Vec::new(),
7721                blueprints: Vec::new(),
7722                building_materials: Vec::new(),
7723                world_x0: 0.0,
7724                world_y0: 0.0,
7725                world_width_m: 0.0,
7726                world_height_m: 0.0,
7727                terrain_zones: Vec::new(),
7728                z_platforms: Vec::new(),
7729                z_transitions: Vec::new(),
7730                z_bands_outdoor_backup: None,
7731                world_clock: flatland_protocol::WorldClock::default(),
7732                inventory: std::collections::HashMap::new(),
7733                inventory_hints: std::collections::HashMap::new(),
7734                item_catalog: std::collections::HashMap::new(),
7735                logs: VecDeque::new(),
7736                intents_sent: 0,
7737                ticks_received: 0,
7738                connected: false,
7739                disconnect_reason: None,
7740                show_stats: false,
7741                hud_log_hidden: false,
7742                show_equip_menu: false,
7743                equip_menu_index: 0,
7744                show_craft_menu: false,
7745                show_plot_build_menu: false,
7746                plot_build_focus_wall: true,
7747                plot_build_wall_index: 0,
7748                plot_build_roof_index: 0,
7749                craft_menu_index: 0,
7750                craft_batch_quantity: 1,
7751                craft_tab: CraftTab::Ready,
7752                craft_filter: String::new(),
7753                craft_filter_focused: false,
7754                craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
7755                show_shop_menu: false,
7756                shop_catalog: None,
7757                bank_panel: None,
7758                bank_menu_index: 0,
7759                bank_ui_mode: BankUiMode::Menu,
7760                storage_panel: None,
7761                market_panel: None,
7762                market_menu_index: 0,
7763                market_filter: String::new(),
7764                market_filter_focused: false,
7765                market_category_filter: None,
7766                market_buy_confirm: None,
7767                market_ui_mode: MarketUiMode::Browse,
7768                storage_menu_index: 0,
7769                storage_ui_mode: StorageUiMode::Menu,
7770                shop_tab: ShopTab::default(),
7771                shop_menu_index: 0,
7772                shop_quantity: 1,
7773                shop_trade_log: VecDeque::new(),
7774                show_npc_verb_menu: false,
7775                npc_verb_target: None,
7776                npc_verb_index: 0,
7777                npc_verb_notice: None,
7778                player_verbs: crate::social::PlayerVerbState::default(),
7779                social_chat: crate::social::SocialChatState::default(),
7780                trade_ui: crate::social::TradeUiState::default(),
7781                whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
7782                show_npc_chat: false,
7783                npc_chat: None,
7784                show_inventory_menu: false,
7785                inventory_menu_index: 0,
7786                inventory_tab: InventoryTab::OnPerson,
7787                inventory_filter: String::new(),
7788                inventory_filter_focused: false,
7789                show_move_picker: false,
7790                show_rename_prompt: false,
7791                rename_plot_id: None,
7792                highlighted_plot_id: None,
7793                show_worker_rename: false,
7794                rename_buffer: String::new(),
7795                move_picker_index: 0,
7796                move_picker: None,
7797                show_grant_picker: false,
7798                grant_picker_index: 0,
7799                grant_picker: None,
7800                show_destroy_picker: false,
7801                destroy_confirm_pending: false,
7802                destroy_picker: None,
7803                combat_target: None,
7804                combat_target_label: None,
7805                ground_target: None,
7806                combat_fx: Vec::new(),
7807                ground_hazards: Vec::new(),
7808                property_zones: Vec::new(),
7809                tax_zones: Vec::new(),
7810                growth_zones: Vec::new(),
7811                biome_zones: Vec::new(),
7812                terrain_kind_nav: Vec::new(),
7813                property_plots: Vec::new(),
7814                property_plot_settings: None,
7815                claim_mode: None,
7816                relocate_mode: None,
7817                sell_plot_confirm: None,
7818                sell_plot_armed_at: None,
7819                show_plant_menu: false,
7820                plant_menu_index: 0,
7821                show_farm_access: false,
7822                farm_access_name_draft: String::new(),
7823                farm_access_discount_bps: 0,
7824                farm_access_index: 0,
7825                plant_quantity: 1,
7826                in_combat: false,
7827                auto_attack: true,
7828                combat_has_los: false,
7829                attack_cd_ticks: 0,
7830                gcd_ticks: 0,
7831                weapon_ability_id: "unarmed".into(),
7832                mainhand_template_id: None,
7833                mainhand_label: None,
7834                mainhand_instance_id: None,
7835                offhand_template_id: None,
7836                offhand_label: None,
7837                offhand_instance_id: None,
7838                mainhand_hand_slots: 1,
7839                defense: None,
7840                worn: BTreeMap::new(),
7841                carry_mass: 0.0,
7842                carry_mass_max: 0.0,
7843                encumbrance: flatland_protocol::EncumbranceState::Light,
7844                move_speed_mps: 0.0,
7845                move_speed_mult: 0.0,
7846                inventory_stacks: Vec::new(),
7847                keychain_stacks: Vec::new(),
7848                whisper_pouch_stacks: Vec::new(),
7849                combat_target_detail: None,
7850                statuses: Vec::new(),
7851                cast_progress: None,
7852                timed_channel: None,
7853                plot_build_offer: None,
7854                ability_cooldowns: Vec::new(),
7855                blocking_active: false,
7856                max_target_slots: 1,
7857                combat_slots: Vec::new(),
7858                rotation_presets: Vec::new(),
7859                known_abilities: Vec::new(),
7860                ability_meta: std::collections::HashMap::new(),
7861                ability_mastery: std::collections::HashMap::new(),
7862                hotbar: vec![None; 9],
7863                max_abilities_per_rotation: 0,
7864                show_loadout_menu: false,
7865                show_keychain_menu: false,
7866                keychain_menu_index: 0,
7867                show_rotation_editor: false,
7868                loadout_menu_index: 0,
7869                loadout_hotbar_slot: 1,
7870                loadout_ability_index: 0,
7871                loadout_focus_presets: false,
7872                rotation_editor: RotationEditorState::default(),
7873                harvest_in_progress: false,
7874                harvest_started_at: None,
7875                pending_craft_ack: None,
7876                craft_channel_blueprint_id: None,
7877                pending_worker_job_ack: None,
7878                attending_worker_instance_id: None,
7879                quest_log: Vec::new(),
7880                interactables: Vec::new(),
7881                ledger: None,
7882                career: None,
7883                character_sheet_tab: CharacterSheetTab::Character,
7884                ledger_period: LedgerPeriod::Day,
7885                show_quest_offer: false,
7886                pending_quest_offers: Vec::new(),
7887                quest_offer_index: 0,
7888                show_quest_menu: false,
7889                quest_menu_index: 0,
7890                quest_withdraw_confirm: false,
7891                hired_workers: Vec::new(),
7892                show_workers_menu: false,
7893                workers_menu_index: 0,
7894                worker_dismiss_confirmation: None,
7895                workers_menu_compact: false,
7896                worker_step_display: BTreeMap::new(),
7897                worker_error_display: BTreeMap::new(),
7898                worker_health_ring_until: BTreeMap::new(),
7899                pending_worker_hire_since: None,
7900                show_worker_give_picker: false,
7901                worker_give_picker_index: 0,
7902                worker_give_picker: None,
7903                show_worker_give_target_picker: false,
7904                worker_give_target_picker_index: 0,
7905                worker_give_target_picker: None,
7906                show_worker_take_picker: false,
7907                worker_take_picker_index: 0,
7908                worker_take_picker: None,
7909                show_worker_teach_picker: false,
7910                worker_teach_picker_index: 0,
7911                worker_teach_picker: None,
7912                worker_route_editor: None,
7913                progression_curve: None,
7914            },
7915        };
7916        client.state.apply_client_ui_prefs();
7917        client
7918    }
7919
7920    pub fn entity_id(&self) -> EntityId {
7921        self.state.entity_id
7922    }
7923
7924    pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
7925        if self.state.connected {
7926            return Ok(());
7927        }
7928
7929        loop {
7930            match self.session.next_event().await {
7931                Some(SessionEvent::Welcome {
7932                    session_id,
7933                    entity_id,
7934                    snapshot,
7935                }) => {
7936                    self.state
7937                        .restore_from_welcome(session_id, entity_id, &snapshot);
7938                    self.state.apply_client_ui_prefs();
7939                    self.state.push_log(format!(
7940                        "Connected — session {session_id}, entity {entity_id}"
7941                    ));
7942                    return Ok(());
7943                }
7944                Some(SessionEvent::Disconnected { .. }) => {
7945                    anyhow::bail!("disconnected before welcome");
7946                }
7947                Some(_) => continue,
7948                None => anyhow::bail!("session closed before welcome"),
7949            }
7950        }
7951    }
7952
7953    /// Drain all pending server events (non-blocking).
7954    pub fn drain_events(&mut self) {
7955        while let Some(event) = self.session.try_next_event() {
7956            if self.handle_event_sync(event).is_err() {
7957                break;
7958            }
7959        }
7960    }
7961
7962    /// Wait for the next server event.
7963    pub async fn next_event(&mut self) -> Option<SessionEvent> {
7964        self.session.next_event().await
7965    }
7966
7967    pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
7968        self.handle_event_sync(event)
7969    }
7970
7971    fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
7972        match event {
7973            SessionEvent::Welcome {
7974                session_id,
7975                entity_id,
7976                snapshot,
7977            } => {
7978                let resumed = self.state.connected;
7979                self.state
7980                    .restore_from_welcome(session_id, entity_id, &snapshot);
7981                if resumed {
7982                    self.state.push_log(format!(
7983                        "Session restored — session {session_id}, entity {entity_id}"
7984                    ));
7985                }
7986            }
7987            SessionEvent::ContentUpdated { snapshot } => {
7988                self.state
7989                    .apply_snapshot_fields(&snapshot, self.state.entity_id);
7990                self.state.push_log(format!(
7991                    "World updated (content rev {})",
7992                    snapshot.content_rev
7993                ));
7994            }
7995            SessionEvent::QuestCatalogUpdated(update) => {
7996                self.state.push_log(format!(
7997                    "Quest board updated (revision {}, {} new, {} retired)",
7998                    update.revision,
7999                    update.accepted.len(),
8000                    update.retired.len()
8001                ));
8002            }
8003            SessionEvent::Tick(delta) => {
8004                self.state.apply_tick_fields(&delta, self.state.entity_id);
8005                self.state.ticks_received += 1;
8006            }
8007            SessionEvent::IntentAck {
8008                entity_id,
8009                seq,
8010                tick,
8011            } => {
8012                crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
8013                if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
8014                    if *craft_seq == seq {
8015                        let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
8016                        if batches > 1 {
8017                            self.state.push_log(format!("Crafting {label} ×{batches}…"));
8018                        } else {
8019                            self.state.push_log(format!("Crafting {label}…"));
8020                        }
8021                    }
8022                }
8023                if self
8024                    .state
8025                    .pending_worker_job_ack
8026                    .as_ref()
8027                    .is_some_and(|p| p.seq == seq)
8028                {
8029                    let pending = self.state.pending_worker_job_ack.take().unwrap();
8030                    if pending.idle {
8031                        self.state.push_log(format!(
8032                            "Route cleared for {} — worker idle",
8033                            pending.worker_label
8034                        ));
8035                    } else {
8036                        self.state.push_log(format!(
8037                            "Route saved for {} — {} stop(s), job loop active",
8038                            pending.worker_label, pending.stop_count
8039                        ));
8040                    }
8041                    if self
8042                        .state
8043                        .worker_route_editor
8044                        .as_ref()
8045                        .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
8046                    {
8047                        self.close_worker_route_editor();
8048                    }
8049                }
8050            }
8051            SessionEvent::Chat(msg) => {
8052                let label = match msg.channel {
8053                    flatland_protocol::ChatChannel::Nearby => "nearby",
8054                    flatland_protocol::ChatChannel::Direct => "speak",
8055                    flatland_protocol::ChatChannel::Whisper => "whisper",
8056                    flatland_protocol::ChatChannel::WhisperStone => "stone",
8057                };
8058                let clarity = match msg.clarity {
8059                    flatland_protocol::ChatClarity::Clear => "",
8060                    flatland_protocol::ChatClarity::Partial => "~",
8061                    flatland_protocol::ChatClarity::Heavy => "…",
8062                };
8063                self.state.push_log(format!(
8064                    "[{label}{clarity}] {}: {}",
8065                    msg.from_name, msg.text
8066                ));
8067                let now_ms = std::time::SystemTime::now()
8068                    .duration_since(std::time::UNIX_EPOCH)
8069                    .map(|d| d.as_millis() as u64)
8070                    .unwrap_or(0);
8071                self.state
8072                    .social_chat
8073                    .note_speech(&msg, self.state.entity_id, now_ms);
8074                self.state
8075                    .social_chat
8076                    .push(crate::social::ChatLogEntry::from_message(
8077                        msg,
8078                        self.state.entity_id,
8079                    ));
8080            }
8081            SessionEvent::TradeOpened(panel) => {
8082                self.state.social_chat.pending_trade = None;
8083                let peer = panel.peer_name.clone();
8084                self.state.trade_ui.open(panel);
8085                self.state.social_chat.push_system(format!(
8086                    "Trade open with {peer} — p present · r ready · Esc cancel"
8087                ));
8088                self.state
8089                    .social_chat
8090                    .push_cue(crate::social::AudioCue::TradeOpened);
8091            }
8092            SessionEvent::TradeClosed { reason } => {
8093                self.state.push_log(reason.clone());
8094                self.state.social_chat.push_system(reason);
8095                self.state.trade_ui.close();
8096            }
8097            SessionEvent::HarvestResult(result) => {
8098                self.state.clear_harvest_state();
8099                crate::harvest_trace!(
8100                    entity_id = self.state.entity_id,
8101                    node_id = %result.node_id,
8102                    template = %result.item_template,
8103                    quantity = result.quantity,
8104                    client_tick = self.state.tick,
8105                    "client applied harvest result"
8106                );
8107                let msg = if result.quantity == 0 {
8108                    format!(
8109                        "Harvested {} x0 — nothing dropped (loot table rolled empty)",
8110                        result.item_template
8111                    )
8112                } else {
8113                    format!(
8114                        "Harvested {} x{} (on the ground — press P to pick up)",
8115                        result.item_template, result.quantity
8116                    )
8117                };
8118                self.state.push_log(msg);
8119            }
8120            SessionEvent::CraftResult(result) => {
8121                for stack in &result.consumed {
8122                    if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
8123                        *qty = qty.saturating_sub(stack.quantity);
8124                        if *qty == 0 {
8125                            self.state.inventory.remove(&stack.template_id);
8126                        }
8127                    }
8128                }
8129                for stack in &result.outputs {
8130                    *self
8131                        .state
8132                        .inventory
8133                        .entry(stack.template_id.clone())
8134                        .or_insert(0) += stack.quantity;
8135                }
8136                self.state.craft_record_completed(&result.blueprint_id);
8137                if let Some(output) = result.outputs.first() {
8138                    if result.batch_total > 1 {
8139                        self.state.push_log(format!(
8140                            "Crafted {} x{} ({}/{})",
8141                            output.template_id,
8142                            output.quantity,
8143                            result.batch_index,
8144                            result.batch_total
8145                        ));
8146                    } else {
8147                        self.state.push_log(format!(
8148                            "Crafted {} x{}",
8149                            output.template_id, output.quantity
8150                        ));
8151                    }
8152                } else {
8153                    self.state
8154                        .push_log(format!("Craft finished: {}", result.blueprint_id));
8155                }
8156            }
8157            SessionEvent::Death(notice) => {
8158                self.state.clear_harvest_state();
8159                self.state.push_log(notice.message.clone());
8160                self.state.push_log(format!(
8161                    "Respawned at ({:.1}, {:.1})",
8162                    notice.respawn_x, notice.respawn_y
8163                ));
8164            }
8165            SessionEvent::Interaction(notice) => {
8166                if notice.message.starts_with("Harvest failed:") {
8167                    self.state.clear_harvest_state();
8168                }
8169                if notice.message.starts_with("Can't do that:") {
8170                    self.state.pending_worker_hire_since = None;
8171                    self.state.pending_craft_ack = None;
8172                    self.state.craft_channel_blueprint_id = None;
8173                    if let Some(pending) = self.state.pending_worker_job_ack.take() {
8174                        if let Some(w) = self
8175                            .state
8176                            .hired_workers
8177                            .iter_mut()
8178                            .find(|w| w.instance_id == pending.worker_instance_id)
8179                        {
8180                            w.route = pending.prev_route;
8181                            w.mode = pending.prev_mode;
8182                            w.step_label = pending.prev_step_label;
8183                            w.last_error = pending.prev_last_error;
8184                        }
8185                        let reason = notice
8186                            .message
8187                            .strip_prefix("Can't do that:")
8188                            .unwrap_or(&notice.message)
8189                            .trim();
8190                        self.state.push_log(format!(
8191                            "Route save failed for {}: {reason}",
8192                            pending.worker_label
8193                        ));
8194                    }
8195                    let reason = notice
8196                        .message
8197                        .strip_prefix("Can't do that:")
8198                        .unwrap_or(&notice.message)
8199                        .trim();
8200                    if reason.contains("already tilled") {
8201                        if let Some(plot) = self.state.my_plot_under_player() {
8202                            self.state.sell_plot_confirm = Some(plot.plot_id);
8203                            self.state.sell_plot_armed_at = Some(Instant::now());
8204                        }
8205                    }
8206                }
8207                if notice.message.starts_with("Cast failed:") {
8208                    self.state.cast_progress = None;
8209                }
8210                if notice.message.contains("slain the") {
8211                    self.state.combat_target = None;
8212                    self.state.combat_target_label = None;
8213                }
8214                // Inbound trade request → inline Y/N in the CHAT column (no popup).
8215                if notice.message.contains("wants to trade") {
8216                    if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
8217                        let from_name = notice
8218                            .message
8219                            .split(" wants to trade")
8220                            .next()
8221                            .unwrap_or("Player")
8222                            .to_string();
8223                        self.state.social_chat.pending_trade =
8224                            Some(crate::social::PendingTradeRequest {
8225                                from_entity,
8226                                from_name: from_name.clone(),
8227                            });
8228                        self.state.social_chat.push_system(format!(
8229                            "{from_name} wants to trade — [Y] accept · [N] decline"
8230                        ));
8231                        self.state
8232                            .social_chat
8233                            .push_cue(crate::social::AudioCue::TradeOffer);
8234                    }
8235                }
8236                if notice.message.starts_with("trade request declined") {
8237                    self.state.social_chat.push_system(notice.message.clone());
8238                    self.state
8239                        .social_chat
8240                        .push_cue(crate::social::AudioCue::TradeDeclined);
8241                }
8242                // Verb dock hides the floating LOG — keep refuse lines on the panel + toast.
8243                if self.state.show_npc_verb_menu && !notice.message.trim().is_empty() {
8244                    self.state.npc_verb_notice = Some(notice.message.clone());
8245                    self.state
8246                        .social_chat
8247                        .push_cue(crate::social::AudioCue::UiError);
8248                }
8249                self.state.apply_interaction_notice(&notice);
8250                self.state.push_log(notice.message.clone());
8251            }
8252            SessionEvent::ShopOpened(catalog) => {
8253                self.state.apply_shop_catalog(catalog);
8254            }
8255            SessionEvent::BankOpened(panel) => {
8256                self.state.apply_bank_panel(panel);
8257            }
8258            SessionEvent::StorageOpened(panel) => {
8259                self.state.apply_storage_panel(panel);
8260            }
8261            SessionEvent::MarketOpened(panel) => {
8262                self.state.apply_market_panel(panel);
8263            }
8264            SessionEvent::NpcTalkOpened(opened) => {
8265                self.state.show_npc_verb_menu = false;
8266                if self.state.npc_verb_target.is_none() {
8267                    self.state.npc_verb_target = Some(opened.npc_id.clone());
8268                }
8269                let label = opened.npc_label.clone();
8270                let banner = if !opened.trade_allowed {
8271                    Some("Trade is unavailable right now.".to_string())
8272                } else {
8273                    None
8274                };
8275                self.state.show_npc_chat = true;
8276                self.state.npc_chat = Some(NpcChatState {
8277                    npc_id: opened.npc_id,
8278                    npc_label: opened.npc_label,
8279                    lines: if opened.greeting.is_empty() {
8280                        vec![]
8281                    } else {
8282                        vec![format!("{label}: {}", opened.greeting)]
8283                    },
8284                    input: String::new(),
8285                    pending: opened.greeting.is_empty(),
8286                    talk_depth: opened.talk_depth,
8287                    trade_allowed: opened.trade_allowed,
8288                    banner,
8289                    suggested_topics: opened.suggested_topics,
8290                });
8291            }
8292            SessionEvent::NpcTalkPending(_) => {
8293                if let Some(chat) = self.state.npc_chat.as_mut() {
8294                    chat.pending = true;
8295                }
8296            }
8297            SessionEvent::NpcTalkReply(reply) => {
8298                if let Some(chat) = self.state.npc_chat.as_mut() {
8299                    if chat.npc_id == reply.npc_id {
8300                        chat.pending = false;
8301                        if reply.trade_disabled {
8302                            chat.trade_allowed = false;
8303                            chat.banner = Some("Trade is unavailable right now.".to_string());
8304                        }
8305                        if reply.wind_down {
8306                            chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
8307                            if chat.banner.is_none() {
8308                                chat.banner =
8309                                    Some("They're wrapping up — keep it brief.".to_string());
8310                            }
8311                        }
8312                        chat.lines
8313                            .push(format!("{}: {}", chat.npc_label, reply.line));
8314                    }
8315                }
8316            }
8317            SessionEvent::NpcTalkClosed(closed) => {
8318                if self
8319                    .state
8320                    .npc_chat
8321                    .as_ref()
8322                    .is_some_and(|c| c.npc_id == closed.npc_id)
8323                {
8324                    self.state.show_npc_chat = false;
8325                    self.state.npc_chat = None;
8326                }
8327            }
8328            SessionEvent::NpcTalkError(err) => {
8329                self.state.push_log(format!("Talk failed: {}", err.reason));
8330                if let Some(chat) = self.state.npc_chat.as_mut() {
8331                    chat.pending = false;
8332                }
8333            }
8334            SessionEvent::UseResult(result) => {
8335                // Inventory count hint only — the Interaction notice already logs
8336                // "Consumed …" (and drives the gfx toast). Logging again here doubled toasts.
8337                if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
8338                    *qty = qty.saturating_sub(1);
8339                    if *qty == 0 {
8340                        self.state.inventory.remove(&result.template_id);
8341                    }
8342                }
8343            }
8344            SessionEvent::QuestOffer(offer) => {
8345                let title = offer.title.clone();
8346                self.state.push_quest_offer(offer);
8347                self.state.push_log(format!("Quest offered: {title}"));
8348            }
8349            SessionEvent::QuestAccepted(notice) => {
8350                self.state.remove_quest_offer(&notice.quest_id);
8351                self.state.push_log(notice.message);
8352            }
8353            SessionEvent::QuestWithdrawn(notice) => {
8354                self.state.show_quest_menu = false;
8355                self.state.quest_withdraw_confirm = false;
8356                self.state.push_log(notice.message);
8357            }
8358            SessionEvent::QuestStepCompleted(notice) => {
8359                self.state.push_log(notice.message);
8360            }
8361            SessionEvent::QuestCompleted(notice) => {
8362                self.state.push_log(notice.message);
8363            }
8364            SessionEvent::Disconnected { reason } => {
8365                self.state.clear_harvest_state();
8366                self.state.connected = false;
8367                self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
8368                if let Some(r) = &self.state.disconnect_reason {
8369                    self.state.push_log(format!("Disconnected: {r}"));
8370                } else {
8371                    self.state.push_log("Disconnected from server");
8372                }
8373            }
8374        }
8375        Ok(())
8376    }
8377
8378    pub fn is_connected(&self) -> bool {
8379        self.state.connected
8380    }
8381
8382    pub fn close_overlays(&mut self) {
8383        self.state.show_stats = false;
8384        self.state.show_craft_menu = false;
8385        self.state.show_plot_build_menu = false;
8386        self.state.show_shop_menu = false;
8387        self.state.shop_catalog = None;
8388        self.state.show_npc_verb_menu = false;
8389        self.state.npc_verb_target = None;
8390        self.state.show_npc_chat = false;
8391        self.state.npc_chat = None;
8392        self.state.show_inventory_menu = false;
8393        self.state.show_loadout_menu = false;
8394        self.state.show_rotation_editor = false;
8395        self.state.rotation_editor.reset();
8396        self.state.show_rename_prompt = false;
8397        self.state.show_worker_rename = false;
8398        self.state.rename_buffer.clear();
8399        self.state.show_move_picker = false;
8400        self.state.move_picker = None;
8401        self.state.show_destroy_picker = false;
8402        self.state.destroy_confirm_pending = false;
8403        self.state.destroy_picker = None;
8404        self.state.show_quest_offer = false;
8405        self.state.clear_quest_offers();
8406        self.state.show_quest_menu = false;
8407        self.state.quest_withdraw_confirm = false;
8408        self.state.show_workers_menu = false;
8409        self.close_worker_give_picker();
8410        self.close_worker_give_target_picker();
8411        self.close_worker_take_picker();
8412        self.close_worker_teach_picker();
8413        self.state.worker_route_editor = None;
8414        self.state.claim_mode = None;
8415        self.state.relocate_mode = None;
8416        self.state.sell_plot_confirm = None;
8417        self.state.sell_plot_armed_at = None;
8418        self.close_farm_access_panel();
8419        if self.state.show_plant_menu {
8420            self.close_plant_menu();
8421        }
8422    }
8423
8424    /// Esc / back — pop one UI layer instead of closing every overlay at once.
8425    pub fn back_on_esc(&mut self) -> bool {
8426        if self.state.social_chat.composer_open() {
8427            self.state.social_chat.close_composer();
8428            return true;
8429        }
8430        if self.state.player_verbs.open {
8431            self.state.player_verbs.close();
8432            return true;
8433        }
8434        if self.state.whisper_pouch_ui.open {
8435            self.state.whisper_pouch_ui.open = false;
8436            return true;
8437        }
8438        if self.state.trade_ui.panel.is_some() {
8439            // async cancel — caller should prefer trade_cancel; close UI optimistically
8440            self.state.trade_ui.close();
8441            return true;
8442        }
8443        if self.state.show_rename_prompt {
8444            self.cancel_rename_prompt();
8445            return true;
8446        }
8447        if self.state.show_worker_rename {
8448            self.cancel_worker_rename();
8449            return true;
8450        }
8451        if self.state.show_destroy_picker {
8452            if self.state.destroy_confirm_pending {
8453                self.cancel_destroy_confirm();
8454            } else {
8455                self.close_destroy_picker();
8456            }
8457            return true;
8458        }
8459        if self.state.claim_mode.is_some() {
8460            self.cancel_claim_mode();
8461            return true;
8462        }
8463        if self.state.relocate_mode.is_some() {
8464            self.cancel_relocate_mode();
8465            return true;
8466        }
8467        if self.state.show_plant_menu {
8468            self.close_plant_menu();
8469            return true;
8470        }
8471        if self.state.show_farm_access {
8472            self.close_farm_access_panel();
8473            return true;
8474        }
8475        if self.state.sell_plot_confirm.is_some() {
8476            self.state.sell_plot_confirm = None;
8477            self.state.sell_plot_armed_at = None;
8478            self.state.push_log("Sell cancelled");
8479            return true;
8480        }
8481        if self.state.show_move_picker {
8482            self.close_move_picker();
8483            return true;
8484        }
8485        if self.state.show_rotation_editor {
8486            match self.state.rotation_editor.mode {
8487                RotationEditorMode::List => {
8488                    self.state.show_rotation_editor = false;
8489                    self.state.rotation_editor.reset();
8490                }
8491                RotationEditorMode::EditLabel => {
8492                    self.state.rotation_editor.label_buffer.clear();
8493                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8494                }
8495                RotationEditorMode::PickAbility => {
8496                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8497                }
8498                RotationEditorMode::EditSequence => {
8499                    self.state.rotation_editor.draft = None;
8500                    self.state.rotation_editor.mode = RotationEditorMode::List;
8501                }
8502            }
8503            return true;
8504        }
8505        if self.state.show_inventory_menu {
8506            self.close_inventory_menu();
8507            return true;
8508        }
8509        if self.state.show_craft_menu {
8510            self.close_craft_menu();
8511            return true;
8512        }
8513        if self.state.show_plot_build_menu {
8514            self.close_plot_build_menu();
8515            return true;
8516        }
8517        if self.state.show_keychain_menu {
8518            self.close_keychain_menu();
8519            return true;
8520        }
8521        if self.state.show_quest_offer {
8522            self.quest_offer_decline();
8523            return true;
8524        }
8525        if self.state.show_shop_menu {
8526            // Caller must await shop close (npc_interaction_back / play-loop).
8527            return false;
8528        }
8529        if self.state.bank_panel.is_some() {
8530            return false;
8531        }
8532        if self.state.storage_panel.is_some() {
8533            return false;
8534        }
8535        if self.state.market_panel.is_some() {
8536            return false;
8537        }
8538        if self.state.show_npc_chat {
8539            // play.rs calls npc_interaction_back().await on Esc
8540            return false;
8541        }
8542        if self.state.show_npc_verb_menu {
8543            self.state.show_npc_verb_menu = false;
8544            self.state.npc_verb_target = None;
8545            self.state.npc_verb_notice = None;
8546            return true;
8547        }
8548        if self.state.show_quest_menu {
8549            if self.state.quest_withdraw_confirm {
8550                self.state.quest_withdraw_confirm = false;
8551            } else {
8552                self.state.show_quest_menu = false;
8553            }
8554            return true;
8555        }
8556        if self.state.worker_route_editor.is_some() {
8557            // Pop one sheet level; only close the editor at the root stop list.
8558            if self.re_at_root_sheet() {
8559                let reopen = self.state.attending_worker_instance_id.clone();
8560                self.close_worker_route_editor();
8561                if let Some(id) = reopen {
8562                    if let Some(idx) = self
8563                        .state
8564                        .hired_workers
8565                        .iter()
8566                        .position(|w| w.instance_id == id)
8567                    {
8568                        self.state.workers_menu_index = idx;
8569                        self.state.show_workers_menu = true;
8570                    }
8571                }
8572            } else {
8573                self.re_sheet_back();
8574            }
8575            return true;
8576        }
8577        if self.state.show_worker_give_picker {
8578            self.close_worker_give_picker();
8579            return true;
8580        }
8581        if self.state.show_worker_give_target_picker {
8582            self.close_worker_give_target_picker();
8583            return true;
8584        }
8585        if self.state.show_worker_take_picker {
8586            self.close_worker_take_picker();
8587            return true;
8588        }
8589        if self.state.show_worker_teach_picker {
8590            self.close_worker_teach_picker();
8591            return true;
8592        }
8593        if self.state.show_workers_menu {
8594            self.close_workers_menu_ui();
8595            return true;
8596        }
8597        if self.state.show_loadout_menu {
8598            self.state.show_loadout_menu = false;
8599            return true;
8600        }
8601        if self.state.show_stats {
8602            self.state.show_stats = false;
8603            return true;
8604        }
8605        if self.state.show_equip_menu {
8606            self.state.show_equip_menu = false;
8607            return true;
8608        }
8609        false
8610    }
8611
8612    pub fn toggle_stats(&mut self) {
8613        self.state.show_stats = !self.state.show_stats;
8614        if self.state.show_stats {
8615            self.state.character_sheet_tab = CharacterSheetTab::Character;
8616            self.state.show_craft_menu = false;
8617            self.state.show_shop_menu = false;
8618            self.state.shop_catalog = None;
8619            self.state.show_inventory_menu = false;
8620            self.state.show_equip_menu = false;
8621        }
8622    }
8623
8624    pub fn toggle_equip_menu(&mut self) {
8625        self.state.show_equip_menu = !self.state.show_equip_menu;
8626        if self.state.show_equip_menu {
8627            self.state.show_stats = false;
8628            self.state.show_craft_menu = false;
8629            self.state.show_shop_menu = false;
8630            self.state.shop_catalog = None;
8631            self.state.show_inventory_menu = false;
8632            self.state.show_loadout_menu = false;
8633        }
8634    }
8635
8636    pub fn cycle_character_sheet_tab(&mut self) {
8637        if self.state.show_stats {
8638            self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
8639        }
8640    }
8641
8642    pub fn set_ledger_period_digit(&mut self, c: char) {
8643        if self.state.show_stats {
8644            if let Some(p) = LedgerPeriod::from_digit(c) {
8645                self.state.ledger_period = p;
8646                self.state.character_sheet_tab = CharacterSheetTab::Ledger;
8647            }
8648        }
8649    }
8650
8651    pub fn cycle_ledger_period(&mut self) {
8652        if self.state.show_stats && self.state.character_sheet_tab == CharacterSheetTab::Ledger {
8653            self.state.ledger_period = self.state.ledger_period.cycle();
8654        }
8655    }
8656
8657    pub fn open_inventory_menu(&mut self) {
8658        self.state.show_inventory_menu = true;
8659        self.state.show_craft_menu = false;
8660        self.state.show_shop_menu = false;
8661        self.state.shop_catalog = None;
8662        self.state.show_stats = false;
8663        self.state.show_move_picker = false;
8664        self.state.move_picker = None;
8665        self.state.show_destroy_picker = false;
8666        self.state.destroy_confirm_pending = false;
8667        self.state.destroy_picker = None;
8668        self.state.show_rename_prompt = false;
8669        self.state.rename_plot_id = None;
8670        self.state.rename_buffer.clear();
8671        self.state.inventory_filter_focused = false;
8672        self.state.clamp_inventory_indices();
8673    }
8674
8675    pub fn close_inventory_menu(&mut self) {
8676        self.state.show_inventory_menu = false;
8677        self.state.show_move_picker = false;
8678        self.state.move_picker = None;
8679        self.close_grant_picker();
8680        self.state.show_destroy_picker = false;
8681        self.state.destroy_confirm_pending = false;
8682        self.state.destroy_picker = None;
8683        self.state.show_rename_prompt = false;
8684        self.state.rename_plot_id = None;
8685        self.state.rename_buffer.clear();
8686        self.state.inventory_filter_focused = false;
8687    }
8688
8689    pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
8690        let Some(row) = self.state.inventory_selected_row() else {
8691            anyhow::bail!("inventory empty");
8692        };
8693        if GameState::is_property_deed_template(&row.stack.template_id) {
8694            let Some(plot_id) = GameState::deed_plot_id(&row.stack) else {
8695                anyhow::bail!("deed has no plot id");
8696            };
8697            let label = self
8698                .state
8699                .property_plots
8700                .iter()
8701                .find(|p| p.plot_id == plot_id)
8702                .map(|p| {
8703                    if p.label.trim().is_empty() {
8704                        p.plot_code.clone()
8705                    } else {
8706                        p.label.clone()
8707                    }
8708                })
8709                .unwrap_or_else(|| {
8710                    row.stack
8711                        .display_name
8712                        .clone()
8713                        .unwrap_or_else(|| "plot".into())
8714                });
8715            self.state.rename_buffer = label;
8716            self.state.rename_plot_id = Some(plot_id);
8717            self.state.highlighted_plot_id = Some(plot_id);
8718            self.state.show_rename_prompt = true;
8719            self.state.show_worker_rename = false;
8720            self.state.show_move_picker = false;
8721            self.state.show_destroy_picker = false;
8722            self.state.destroy_confirm_pending = false;
8723            return Ok(());
8724        }
8725        if !self.state.row_is_renameable_container(&row) {
8726            anyhow::bail!("only storage containers or deeds can be renamed");
8727        }
8728        let current = row
8729            .stack
8730            .display_name
8731            .clone()
8732            .unwrap_or_else(|| row.stack.template_id.clone());
8733        self.state.rename_buffer = current;
8734        self.state.rename_plot_id = None;
8735        self.state.show_rename_prompt = true;
8736        self.state.show_worker_rename = false;
8737        self.state.show_move_picker = false;
8738        self.state.show_destroy_picker = false;
8739        self.state.destroy_confirm_pending = false;
8740        Ok(())
8741    }
8742
8743    /// Rename the owned plot under the player's feet (`n` in world).
8744    pub fn open_plot_rename_under_player(&mut self) -> anyhow::Result<()> {
8745        let Some(plot) = self.state.my_plot_under_player().cloned() else {
8746            anyhow::bail!("stand on your plot to rename it");
8747        };
8748        let label = if plot.label.trim().is_empty() {
8749            plot.plot_code.clone()
8750        } else {
8751            plot.label.clone()
8752        };
8753        self.state.rename_buffer = label;
8754        self.state.rename_plot_id = Some(plot.plot_id);
8755        self.state.highlighted_plot_id = Some(plot.plot_id);
8756        self.state.show_rename_prompt = true;
8757        self.state.show_worker_rename = false;
8758        Ok(())
8759    }
8760
8761    pub fn cancel_rename_prompt(&mut self) {
8762        self.state.show_rename_prompt = false;
8763        self.state.rename_plot_id = None;
8764        self.state.rename_buffer.clear();
8765    }
8766
8767    pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
8768        let name = self.state.rename_buffer.trim().to_string();
8769        if name.is_empty() {
8770            anyhow::bail!("name cannot be empty");
8771        }
8772        if let Some(plot_id) = self.state.rename_plot_id {
8773            if name.chars().count() > 48 {
8774                anyhow::bail!("label must be 1–48 characters");
8775            }
8776            self.seq += 1;
8777            self.session
8778                .submit_intent(Intent::RenamePropertyPlot {
8779                    entity_id: self.state.entity_id,
8780                    plot_id,
8781                    label: name,
8782                    seq: self.seq,
8783                })
8784                .await?;
8785            self.state.intents_sent += 1;
8786            self.state.show_rename_prompt = false;
8787            self.state.rename_plot_id = None;
8788            self.state.rename_buffer.clear();
8789            return Ok(());
8790        }
8791        if name.chars().count() > 32 {
8792            anyhow::bail!("name must be 1–32 characters");
8793        }
8794        let Some(row) = self.state.inventory_selected_row() else {
8795            anyhow::bail!("inventory empty");
8796        };
8797        let Some(instance_id) = row.stack.item_instance_id else {
8798            anyhow::bail!("item has no instance id");
8799        };
8800        self.seq += 1;
8801        self.session
8802            .submit_intent(Intent::RenameContainer {
8803                entity_id: self.state.entity_id,
8804                item_instance_id: instance_id,
8805                location: row.from.clone(),
8806                name,
8807                seq: self.seq,
8808            })
8809            .await?;
8810        self.state.intents_sent += 1;
8811        self.state.show_rename_prompt = false;
8812        self.state.rename_buffer.clear();
8813        Ok(())
8814    }
8815
8816    pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
8817        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
8818            anyhow::bail!("no worker selected");
8819        };
8820        self.state.rename_buffer = worker.label.clone();
8821        self.state.show_worker_rename = true;
8822        self.state.show_rename_prompt = false;
8823        Ok(())
8824    }
8825
8826    pub fn cancel_worker_rename(&mut self) {
8827        self.state.show_worker_rename = false;
8828        self.state.rename_buffer.clear();
8829    }
8830
8831    pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
8832        let name = self.state.rename_buffer.trim().to_string();
8833        if name.is_empty() {
8834            anyhow::bail!("name cannot be empty");
8835        }
8836        if name.chars().count() > 32 {
8837            anyhow::bail!("name must be 1–32 characters");
8838        }
8839        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
8840            anyhow::bail!("no worker selected");
8841        };
8842        let worker_instance_id = worker.instance_id.clone();
8843        self.seq += 1;
8844        self.session
8845            .submit_intent(Intent::RenameHiredWorker {
8846                entity_id: self.state.entity_id,
8847                worker_instance_id: worker_instance_id.clone(),
8848                name: name.clone(),
8849                seq: self.seq,
8850            })
8851            .await?;
8852        self.state.intents_sent += 1;
8853        if let Some(w) = self
8854            .state
8855            .hired_workers
8856            .iter_mut()
8857            .find(|w| w.instance_id == worker_instance_id)
8858        {
8859            w.label = name.clone();
8860        }
8861        if let Some(ed) = self.state.worker_route_editor.as_mut() {
8862            if ed.worker_instance_id == worker_instance_id {
8863                ed.worker_label = name.clone();
8864            }
8865        }
8866        self.state.show_worker_rename = false;
8867        self.state.rename_buffer.clear();
8868        self.state.push_log(format!("Renamed worker to \"{name}\""));
8869        Ok(())
8870    }
8871
8872    pub fn toggle_inventory_menu(&mut self) {
8873        if self.state.show_inventory_menu {
8874            self.close_inventory_menu();
8875        } else {
8876            self.open_inventory_menu();
8877        }
8878    }
8879
8880    /// ↑/↓ in the inventory browser, or within the "move to…" / grant picker when open.
8881    pub fn inventory_menu_move(&mut self, delta: i32) {
8882        if self.state.show_grant_picker {
8883            let Some(picker) = self.state.grant_picker.as_ref() else {
8884                return;
8885            };
8886            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8887            let filter = picker.filter.clone();
8888            let n = labels.len();
8889            if n == 0 {
8890                return;
8891            }
8892            self.state.grant_picker_index =
8893                step_filtered_index(self.state.grant_picker_index, delta, n, |i| {
8894                    list_label_matches(&labels[i], &filter)
8895                });
8896            return;
8897        }
8898        if self.state.show_move_picker {
8899            let Some(picker) = self.state.move_picker.as_ref() else {
8900                return;
8901            };
8902            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8903            let filter = picker.filter.clone();
8904            let n = labels.len();
8905            if n == 0 {
8906                return;
8907            }
8908            self.state.move_picker_index =
8909                step_filtered_index(self.state.move_picker_index, delta, n, |i| {
8910                    list_label_matches(&labels[i], &filter)
8911                });
8912            self.state.clamp_move_picker_quantity();
8913            return;
8914        }
8915        let n = self.state.inventory_selectable_rows().len();
8916        if n == 0 {
8917            return;
8918        }
8919        let idx = self.state.inventory_menu_index as i32;
8920        self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8921    }
8922
8923    /// PageUp / PageDown (±1 page) for inventory / move / grant lists.
8924    pub fn inventory_menu_page(&mut self, pages: i32) {
8925        if self.state.show_grant_picker {
8926            let Some(picker) = self.state.grant_picker.as_ref() else {
8927                return;
8928            };
8929            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8930            let filter = picker.filter.clone();
8931            let n = labels.len();
8932            self.state.grant_picker_index =
8933                page_filtered_index(self.state.grant_picker_index, pages, n, |i| {
8934                    list_label_matches(&labels[i], &filter)
8935                });
8936            return;
8937        }
8938        if self.state.show_move_picker {
8939            let Some(picker) = self.state.move_picker.as_ref() else {
8940                return;
8941            };
8942            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8943            let filter = picker.filter.clone();
8944            let n = labels.len();
8945            self.state.move_picker_index =
8946                page_filtered_index(self.state.move_picker_index, pages, n, |i| {
8947                    list_label_matches(&labels[i], &filter)
8948                });
8949            self.state.clamp_move_picker_quantity();
8950            return;
8951        }
8952        let n = self.state.inventory_selectable_rows().len();
8953        self.state.inventory_menu_index =
8954            page_list_index(self.state.inventory_menu_index, pages, n);
8955    }
8956
8957    pub fn cycle_inventory_tab(&mut self, forward: bool) {
8958        if self.state.show_move_picker
8959            || self.state.show_grant_picker
8960            || self.state.show_destroy_picker
8961            || self.state.show_rename_prompt
8962            || self.state.inventory_filter_focused
8963        {
8964            return;
8965        }
8966        self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
8967        self.state.inventory_menu_index = 0;
8968        self.state.clamp_inventory_indices();
8969    }
8970
8971    pub fn focus_inventory_filter(&mut self) {
8972        if self.state.show_grant_picker {
8973            if let Some(p) = self.state.grant_picker.as_mut() {
8974                p.filter_focused = true;
8975            }
8976            return;
8977        }
8978        if self.state.show_move_picker {
8979            if let Some(p) = self.state.move_picker.as_mut() {
8980                p.filter_focused = true;
8981            }
8982            return;
8983        }
8984        self.state.inventory_filter_focused = true;
8985    }
8986
8987    pub fn set_inventory_filter(&mut self, filter: String) {
8988        self.state.inventory_filter = filter;
8989        self.state.inventory_menu_index = 0;
8990        self.state.clamp_inventory_indices();
8991    }
8992
8993    pub fn append_inventory_filter_char(&mut self, ch: char) {
8994        if !is_list_filter_char(ch) {
8995            return;
8996        }
8997        if self.state.show_grant_picker {
8998            if let Some(p) = self.state.grant_picker.as_mut() {
8999                if p.filter_focused {
9000                    p.filter.push(ch);
9001                    self.state.grant_picker_index = 0;
9002                }
9003            }
9004            return;
9005        }
9006        if self.state.show_move_picker {
9007            if let Some(p) = self.state.move_picker.as_mut() {
9008                if p.filter_focused {
9009                    p.filter.push(ch);
9010                    self.state.move_picker_index = 0;
9011                    self.state.clamp_move_picker_quantity();
9012                }
9013            }
9014            return;
9015        }
9016        if !self.state.inventory_filter_focused {
9017            return;
9018        }
9019        self.state.inventory_filter.push(ch);
9020        self.state.inventory_menu_index = 0;
9021        self.state.clamp_inventory_indices();
9022    }
9023
9024    pub fn inventory_filter_backspace(&mut self) {
9025        if self.state.show_grant_picker {
9026            if let Some(p) = self.state.grant_picker.as_mut() {
9027                if p.filter_focused {
9028                    p.filter.pop();
9029                    self.state.grant_picker_index = 0;
9030                }
9031            }
9032            return;
9033        }
9034        if self.state.show_move_picker {
9035            if let Some(p) = self.state.move_picker.as_mut() {
9036                if p.filter_focused {
9037                    p.filter.pop();
9038                    self.state.move_picker_index = 0;
9039                    self.state.clamp_move_picker_quantity();
9040                }
9041            }
9042            return;
9043        }
9044        if !self.state.inventory_filter_focused {
9045            return;
9046        }
9047        self.state.inventory_filter.pop();
9048        self.state.inventory_menu_index = 0;
9049        self.state.clamp_inventory_indices();
9050    }
9051
9052    /// Esc while filter focused: clear filter then blur. Returns true if handled.
9053    pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
9054        if self.state.show_grant_picker {
9055            if let Some(p) = self.state.grant_picker.as_mut() {
9056                if p.filter_focused {
9057                    if !p.filter.is_empty() {
9058                        p.filter.clear();
9059                        self.state.grant_picker_index = 0;
9060                    } else {
9061                        p.filter_focused = false;
9062                    }
9063                    return true;
9064                }
9065                if !p.filter.is_empty() {
9066                    p.filter.clear();
9067                    self.state.grant_picker_index = 0;
9068                    return true;
9069                }
9070            }
9071            return false;
9072        }
9073        if self.state.show_move_picker {
9074            if let Some(p) = self.state.move_picker.as_mut() {
9075                if p.filter_focused {
9076                    if !p.filter.is_empty() {
9077                        p.filter.clear();
9078                        self.state.move_picker_index = 0;
9079                        self.state.clamp_move_picker_quantity();
9080                    } else {
9081                        p.filter_focused = false;
9082                    }
9083                    return true;
9084                }
9085                if !p.filter.is_empty() {
9086                    p.filter.clear();
9087                    self.state.move_picker_index = 0;
9088                    self.state.clamp_move_picker_quantity();
9089                    return true;
9090                }
9091            }
9092            return false;
9093        }
9094        if self.state.inventory_filter_focused {
9095            if !self.state.inventory_filter.is_empty() {
9096                self.state.inventory_filter.clear();
9097                self.state.inventory_menu_index = 0;
9098                self.state.clamp_inventory_indices();
9099            } else {
9100                self.state.inventory_filter_focused = false;
9101            }
9102            return true;
9103        }
9104        if !self.state.inventory_filter.is_empty() {
9105            self.state.inventory_filter.clear();
9106            self.state.inventory_menu_index = 0;
9107            self.state.clamp_inventory_indices();
9108            return true;
9109        }
9110        false
9111    }
9112
9113    pub fn craft_menu_page(&mut self, pages: i32) {
9114        let n = self.state.craft_filtered_indices().len();
9115        self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
9116        self.state.clamp_craft_batch_quantity();
9117    }
9118
9119    pub fn shop_menu_page(&mut self, pages: i32) {
9120        let n = self.state.shop_list_len();
9121        self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
9122        self.state.clamp_shop_quantity();
9123    }
9124
9125    pub fn workers_menu_page(&mut self, pages: i32) {
9126        let n = self.state.hired_workers.len();
9127        self.state.workers_menu_index = page_list_index(self.state.workers_menu_index, pages, n);
9128    }
9129
9130    /// Enter: unequip a worn bag, equip a weapon, wear/place a loose bag or
9131    /// chest — or fall back to the "move to…" destination picker (including
9132    /// loose consumables; pick "Use" in that list or press `e` to eat/drink).
9133    /// Placed chest shells open a pick-up destination picker (`l` still locks).
9134    pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
9135        if self.state.show_destroy_picker {
9136            if self.state.destroy_confirm_pending {
9137                return self.confirm_destroy_item().await;
9138            }
9139            return self.request_destroy_confirm();
9140        }
9141        if self.state.show_grant_picker {
9142            return self.confirm_grant_picker().await;
9143        }
9144        if self.state.show_move_picker {
9145            return self.confirm_move_picker().await;
9146        }
9147        let Some(row) = self.state.inventory_selected_row() else {
9148            anyhow::bail!("inventory empty");
9149        };
9150        if row.is_equip_shell {
9151            let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
9152                anyhow::bail!("not a worn item");
9153            };
9154            return self.equip_worn(slot, None).await;
9155        }
9156        if row.is_chest_shell {
9157            return self.open_chest_pickup_picker();
9158        }
9159        let template_id = row.stack.template_id.clone();
9160        let instance_id = row.stack.item_instance_id;
9161        let category = self.state.inventory_item_category(&template_id);
9162        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9163
9164        if category == Some("weapon") {
9165            return self.equip_mainhand(Some(template_id)).await;
9166        }
9167        if category == Some("lodging") && on_person {
9168            if let Some(inst) = instance_id {
9169                return self.place_container(inst).await;
9170            }
9171        }
9172        // Placeable workshop tools (kiln, furnace) — Enter places them like chests.
9173        if on_person {
9174            if let Some(inst) = instance_id {
9175                if row.stack.world_placeable == Some(true) {
9176                    return self.place_container(inst).await;
9177                }
9178            }
9179        }
9180        if (category == Some("container") || category == Some("armor")) && on_person {
9181            if let Some(inst) = instance_id {
9182                let world_placeable =
9183                    row.stack.world_placeable == Some(true) || template_id.contains("chest");
9184                if world_placeable {
9185                    return self.place_container(inst).await;
9186                }
9187                // Pouches no longer equip directly — they clip onto a worn belt's loops
9188                // instead, so fall through to the move picker (offers "belt loop" when a
9189                // belt is worn). Backpacks/belts/armor equip straight to their body slot.
9190                if let Some(slot) = guess_body_slot(&template_id) {
9191                    return self.equip_worn(slot, Some(inst)).await;
9192                }
9193            }
9194        }
9195        // Anything else (materials, consumables, pouches, items nested in a bag/chest,
9196        // weapons you'd rather stash than wield, ...) — offer explicit places to move it
9197        // instead of guessing.
9198        self.open_move_picker()
9199    }
9200
9201    /// `e`: eat/drink a consumable, or open grant-target picker for grant oils/scrolls.
9202    pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
9203        let Some(row) = self.state.inventory_selected_row() else {
9204            anyhow::bail!("inventory empty");
9205        };
9206        if row.from != flatland_protocol::InventoryLocation::Root {
9207            anyhow::bail!("select a consumable on your person");
9208        }
9209        if GameState::stack_is_item_grant(&row.stack) {
9210            return self.open_grant_target_picker();
9211        }
9212        if GameState::is_property_deed_template(&row.stack.template_id) {
9213            return self.open_move_picker();
9214        }
9215        let category = self.state.inventory_item_category(&row.stack.template_id);
9216        if category != Some("consumable") && !GameState::stack_is_serving(&row.stack) {
9217            anyhow::bail!("selected item is not usable");
9218        }
9219        self.use_item(&row.stack.template_id).await
9220    }
9221
9222    /// Open picker: apply selected grant item onto inventory / worn gear.
9223    pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
9224        let Some(row) = self.state.inventory_selected_row() else {
9225            anyhow::bail!("inventory empty");
9226        };
9227        if row.from != flatland_protocol::InventoryLocation::Root {
9228            anyhow::bail!("select a grant item on your person");
9229        }
9230        if !GameState::stack_is_item_grant(&row.stack) {
9231            anyhow::bail!("selected item does not grant onto gear");
9232        }
9233        let Some(grant_instance_id) = row.stack.item_instance_id else {
9234            anyhow::bail!("grant has no instance id");
9235        };
9236        let effect_id = GameState::grant_effect_id(&row.stack)
9237            .unwrap_or("?")
9238            .to_string();
9239        let mode = GameState::grant_mode(&row.stack).to_string();
9240        let options = self.state.grant_target_options(&row.stack);
9241        if options.is_empty() {
9242            anyhow::bail!("no valid gear to apply {effect_id} to");
9243        }
9244        let grant_label = row
9245            .stack
9246            .display_name
9247            .clone()
9248            .unwrap_or_else(|| row.stack.template_id.clone());
9249        self.state.show_grant_picker = true;
9250        self.state.grant_picker_index = 0;
9251        self.state.grant_picker = Some(GrantTargetPicker {
9252            grant_instance_id,
9253            grant_label,
9254            effect_id,
9255            mode,
9256            options,
9257            filter: String::new(),
9258            filter_focused: false,
9259        });
9260        Ok(())
9261    }
9262
9263    pub fn close_grant_picker(&mut self) {
9264        self.state.show_grant_picker = false;
9265        self.state.grant_picker = None;
9266        self.state.grant_picker_index = 0;
9267    }
9268
9269    pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
9270        let Some(picker) = self.state.grant_picker.clone() else {
9271            self.close_grant_picker();
9272            return Ok(());
9273        };
9274        let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
9275            self.close_grant_picker();
9276            return Ok(());
9277        };
9278        self.close_grant_picker();
9279        self.use_grant(picker.grant_instance_id, opt.target_instance_id)
9280            .await?;
9281        self.state
9282            .push_log(format!("Applying {} onto {}…", picker.effect_id, opt.label));
9283        Ok(())
9284    }
9285
9286    /// `m`: always open the "move to…" picker for the selected item, even for
9287    /// weapons/wearables that Enter would otherwise equip/wear directly.
9288    /// Placed chests open the pick-up destination picker instead.
9289    pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
9290        let Some(row) = self.state.inventory_selected_row() else {
9291            anyhow::bail!("inventory empty");
9292        };
9293        if row.is_equip_shell {
9294            anyhow::bail!("this is a worn bag — press Enter to unequip it");
9295        }
9296        if row.is_chest_shell {
9297            return self.open_chest_pickup_picker();
9298        }
9299        let Some(instance_id) = row.stack.item_instance_id else {
9300            anyhow::bail!("item has no instance id");
9301        };
9302        let mut options = self.state.move_destinations_for(
9303            &row.from,
9304            row.from_parent_instance_id,
9305            row.stack.item_instance_id,
9306            &row.stack.template_id,
9307        );
9308        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9309        let category = self.state.inventory_item_category(&row.stack.template_id);
9310        if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
9311            if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
9312                options.insert(
9313                    0,
9314                    MoveOption {
9315                        label: "Sell plot to crown…".into(),
9316                        kind: MoveOptionKind::SellPlotToCrown { plot_id },
9317                    },
9318                );
9319            }
9320        }
9321        if on_person && category == Some("consumable") {
9322            if GameState::stack_is_item_grant(&row.stack) {
9323                options.insert(
9324                    0,
9325                    MoveOption {
9326                        label: "Apply onto gear…".into(),
9327                        kind: MoveOptionKind::GrantApply,
9328                    },
9329                );
9330            } else {
9331                let study = GameState::stack_is_blueprint_scroll(&row.stack);
9332                options.insert(
9333                    0,
9334                    MoveOption {
9335                        label: if study {
9336                            "Study".into()
9337                        } else {
9338                            "Use (eat / drink)".into()
9339                        },
9340                        kind: MoveOptionKind::Use,
9341                    },
9342                );
9343            }
9344        } else if on_person && GameState::stack_is_serving(&row.stack) {
9345            let label = if GameState::stack_is_food_serving(&row.stack) {
9346                "Use (eat)"
9347            } else {
9348                "Use (fill / drink)"
9349            };
9350            options.insert(
9351                0,
9352                MoveOption {
9353                    label: label.into(),
9354                    kind: MoveOptionKind::Use,
9355                },
9356            );
9357        }
9358        let item_label = row
9359            .stack
9360            .display_name
9361            .clone()
9362            .unwrap_or_else(|| row.stack.template_id.clone());
9363        // Default to 1 so withdrawing from storage is partial unless the player
9364        // presses `a` for max fit (full stack when the destination allows it).
9365        let initial_qty = if row.stack.quantity > 1 {
9366            1
9367        } else {
9368            row.stack.quantity
9369        };
9370        self.state.move_picker = Some(MovePicker {
9371            item_instance_id: instance_id,
9372            from: row.from,
9373            item_label,
9374            template_id: row.stack.template_id.clone(),
9375            stack_quantity: row.stack.quantity,
9376            quantity: initial_qty.max(1),
9377            options,
9378            filter: String::new(),
9379            filter_focused: false,
9380        });
9381        self.state.move_picker_index = 0;
9382        self.state.show_move_picker = true;
9383        self.state.show_destroy_picker = false;
9384        self.state.destroy_confirm_pending = false;
9385        self.state.destroy_picker = None;
9386        self.state.clamp_move_picker_quantity();
9387        Ok(())
9388    }
9389
9390    /// Enter/`m` on a placed chest shell: pick destinations to take it into inventory.
9391    pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
9392        let Some(row) = self.state.inventory_selected_row() else {
9393            anyhow::bail!("inventory empty");
9394        };
9395        if !row.is_chest_shell {
9396            anyhow::bail!("not a placed chest");
9397        }
9398        let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
9399            anyhow::bail!("not a placed chest");
9400        };
9401        let Some(instance_id) = row.stack.item_instance_id else {
9402            anyhow::bail!("chest has no instance id");
9403        };
9404        let chest = self
9405            .state
9406            .placed_containers
9407            .iter()
9408            .find(|c| c.id == *container_id)
9409            .cloned()
9410            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
9411        let (px, py) = self.state.player_position();
9412        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
9413            anyhow::bail!("too far from {}", chest.display_name);
9414        }
9415        if chest.locked && !chest.accessible {
9416            anyhow::bail!(
9417                "need the matching key for {} before picking it up",
9418                chest.display_name
9419            );
9420        }
9421        let options = self.state.chest_pickup_destinations(container_id);
9422        let item_label = row
9423            .stack
9424            .display_name
9425            .clone()
9426            .unwrap_or_else(|| row.stack.template_id.clone());
9427        self.state.move_picker = Some(MovePicker {
9428            item_instance_id: instance_id,
9429            from: row.from.clone(),
9430            item_label,
9431            template_id: row.stack.template_id.clone(),
9432            stack_quantity: 1,
9433            quantity: 1,
9434            options,
9435            filter: String::new(),
9436            filter_focused: false,
9437        });
9438        self.state.move_picker_index = 0;
9439        self.state.show_move_picker = true;
9440        self.state.show_destroy_picker = false;
9441        self.state.destroy_confirm_pending = false;
9442        self.state.destroy_picker = None;
9443        Ok(())
9444    }
9445
9446    pub fn close_move_picker(&mut self) {
9447        self.state.show_move_picker = false;
9448        self.state.move_picker = None;
9449    }
9450
9451    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
9452        self.state.move_picker_adjust_quantity(delta);
9453    }
9454
9455    pub fn move_picker_set_quantity_max(&mut self) {
9456        self.state.move_picker_set_quantity_max();
9457    }
9458
9459    pub fn move_picker_set_quantity_min(&mut self) {
9460        self.state.move_picker_set_quantity_min();
9461    }
9462
9463    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
9464        self.state.destroy_picker_adjust_quantity(delta);
9465    }
9466
9467    pub fn destroy_picker_set_quantity_max(&mut self) {
9468        self.state.destroy_picker_set_quantity_max();
9469    }
9470
9471    pub fn destroy_picker_set_quantity_min(&mut self) {
9472        self.state.destroy_picker_set_quantity_min();
9473    }
9474
9475    async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
9476        let Some(picker) = self.state.move_picker.clone() else {
9477            self.close_move_picker();
9478            return Ok(());
9479        };
9480        let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
9481            self.close_move_picker();
9482            return Ok(());
9483        };
9484        match option.kind {
9485            MoveOptionKind::Cancel => {
9486                self.close_move_picker();
9487            }
9488            MoveOptionKind::Use => {
9489                self.close_move_picker();
9490                self.use_item(&picker.template_id).await?;
9491            }
9492            MoveOptionKind::GrantApply => {
9493                self.close_move_picker();
9494                self.open_grant_target_picker()?;
9495            }
9496            MoveOptionKind::SellPlotToCrown { plot_id } => {
9497                self.close_move_picker();
9498                self.confirm_sell_plot_to_crown(plot_id).await?;
9499            }
9500            MoveOptionKind::RelocatePlaced { container_id } => {
9501                self.close_move_picker();
9502                self.state.show_inventory_menu = false;
9503                self.begin_relocate_container(&container_id)?;
9504            }
9505            MoveOptionKind::Drop => {
9506                self.close_move_picker();
9507                if self
9508                    .state
9509                    .hand_equipped_instance_ids()
9510                    .contains(&picker.item_instance_id)
9511                {
9512                    anyhow::bail!("unequip that item first");
9513                }
9514                if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
9515                    if self.state.deed_bound(&stack) {
9516                        anyhow::bail!(
9517                            "cannot drop a property deed — store it or trade it to another player"
9518                        );
9519                    }
9520                    if self.state.key_drop_blocked(&stack) {
9521                        anyhow::bail!("cannot drop the key while its chest is locked");
9522                    }
9523                }
9524                self.drop_item(picker.item_instance_id, picker.from).await?;
9525                self.state
9526                    .push_log(format!("Dropped {}", picker.item_label));
9527            }
9528            MoveOptionKind::PickupPlaced {
9529                container_id,
9530                nest_location,
9531                nest_parent_instance_id,
9532            } => {
9533                self.close_move_picker();
9534                self.pickup_container(container_id.clone()).await?;
9535                let nest_into_bag = nest_parent_instance_id.is_some()
9536                    || !matches!(nest_location, flatland_protocol::InventoryLocation::Root);
9537                if nest_into_bag {
9538                    self.move_item(
9539                        picker.item_instance_id,
9540                        flatland_protocol::InventoryLocation::Root,
9541                        nest_location,
9542                        nest_parent_instance_id,
9543                        None,
9544                    )
9545                    .await?;
9546                    self.state
9547                        .push_log(format!("Picked up {} into bag", picker.item_label));
9548                } else {
9549                    self.state
9550                        .push_log(format!("Picked up {}", picker.item_label));
9551                }
9552            }
9553            MoveOptionKind::Move {
9554                location,
9555                parent_instance_id,
9556            } => {
9557                self.close_move_picker();
9558                let qty = if picker.quantity >= picker.stack_quantity {
9559                    None
9560                } else {
9561                    Some(picker.quantity)
9562                };
9563                self.move_item(
9564                    picker.item_instance_id,
9565                    picker.from,
9566                    location,
9567                    parent_instance_id,
9568                    qty,
9569                )
9570                .await?;
9571                let moved = qty.unwrap_or(picker.stack_quantity);
9572                if moved >= picker.stack_quantity {
9573                    self.state.push_log(format!("Moved {}", picker.item_label));
9574                } else {
9575                    self.state.push_log(format!(
9576                        "Moved {} ×{} of {}",
9577                        picker.item_label, moved, picker.stack_quantity
9578                    ));
9579                }
9580            }
9581        }
9582        Ok(())
9583    }
9584
9585    /// `d`: place furniture (kiln, chest, bed) at your feet, or drop other items
9586    /// as ground loot. Placeable tools must not become loot piles — companions
9587    /// would vacuum them and they'd look "gone".
9588    pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
9589        let Some(row) = self.state.inventory_selected_row() else {
9590            anyhow::bail!("inventory empty");
9591        };
9592        if row.is_equip_shell {
9593            anyhow::bail!("unequip the bag first (Enter), then drop from your person");
9594        }
9595        if row.is_chest_shell {
9596            anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
9597        }
9598        let Some(inst) = row.stack.item_instance_id else {
9599            anyhow::bail!("item has no instance id");
9600        };
9601        if self.state.hand_equipped_instance_ids().contains(&inst) {
9602            anyhow::bail!("unequip that item first");
9603        }
9604        if self.state.deed_bound(&row.stack) {
9605            anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
9606        }
9607        if self.state.key_drop_blocked(&row.stack) {
9608            anyhow::bail!("cannot drop the key while its chest is locked");
9609        }
9610        let label = row
9611            .stack
9612            .display_name
9613            .clone()
9614            .unwrap_or_else(|| row.stack.template_id.clone());
9615        let placeable = row.stack.world_placeable == Some(true)
9616            || row.from == flatland_protocol::InventoryLocation::Root
9617                && matches!(
9618                    self.state.inventory_item_category(&row.stack.template_id).as_deref(),
9619                    Some("lodging")
9620                );
9621        if placeable && row.from == flatland_protocol::InventoryLocation::Root {
9622            self.place_container(inst).await?;
9623            self.state.push_log(format!("Placed {label}"));
9624            return Ok(());
9625        }
9626        self.drop_item(inst, row.from).await?;
9627        self.state.push_log(format!("Dropped {label}"));
9628        Ok(())
9629    }
9630
9631    pub async fn drop_item(
9632        &mut self,
9633        item_instance_id: uuid::Uuid,
9634        from: flatland_protocol::InventoryLocation,
9635    ) -> anyhow::Result<()> {
9636        self.seq += 1;
9637        self.session
9638            .submit_intent(Intent::DropItem {
9639                entity_id: self.state.entity_id,
9640                item_instance_id,
9641                from,
9642                seq: self.seq,
9643            })
9644            .await?;
9645        self.state.intents_sent += 1;
9646        Ok(())
9647    }
9648
9649    /// `x`: open permanent-delete picker for the selected item (quantity + confirm).
9650    pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
9651        let Some(row) = self.state.inventory_selected_row() else {
9652            anyhow::bail!("inventory empty");
9653        };
9654        if row.is_equip_shell {
9655            anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
9656        }
9657        if row.is_chest_shell {
9658            anyhow::bail!("can't destroy a placed chest from the inventory list");
9659        }
9660        let Some(instance_id) = row.stack.item_instance_id else {
9661            anyhow::bail!("item has no instance id");
9662        };
9663        if self
9664            .state
9665            .hand_equipped_instance_ids()
9666            .contains(&instance_id)
9667        {
9668            anyhow::bail!("unequip that item first");
9669        }
9670        if self.state.deed_bound(&row.stack) {
9671            anyhow::bail!(
9672                "cannot destroy a property deed — store it or trade it to another player"
9673            );
9674        }
9675        if self.state.key_drop_blocked(&row.stack) {
9676            anyhow::bail!("cannot destroy the key while its chest is locked");
9677        }
9678        let item_label = row
9679            .stack
9680            .display_name
9681            .clone()
9682            .unwrap_or_else(|| row.stack.template_id.clone());
9683        self.state.destroy_picker = Some(DestroyPicker {
9684            item_instance_id: instance_id,
9685            from: row.from,
9686            item_label,
9687            stack_quantity: row.stack.quantity,
9688            quantity: row.stack.quantity,
9689        });
9690        self.state.destroy_confirm_pending = false;
9691        self.state.show_destroy_picker = true;
9692        self.state.show_move_picker = false;
9693        self.state.move_picker = None;
9694        Ok(())
9695    }
9696
9697    pub fn close_destroy_picker(&mut self) {
9698        self.state.show_destroy_picker = false;
9699        self.state.destroy_confirm_pending = false;
9700        self.state.destroy_picker = None;
9701    }
9702
9703    pub fn cancel_destroy_confirm(&mut self) {
9704        self.state.destroy_confirm_pending = false;
9705    }
9706
9707    pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
9708        if self.state.destroy_picker.is_none() {
9709            self.close_destroy_picker();
9710            return Ok(());
9711        }
9712        self.state.destroy_confirm_pending = true;
9713        Ok(())
9714    }
9715
9716    pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
9717        let Some(picker) = self.state.destroy_picker.clone() else {
9718            self.close_destroy_picker();
9719            return Ok(());
9720        };
9721        let qty = if picker.quantity >= picker.stack_quantity {
9722            None
9723        } else {
9724            Some(picker.quantity)
9725        };
9726        self.destroy_item(picker.item_instance_id, picker.from, qty)
9727            .await?;
9728        let destroyed = qty.unwrap_or(picker.stack_quantity);
9729        if destroyed >= picker.stack_quantity {
9730            self.state
9731                .push_log(format!("Destroyed {}", picker.item_label));
9732        } else {
9733            self.state.push_log(format!(
9734                "Destroyed {} ×{} of {}",
9735                picker.item_label, destroyed, picker.stack_quantity
9736            ));
9737        }
9738        self.close_destroy_picker();
9739        Ok(())
9740    }
9741
9742    pub async fn destroy_item(
9743        &mut self,
9744        item_instance_id: uuid::Uuid,
9745        from: flatland_protocol::InventoryLocation,
9746        quantity: Option<u32>,
9747    ) -> anyhow::Result<()> {
9748        self.seq += 1;
9749        self.session
9750            .submit_intent(Intent::DestroyItem {
9751                entity_id: self.state.entity_id,
9752                item_instance_id,
9753                from,
9754                quantity,
9755                seq: self.seq,
9756            })
9757            .await?;
9758        self.state.intents_sent += 1;
9759        Ok(())
9760    }
9761
9762    /// `l`: lock/unlock a placed chest — selected chest in inventory UI, else nearest.
9763    pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
9764        if let Some(row) = self.state.inventory_selected_row() {
9765            if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
9766                return self.toggle_placed_chest_lock(container_id).await;
9767            }
9768        }
9769        self.toggle_nearby_chest_lock().await
9770    }
9771
9772    pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
9773        let chest = self
9774            .state
9775            .placed_containers
9776            .iter()
9777            .find(|c| c.id == container_id)
9778            .cloned()
9779            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
9780        let (px, py) = self.state.player_position();
9781        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
9782            anyhow::bail!("too far from {}", chest.display_name);
9783        }
9784        if !chest.accessible && chest.locked {
9785            anyhow::bail!(
9786                "need the matching key for {} (each crafted chest has its own key)",
9787                chest.display_name
9788            );
9789        }
9790        let lock = !chest.locked;
9791        self.set_container_locked(
9792            flatland_protocol::InventoryLocation::Placed {
9793                container_id: chest.id.clone(),
9794            },
9795            lock,
9796        )
9797        .await?;
9798        self.state.push_log(if lock {
9799            format!("Locked {}", chest.display_name)
9800        } else {
9801            format!("Unlocked {}", chest.display_name)
9802        });
9803        Ok(())
9804    }
9805
9806    /// `l` outside inventory: lock/unlock the nearest placed chest (within `CONTAINER_RANGE_M`).
9807    pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
9808        let chest = self
9809            .state
9810            .nearest_placed_container(CONTAINER_RANGE_M)
9811            .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
9812        self.toggle_placed_chest_lock(&chest.id).await
9813    }
9814
9815    pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
9816        self.equip_mainhand(None).await
9817    }
9818
9819    pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
9820        if !self.state.is_alive() {
9821            anyhow::bail!("you are dead");
9822        }
9823        self.seq += 1;
9824        self.session
9825            .submit_intent(Intent::EquipOffhand {
9826                entity_id: self.state.entity_id,
9827                template_id,
9828                instance_id: None,
9829                seq: self.seq,
9830            })
9831            .await?;
9832        self.state.intents_sent += 1;
9833        Ok(())
9834    }
9835
9836    pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
9837        self.equip_offhand(None).await
9838    }
9839
9840    pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
9841        let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
9842        for slot in slots {
9843            self.equip_worn(slot, None).await?;
9844        }
9845        Ok(())
9846    }
9847
9848    pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
9849        let (px, py) = self.state.player_position();
9850        let nearest = self
9851            .state
9852            .placed_containers
9853            .iter()
9854            .min_by(|a, b| {
9855                let da = (a.x - px).hypot(a.y - py);
9856                let db = (b.x - px).hypot(b.y - py);
9857                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
9858            })
9859            .cloned();
9860        let Some(chest) = nearest else {
9861            anyhow::bail!("no chest nearby");
9862        };
9863        if (chest.x - px).hypot(chest.y - py) > 2.0 {
9864            anyhow::bail!("too far from chest");
9865        }
9866        self.pickup_container(chest.id).await
9867    }
9868
9869    pub async fn equip_worn(
9870        &mut self,
9871        slot: BodySlot,
9872        instance_id: Option<uuid::Uuid>,
9873    ) -> anyhow::Result<()> {
9874        self.seq += 1;
9875        self.session
9876            .submit_intent(Intent::EquipWorn {
9877                entity_id: self.state.entity_id,
9878                slot,
9879                instance_id,
9880                seq: self.seq,
9881            })
9882            .await?;
9883        self.state.intents_sent += 1;
9884        Ok(())
9885    }
9886
9887    pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
9888        self.seq += 1;
9889        self.session
9890            .submit_intent(Intent::PlaceContainer {
9891                entity_id: self.state.entity_id,
9892                item_instance_id,
9893                seq: self.seq,
9894            })
9895            .await?;
9896        self.state.intents_sent += 1;
9897        Ok(())
9898    }
9899
9900    pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
9901        self.seq += 1;
9902        self.session
9903            .submit_intent(Intent::PickupContainer {
9904                entity_id: self.state.entity_id,
9905                container_id,
9906                seq: self.seq,
9907            })
9908            .await?;
9909        self.state.intents_sent += 1;
9910        Ok(())
9911    }
9912
9913    pub async fn move_item(
9914        &mut self,
9915        item_instance_id: uuid::Uuid,
9916        from: flatland_protocol::InventoryLocation,
9917        to: flatland_protocol::InventoryLocation,
9918        to_parent_instance_id: Option<uuid::Uuid>,
9919        quantity: Option<u32>,
9920    ) -> anyhow::Result<()> {
9921        self.seq += 1;
9922        self.session
9923            .submit_intent(Intent::MoveItem {
9924                entity_id: self.state.entity_id,
9925                item_instance_id,
9926                from,
9927                to,
9928                to_parent_instance_id,
9929                quantity,
9930                seq: self.seq,
9931            })
9932            .await?;
9933        self.state.intents_sent += 1;
9934        Ok(())
9935    }
9936
9937    pub async fn set_container_locked(
9938        &mut self,
9939        location: flatland_protocol::InventoryLocation,
9940        locked: bool,
9941    ) -> anyhow::Result<()> {
9942        self.seq += 1;
9943        self.session
9944            .submit_intent(Intent::SetContainerLocked {
9945                entity_id: self.state.entity_id,
9946                location,
9947                locked,
9948                seq: self.seq,
9949            })
9950            .await?;
9951        self.state.intents_sent += 1;
9952        Ok(())
9953    }
9954
9955    pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
9956        if !self.state.is_alive() {
9957            anyhow::bail!("you are dead");
9958        }
9959        self.seq += 1;
9960        self.session
9961            .submit_intent(Intent::Use {
9962                entity_id: self.state.entity_id,
9963                template_id: template_id.to_string(),
9964                seq: self.seq,
9965            })
9966            .await?;
9967        self.state.intents_sent += 1;
9968        Ok(())
9969    }
9970
9971    /// Bind a grant consumable onto a specific item instance (unique gear status).
9972    pub async fn use_grant(
9973        &mut self,
9974        grant_instance_id: uuid::Uuid,
9975        target_instance_id: uuid::Uuid,
9976    ) -> 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::UseGrant {
9983                entity_id: self.state.entity_id,
9984                grant_instance_id,
9985                target_instance_id,
9986                seq: self.seq,
9987            })
9988            .await?;
9989        self.state.intents_sent += 1;
9990        Ok(())
9991    }
9992
9993    pub fn open_craft_menu(&mut self) {
9994        self.state.show_craft_menu = true;
9995        self.state.show_shop_menu = false;
9996        self.state.shop_catalog = None;
9997        self.state.show_stats = false;
9998        self.state.show_inventory_menu = false;
9999        self.state.reload_craft_prefs();
10000        self.state.craft_tab = CraftTab::Ready;
10001        self.state.craft_filter.clear();
10002        self.state.craft_filter_focused = false;
10003        self.state.craft_menu_index = 0;
10004        self.state.clamp_craft_menu_index();
10005        self.state.craft_batch_quantity = 1;
10006        self.state.clamp_craft_batch_quantity();
10007    }
10008
10009    pub fn close_craft_menu(&mut self) {
10010        self.state.show_craft_menu = false;
10011        self.state.craft_filter_focused = false;
10012    }
10013
10014    pub fn toggle_keychain_menu(&mut self) {
10015        if self.state.show_keychain_menu {
10016            self.close_keychain_menu();
10017        } else {
10018            self.state.show_keychain_menu = true;
10019            self.state.show_craft_menu = false;
10020            self.state.show_shop_menu = false;
10021            self.state.show_inventory_menu = false;
10022            let n = self.state.keychain_entries().len();
10023            if n == 0 {
10024                self.state.keychain_menu_index = 0;
10025            } else {
10026                self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
10027            }
10028        }
10029    }
10030
10031    pub fn close_keychain_menu(&mut self) {
10032        self.state.show_keychain_menu = false;
10033    }
10034
10035    pub fn keychain_menu_move(&mut self, delta: i32) {
10036        let n = self.state.keychain_entries().len();
10037        if n == 0 {
10038            self.state.keychain_menu_index = 0;
10039            return;
10040        }
10041        let idx = self.state.keychain_menu_index as i32 + delta;
10042        self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
10043    }
10044
10045    pub fn keychain_menu_page(&mut self, pages: i32) {
10046        let n = self.state.keychain_entries().len();
10047        self.state.keychain_menu_index = page_list_index(self.state.keychain_menu_index, pages, n);
10048    }
10049
10050    pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
10051        if !self.state.is_alive() {
10052            anyhow::bail!("you are dead");
10053        }
10054        let entries = self.state.keychain_entries();
10055        let Some(entry) = entries.get(self.state.keychain_menu_index) else {
10056            anyhow::bail!("nothing selected");
10057        };
10058        let Some(instance_id) = entry.stack.item_instance_id else {
10059            anyhow::bail!("key has no instance id");
10060        };
10061        if entry.stowed {
10062            self.move_item(
10063                instance_id,
10064                flatland_protocol::InventoryLocation::Keychain,
10065                flatland_protocol::InventoryLocation::Root,
10066                None,
10067                Some(1),
10068            )
10069            .await
10070        } else {
10071            self.move_item(
10072                instance_id,
10073                flatland_protocol::InventoryLocation::Root,
10074                flatland_protocol::InventoryLocation::Keychain,
10075                None,
10076                Some(1),
10077            )
10078            .await
10079        }
10080    }
10081
10082    pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
10083        let npc_id = self.state.shop_catalog.as_ref().map(|c| c.npc_id.clone());
10084        self.state.show_shop_menu = false;
10085        self.state.shop_catalog = None;
10086        self.state.clear_shop_trade_log();
10087        if let Some(npc_id) = npc_id {
10088            self.seq += 1;
10089            self.session
10090                .submit_intent(Intent::ShopClose {
10091                    entity_id: self.state.entity_id,
10092                    npc_id,
10093                    seq: self.seq,
10094                })
10095                .await?;
10096            self.state.intents_sent += 1;
10097        }
10098        Ok(())
10099    }
10100
10101    pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
10102        let Some(panel) = self.state.bank_panel.clone() else {
10103            return Ok(());
10104        };
10105        self.seq += 1;
10106        self.session
10107            .submit_intent(Intent::BankDeposit {
10108                entity_id: self.state.entity_id,
10109                npc_id: panel.npc_id,
10110                amount_copper,
10111                seq: self.seq,
10112            })
10113            .await?;
10114        self.state.intents_sent += 1;
10115        Ok(())
10116    }
10117
10118    pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
10119        let Some(panel) = self.state.bank_panel.clone() else {
10120            return Ok(());
10121        };
10122        self.seq += 1;
10123        self.session
10124            .submit_intent(Intent::BankWithdraw {
10125                entity_id: self.state.entity_id,
10126                npc_id: panel.npc_id,
10127                amount_copper,
10128                seq: self.seq,
10129            })
10130            .await?;
10131        self.state.intents_sent += 1;
10132        Ok(())
10133    }
10134
10135    pub async fn bank_transfer(
10136        &mut self,
10137        to_character_id: Option<uuid::Uuid>,
10138        to_name: String,
10139        amount_copper: u64,
10140    ) -> anyhow::Result<()> {
10141        let Some(panel) = self.state.bank_panel.clone() else {
10142            return Ok(());
10143        };
10144        self.seq += 1;
10145        self.session
10146            .submit_intent(Intent::BankTransfer {
10147                entity_id: self.state.entity_id,
10148                npc_id: panel.npc_id,
10149                to_character_id,
10150                to_name,
10151                amount_copper,
10152                seq: self.seq,
10153            })
10154            .await?;
10155        self.state.intents_sent += 1;
10156        Ok(())
10157    }
10158
10159    pub fn bank_menu_move(&mut self, delta: i32) {
10160        let n = self.state.bank_menu_options().len();
10161        if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
10162            return;
10163        }
10164        let idx = self.state.bank_menu_index as i32;
10165        self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10166    }
10167
10168    pub fn storage_menu_move(&mut self, delta: i32) {
10169        let n = self.state.storage_menu_options().len();
10170        if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
10171            return;
10172        }
10173        let idx = self.state.storage_menu_index as i32;
10174        self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10175    }
10176
10177    pub fn storage_pick_move(&mut self, delta: i32) {
10178        let n = match &self.state.storage_ui_mode {
10179            StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
10180            StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
10181                self.state.storage_vault_options().len()
10182            }
10183            StorageUiMode::Menu
10184            | StorageUiMode::StoreAmount { .. }
10185            | StorageUiMode::TakeAmount { .. }
10186            | StorageUiMode::ShipAmount { .. } => 0,
10187        };
10188        if n == 0 {
10189            return;
10190        }
10191        match &mut self.state.storage_ui_mode {
10192            StorageUiMode::StorePick { index }
10193            | StorageUiMode::TakePick { index }
10194            | StorageUiMode::ShipPick { index, .. } => {
10195                *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10196            }
10197            StorageUiMode::Menu
10198            | StorageUiMode::StoreAmount { .. }
10199            | StorageUiMode::TakeAmount { .. }
10200            | StorageUiMode::ShipAmount { .. } => {}
10201        }
10202    }
10203
10204    pub fn storage_ui_back(&mut self) {
10205        self.state.storage_ui_mode = match &self.state.storage_ui_mode {
10206            StorageUiMode::StoreAmount { pick_index, .. } => {
10207                StorageUiMode::StorePick { index: *pick_index }
10208            }
10209            StorageUiMode::TakeAmount { pick_index, .. } => {
10210                StorageUiMode::TakePick { index: *pick_index }
10211            }
10212            StorageUiMode::ShipAmount {
10213                dest_building_id,
10214                dest_label,
10215                pick_index,
10216                ..
10217            } => StorageUiMode::ShipPick {
10218                dest_building_id: dest_building_id.clone(),
10219                dest_label: dest_label.clone(),
10220                index: *pick_index,
10221            },
10222            StorageUiMode::StorePick { .. }
10223            | StorageUiMode::TakePick { .. }
10224            | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
10225            StorageUiMode::Menu => StorageUiMode::Menu,
10226        };
10227    }
10228
10229    pub fn storage_amount_append_char(&mut self, c: char) {
10230        match &mut self.state.storage_ui_mode {
10231            StorageUiMode::StoreAmount { input, .. }
10232            | StorageUiMode::TakeAmount { input, .. }
10233            | StorageUiMode::ShipAmount { input, .. } => {
10234                if c.is_ascii_digit() && input.len() < 8 {
10235                    input.push(c);
10236                }
10237            }
10238            _ => {}
10239        }
10240    }
10241
10242    pub fn storage_amount_backspace(&mut self) {
10243        match &mut self.state.storage_ui_mode {
10244            StorageUiMode::StoreAmount { input, .. }
10245            | StorageUiMode::TakeAmount { input, .. }
10246            | StorageUiMode::ShipAmount { input, .. } => {
10247                input.pop();
10248            }
10249            _ => {}
10250        }
10251    }
10252
10253    pub fn storage_ui_typing(&self) -> bool {
10254        matches!(
10255            self.state.storage_ui_mode,
10256            StorageUiMode::StoreAmount { .. }
10257                | StorageUiMode::TakeAmount { .. }
10258                | StorageUiMode::ShipAmount { .. }
10259        )
10260    }
10261
10262    pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
10263        match self.state.storage_ui_mode.clone() {
10264            StorageUiMode::Menu => {
10265                let index = self.state.storage_menu_index;
10266                match index {
10267                    0 => {
10268                        let opts = self.state.storage_store_options();
10269                        if opts.is_empty() {
10270                            self.state.push_log("Nothing loose to store.");
10271                            return Ok(());
10272                        }
10273                        self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
10274                    }
10275                    1 => {
10276                        let opts = self.state.storage_vault_options();
10277                        if opts.is_empty() {
10278                            self.state.push_log("Vault is empty.");
10279                            return Ok(());
10280                        }
10281                        self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
10282                    }
10283                    n => {
10284                        let dest = self
10285                            .state
10286                            .storage_panel
10287                            .as_ref()
10288                            .and_then(|p| p.ship_destinations.get(n - 2))
10289                            .cloned();
10290                        let Some(dest) = dest else {
10291                            return Ok(());
10292                        };
10293                        let opts = self.state.storage_vault_options();
10294                        if opts.is_empty() {
10295                            self.state.push_log("Vault is empty — nothing to ship.");
10296                            return Ok(());
10297                        }
10298                        self.state.storage_ui_mode = StorageUiMode::ShipPick {
10299                            dest_building_id: dest.building_id,
10300                            dest_label: dest.label,
10301                            index: 0,
10302                        };
10303                    }
10304                }
10305            }
10306            StorageUiMode::StorePick { index } => {
10307                let opts = self.state.storage_store_options();
10308                let Some(opt) = opts.get(index) else {
10309                    self.state.push_log("Nothing loose to store.");
10310                    self.state.storage_ui_mode = StorageUiMode::Menu;
10311                    return Ok(());
10312                };
10313                self.state.storage_ui_mode = StorageUiMode::StoreAmount {
10314                    pick_index: index,
10315                    item_instance_id: opt.item_instance_id,
10316                    label: opt.label.clone(),
10317                    max_qty: opt.quantity.max(1),
10318                    input: String::new(),
10319                };
10320            }
10321            StorageUiMode::TakePick { index } => {
10322                let opts = self.state.storage_vault_options();
10323                let Some(opt) = opts.get(index) else {
10324                    self.state.push_log("Vault is empty.");
10325                    self.state.storage_ui_mode = StorageUiMode::Menu;
10326                    return Ok(());
10327                };
10328                self.state.storage_ui_mode = StorageUiMode::TakeAmount {
10329                    pick_index: index,
10330                    item_instance_id: opt.item_instance_id,
10331                    label: opt.label.clone(),
10332                    max_qty: opt.quantity.max(1),
10333                    input: String::new(),
10334                };
10335            }
10336            StorageUiMode::ShipPick {
10337                dest_building_id,
10338                dest_label,
10339                index,
10340            } => {
10341                let opts = self.state.storage_vault_options();
10342                let Some(opt) = opts.get(index) else {
10343                    self.state.push_log("Vault is empty — nothing to ship.");
10344                    self.state.storage_ui_mode = StorageUiMode::Menu;
10345                    return Ok(());
10346                };
10347                self.state.storage_ui_mode = StorageUiMode::ShipAmount {
10348                    dest_building_id,
10349                    dest_label,
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::StoreAmount {
10358                item_instance_id,
10359                max_qty,
10360                input,
10361                ..
10362            } => {
10363                let Some(qty) = parse_storage_quantity(&input) else {
10364                    self.state.push_log("Enter a quantity (blank or 0 = all).");
10365                    return Ok(());
10366                };
10367                let qty = qty.map(|n| n.min(max_qty).max(1));
10368                self.storage_store(item_instance_id, qty).await?;
10369                self.state.storage_ui_mode = StorageUiMode::Menu;
10370            }
10371            StorageUiMode::TakeAmount {
10372                item_instance_id,
10373                max_qty,
10374                input,
10375                ..
10376            } => {
10377                let Some(qty) = parse_storage_quantity(&input) else {
10378                    self.state.push_log("Enter a quantity (blank or 0 = all).");
10379                    return Ok(());
10380                };
10381                let qty = qty.map(|n| n.min(max_qty).max(1));
10382                self.storage_take(item_instance_id, qty).await?;
10383                self.state.storage_ui_mode = StorageUiMode::Menu;
10384            }
10385            StorageUiMode::ShipAmount {
10386                dest_building_id,
10387                item_instance_id,
10388                max_qty,
10389                input,
10390                ..
10391            } => {
10392                let Some(qty) = parse_storage_quantity(&input) else {
10393                    self.state.push_log("Enter a quantity (blank or 0 = all).");
10394                    return Ok(());
10395                };
10396                let qty = qty.map(|n| n.min(max_qty).max(1));
10397                self.storage_ship(dest_building_id, item_instance_id, qty)
10398                    .await?;
10399                self.state.storage_ui_mode = StorageUiMode::Menu;
10400            }
10401        }
10402        Ok(())
10403    }
10404
10405    pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
10406        match self.state.bank_ui_mode.clone() {
10407            BankUiMode::Menu => {
10408                let choice = self
10409                    .state
10410                    .bank_menu_options()
10411                    .get(self.state.bank_menu_index)
10412                    .copied()
10413                    .unwrap_or("Deposit…");
10414                match choice {
10415                    "Withdraw…" => {
10416                        self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
10417                            input: String::new(),
10418                        };
10419                    }
10420                    "Deposit all" => self.bank_deposit(0).await?,
10421                    "Withdraw all" => self.bank_withdraw(0).await?,
10422                    "Transfer…" => {
10423                        self.state.bank_ui_mode = BankUiMode::TransferName {
10424                            input: String::new(),
10425                        };
10426                    }
10427                    _ => {
10428                        self.state.bank_ui_mode = BankUiMode::DepositAmount {
10429                            input: String::new(),
10430                        };
10431                    }
10432                }
10433            }
10434            BankUiMode::DepositAmount { input } => {
10435                let Some(amount) = parse_bank_copper_amount(&input) else {
10436                    self.state
10437                        .push_log("Enter a copper amount (blank or 0 = everything on person).");
10438                    return Ok(());
10439                };
10440                self.bank_deposit(amount).await?;
10441                self.state.bank_ui_mode = BankUiMode::Menu;
10442            }
10443            BankUiMode::WithdrawAmount { input } => {
10444                let Some(amount) = parse_bank_copper_amount(&input) else {
10445                    self.state
10446                        .push_log("Enter a copper amount (blank or 0 = full ledger).");
10447                    return Ok(());
10448                };
10449                self.bank_withdraw(amount).await?;
10450                self.state.bank_ui_mode = BankUiMode::Menu;
10451            }
10452            BankUiMode::TransferName { input } => {
10453                let name = input.trim().to_string();
10454                if name.is_empty() {
10455                    self.state.push_log("Enter the recipient character name.");
10456                    return Ok(());
10457                }
10458                self.state.bank_ui_mode = BankUiMode::TransferAmount {
10459                    to_name: name,
10460                    input: String::new(),
10461                };
10462            }
10463            BankUiMode::TransferAmount { to_name, input } => {
10464                let amount: u64 = match input.trim().parse() {
10465                    Ok(v) if v > 0 => v,
10466                    _ => {
10467                        self.state
10468                            .push_log("Enter a positive copper amount to transfer.");
10469                        return Ok(());
10470                    }
10471                };
10472                self.bank_transfer(None, to_name, amount).await?;
10473                self.state.bank_ui_mode = BankUiMode::Menu;
10474            }
10475        }
10476        Ok(())
10477    }
10478
10479    pub fn bank_transfer_back(&mut self) {
10480        match &self.state.bank_ui_mode {
10481            BankUiMode::TransferAmount { to_name, .. } => {
10482                self.state.bank_ui_mode = BankUiMode::TransferName {
10483                    input: to_name.clone(),
10484                };
10485            }
10486            BankUiMode::TransferName { .. }
10487            | BankUiMode::DepositAmount { .. }
10488            | BankUiMode::WithdrawAmount { .. } => {
10489                self.state.bank_ui_mode = BankUiMode::Menu;
10490            }
10491            BankUiMode::Menu => {}
10492        }
10493    }
10494
10495    pub fn bank_transfer_append_char(&mut self, c: char) {
10496        match &mut self.state.bank_ui_mode {
10497            BankUiMode::TransferName { input } => {
10498                if input.len() < 32 && !c.is_control() {
10499                    input.push(c);
10500                }
10501            }
10502            BankUiMode::DepositAmount { input }
10503            | BankUiMode::WithdrawAmount { input }
10504            | BankUiMode::TransferAmount { input, .. } => {
10505                if c.is_ascii_digit() && input.len() < 12 {
10506                    input.push(c);
10507                }
10508            }
10509            BankUiMode::Menu => {}
10510        }
10511    }
10512
10513    pub fn bank_transfer_backspace(&mut self) {
10514        match &mut self.state.bank_ui_mode {
10515            BankUiMode::TransferName { input }
10516            | BankUiMode::DepositAmount { input }
10517            | BankUiMode::WithdrawAmount { input }
10518            | BankUiMode::TransferAmount { input, .. } => {
10519                input.pop();
10520            }
10521            BankUiMode::Menu => {}
10522        }
10523    }
10524
10525    pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
10526        let npc_id = self.state.bank_panel.as_ref().map(|p| p.npc_id.clone());
10527        self.state.clear_bank_panel();
10528        if let Some(npc_id) = npc_id {
10529            self.seq += 1;
10530            self.session
10531                .submit_intent(Intent::BankClose {
10532                    entity_id: self.state.entity_id,
10533                    npc_id,
10534                    seq: self.seq,
10535                })
10536                .await?;
10537            self.state.intents_sent += 1;
10538        }
10539        Ok(())
10540    }
10541
10542    pub async fn storage_store(
10543        &mut self,
10544        item_instance_id: uuid::Uuid,
10545        quantity: Option<u32>,
10546    ) -> anyhow::Result<()> {
10547        let Some(panel) = self.state.storage_panel.clone() else {
10548            return Ok(());
10549        };
10550        self.seq += 1;
10551        self.session
10552            .submit_intent(Intent::StorageStore {
10553                entity_id: self.state.entity_id,
10554                npc_id: panel.npc_id,
10555                item_instance_id,
10556                quantity,
10557                seq: self.seq,
10558            })
10559            .await?;
10560        self.state.intents_sent += 1;
10561        Ok(())
10562    }
10563
10564    pub async fn storage_take(
10565        &mut self,
10566        item_instance_id: uuid::Uuid,
10567        quantity: Option<u32>,
10568    ) -> anyhow::Result<()> {
10569        let Some(panel) = self.state.storage_panel.clone() else {
10570            return Ok(());
10571        };
10572        self.seq += 1;
10573        self.session
10574            .submit_intent(Intent::StorageTake {
10575                entity_id: self.state.entity_id,
10576                npc_id: panel.npc_id,
10577                item_instance_id,
10578                quantity,
10579                seq: self.seq,
10580            })
10581            .await?;
10582        self.state.intents_sent += 1;
10583        Ok(())
10584    }
10585
10586    pub async fn storage_ship(
10587        &mut self,
10588        dest_building_id: String,
10589        item_instance_id: uuid::Uuid,
10590        quantity: Option<u32>,
10591    ) -> anyhow::Result<()> {
10592        let Some(panel) = self.state.storage_panel.clone() else {
10593            return Ok(());
10594        };
10595        self.seq += 1;
10596        self.session
10597            .submit_intent(Intent::StorageShip {
10598                entity_id: self.state.entity_id,
10599                npc_id: panel.npc_id,
10600                dest_building_id,
10601                item_instance_id,
10602                quantity,
10603                seq: self.seq,
10604            })
10605            .await?;
10606        self.state.intents_sent += 1;
10607        Ok(())
10608    }
10609
10610    pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
10611        let npc_id = self.state.storage_panel.as_ref().map(|p| p.npc_id.clone());
10612        self.state.clear_storage_panel();
10613        if let Some(npc_id) = npc_id {
10614            self.seq += 1;
10615            self.session
10616                .submit_intent(Intent::StorageClose {
10617                    entity_id: self.state.entity_id,
10618                    npc_id,
10619                    seq: self.seq,
10620                })
10621                .await?;
10622            self.state.intents_sent += 1;
10623        }
10624        Ok(())
10625    }
10626
10627    pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
10628        let npc_id = self.state.market_panel.as_ref().map(|p| p.npc_id.clone());
10629        self.state.clear_market_panel();
10630        if let Some(npc_id) = npc_id {
10631            self.seq += 1;
10632            self.session
10633                .submit_intent(Intent::MarketClose {
10634                    entity_id: self.state.entity_id,
10635                    npc_id,
10636                    seq: self.seq,
10637                })
10638                .await?;
10639            self.state.intents_sent += 1;
10640        }
10641        Ok(())
10642    }
10643
10644    pub fn market_move_selection(&mut self, delta: i32) {
10645        let indices = self.state.market_filtered_listing_indices();
10646        let n = indices.len();
10647        if n == 0 {
10648            self.state.market_menu_index = 0;
10649            return;
10650        }
10651        let cur = self.state.market_menu_index as i32;
10652        self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
10653    }
10654
10655    pub fn market_page_selection(&mut self, pages: i32) {
10656        let indices = self.state.market_filtered_listing_indices();
10657        let n = indices.len();
10658        if n == 0 {
10659            self.state.market_menu_index = 0;
10660            return;
10661        }
10662        self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
10663    }
10664
10665    pub fn market_list_page(&mut self, pages: i32) {
10666        match &self.state.market_ui_mode {
10667            MarketUiMode::ListSource { index } => {
10668                let n = self.state.market_list_source_options().len();
10669                if n == 0 {
10670                    return;
10671                }
10672                let next = page_list_index(*index, pages, n);
10673                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
10674            }
10675            MarketUiMode::ListPricingMode { index, .. } => {
10676                let next = page_list_index(*index, pages, 2);
10677                if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
10678                {
10679                    *index = next;
10680                }
10681            }
10682            MarketUiMode::ListPick { source, index } => {
10683                let opts = self.state.market_list_item_options(source);
10684                let n = opts.len();
10685                if n == 0 {
10686                    return;
10687                }
10688                let next = page_list_index(*index, pages, n);
10689                self.state.market_ui_mode = MarketUiMode::ListPick {
10690                    source: source.clone(),
10691                    index: next,
10692                };
10693            }
10694            _ => {}
10695        }
10696    }
10697
10698    pub fn market_cycle_category(&mut self, delta: i32) {
10699        let groups = self.state.market_available_category_groups();
10700        // All + groups
10701        let mut labels: Vec<Option<&'static str>> = vec![None];
10702        labels.extend(groups.into_iter().map(Some));
10703        let n = labels.len() as i32;
10704        let cur = labels
10705            .iter()
10706            .position(|g| *g == self.state.market_category_filter)
10707            .unwrap_or(0) as i32;
10708        let next = (cur + delta).rem_euclid(n) as usize;
10709        self.state.market_category_filter = labels[next];
10710        self.state.market_menu_index = 0;
10711        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10712            let source = source.clone();
10713            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10714        }
10715    }
10716
10717    pub fn focus_market_filter(&mut self) {
10718        self.state.market_filter_focused = true;
10719    }
10720
10721    pub fn append_market_filter_char(&mut self, ch: char) {
10722        if !self.state.market_filter_focused {
10723            return;
10724        }
10725        if !is_list_filter_char(ch) {
10726            return;
10727        }
10728        if self.state.market_filter.len() < 48 {
10729            self.state.market_filter.push(ch);
10730            self.state.market_menu_index = 0;
10731            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10732                let source = source.clone();
10733                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10734            }
10735        }
10736    }
10737
10738    pub fn market_filter_backspace(&mut self) {
10739        if !self.state.market_filter_focused {
10740            return;
10741        }
10742        self.state.market_filter.pop();
10743        self.state.market_menu_index = 0;
10744        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10745            let source = source.clone();
10746            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10747        }
10748    }
10749
10750    /// Clears filter text, blurs search, or returns false if already idle.
10751    pub fn clear_or_blur_market_filter(&mut self) -> bool {
10752        if self.state.market_filter_focused {
10753            if !self.state.market_filter.is_empty() {
10754                self.state.market_filter.clear();
10755                self.state.market_menu_index = 0;
10756                if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10757                    let source = source.clone();
10758                    self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10759                }
10760                return true;
10761            }
10762            self.state.market_filter_focused = false;
10763            return true;
10764        }
10765        if !self.state.market_filter.is_empty() {
10766            self.state.market_filter.clear();
10767            self.state.market_menu_index = 0;
10768            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10769                let source = source.clone();
10770                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10771            }
10772            return true;
10773        }
10774        false
10775    }
10776
10777    pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
10778        if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
10779            return self.market_confirm_buy(listing_id, qty).await;
10780        }
10781        let Some(panel) = self.state.market_panel.clone() else {
10782            return Ok(());
10783        };
10784        let indices = self.state.market_filtered_listing_indices();
10785        let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
10786            return Ok(());
10787        };
10788        let Some(listing) = panel.listings.get(raw_idx) else {
10789            return Ok(());
10790        };
10791        if listing.mine {
10792            self.seq += 1;
10793            self.session
10794                .submit_intent(Intent::MarketDelist {
10795                    entity_id: self.state.entity_id,
10796                    npc_id: panel.npc_id.clone(),
10797                    listing_id: listing.listing_id,
10798                    dest: flatland_protocol::GoodsLocation::Person,
10799                    seq: self.seq,
10800                })
10801                .await?;
10802            self.state.intents_sent += 1;
10803            return Ok(());
10804        }
10805        if listing.npc_price {
10806            self.state
10807                .push_log("NPC-price listings are bought by merchants only.");
10808            return Ok(());
10809        }
10810        let qty = 1u32.min(listing.quantity).max(1);
10811        let line = listing.unit_price_copper.saturating_mul(qty as u64);
10812        self.state.market_buy_confirm = Some((
10813            listing.listing_id,
10814            qty,
10815            listing.unit_price_copper,
10816            line,
10817            listing.display_name.clone(),
10818        ));
10819        Ok(())
10820    }
10821
10822    pub async fn market_confirm_buy(
10823        &mut self,
10824        listing_id: uuid::Uuid,
10825        quantity: u32,
10826    ) -> anyhow::Result<()> {
10827        let Some(panel) = self.state.market_panel.clone() else {
10828            self.state.market_buy_confirm = None;
10829            return Ok(());
10830        };
10831        self.state.market_buy_confirm = None;
10832        self.seq += 1;
10833        self.session
10834            .submit_intent(Intent::MarketBuy {
10835                entity_id: self.state.entity_id,
10836                npc_id: panel.npc_id,
10837                listing_id,
10838                quantity,
10839                dest: flatland_protocol::GoodsLocation::Person,
10840                seq: self.seq,
10841            })
10842            .await?;
10843        self.state.intents_sent += 1;
10844        Ok(())
10845    }
10846
10847    /// Begin the list wizard (`l` in market browse).
10848    pub fn market_begin_list(&mut self) {
10849        if self.state.market_panel.is_none() {
10850            return;
10851        }
10852        let sources = self.state.market_list_source_options();
10853        if sources.is_empty() {
10854            self.state.push_log("Nothing to list from.");
10855            return;
10856        }
10857        // One source (person only) → skip straight to item pick when it has stacks.
10858        if sources.len() == 1 {
10859            let (source, _) = sources[0].clone();
10860            let opts = self.state.market_list_item_options(&source);
10861            if opts.is_empty() {
10862                self.state.push_log("Nothing loose to list.");
10863                return;
10864            }
10865            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10866            self.state.market_buy_confirm = None;
10867            return;
10868        }
10869        self.state.market_buy_confirm = None;
10870        self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
10871    }
10872
10873    pub fn market_ui_back(&mut self) {
10874        self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
10875            MarketUiMode::Browse => MarketUiMode::Browse,
10876            MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
10877            MarketUiMode::ListPick { .. } => {
10878                if self.state.market_list_source_options().len() <= 1 {
10879                    MarketUiMode::Browse
10880                } else {
10881                    MarketUiMode::ListSource { index: 0 }
10882                }
10883            }
10884            MarketUiMode::ListAmount {
10885                source, pick_index, ..
10886            } => MarketUiMode::ListPick {
10887                source,
10888                index: pick_index,
10889            },
10890            MarketUiMode::ListPricingMode {
10891                source,
10892                item_instance_id,
10893                template_id,
10894                label,
10895                max_qty,
10896                quantity,
10897                pick_index,
10898                ..
10899            } => {
10900                let input = quantity.map(|q| q.to_string()).unwrap_or_default();
10901                MarketUiMode::ListAmount {
10902                    source,
10903                    pick_index,
10904                    item_instance_id,
10905                    template_id,
10906                    label,
10907                    max_qty,
10908                    input,
10909                }
10910            }
10911            MarketUiMode::ListPrice {
10912                source,
10913                pick_index,
10914                item_instance_id,
10915                template_id,
10916                label,
10917                max_qty,
10918                quantity,
10919                ..
10920            } => MarketUiMode::ListPricingMode {
10921                source,
10922                pick_index,
10923                item_instance_id,
10924                template_id,
10925                label,
10926                quantity,
10927                max_qty,
10928                index: 1,
10929            },
10930        };
10931    }
10932
10933    pub fn market_list_move(&mut self, delta: i32) {
10934        match &self.state.market_ui_mode {
10935            MarketUiMode::ListSource { index } => {
10936                let n = self.state.market_list_source_options().len();
10937                if n == 0 {
10938                    return;
10939                }
10940                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10941                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
10942            }
10943            MarketUiMode::ListPricingMode { index, .. } => {
10944                let next = (*index as i32 + delta).rem_euclid(2) as usize;
10945                if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
10946                {
10947                    *index = next;
10948                }
10949            }
10950            MarketUiMode::ListPick { source, index } => {
10951                let opts = self.state.market_list_item_options(source);
10952                let n = opts.len();
10953                if n == 0 {
10954                    return;
10955                }
10956                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10957                self.state.market_ui_mode = MarketUiMode::ListPick {
10958                    source: source.clone(),
10959                    index: next,
10960                };
10961            }
10962            _ => {}
10963        }
10964    }
10965
10966    pub fn market_list_amount_append_char(&mut self, c: char) {
10967        if !c.is_ascii_digit() {
10968            return;
10969        }
10970        match &mut self.state.market_ui_mode {
10971            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
10972                if input.len() < 12 {
10973                    input.push(c);
10974                }
10975            }
10976            _ => {}
10977        }
10978    }
10979
10980    pub fn market_list_amount_backspace(&mut self) {
10981        match &mut self.state.market_ui_mode {
10982            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
10983                input.pop();
10984            }
10985            _ => {}
10986        }
10987    }
10988
10989    pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
10990        match self.state.market_ui_mode.clone() {
10991            MarketUiMode::Browse => Ok(()),
10992            MarketUiMode::ListSource { index } => {
10993                let sources = self.state.market_list_source_options();
10994                let Some((source, _)) = sources.get(index).cloned() else {
10995                    return Ok(());
10996                };
10997                let opts = self.state.market_list_item_options(&source);
10998                if opts.is_empty() {
10999                    self.state.push_log("Nothing to list from that source.");
11000                    return Ok(());
11001                }
11002                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11003                Ok(())
11004            }
11005            MarketUiMode::ListPick { source, index } => {
11006                let opts = self.state.market_list_item_options(&source);
11007                let Some(opt) = opts.get(index) else {
11008                    self.state.push_log("Nothing to list.");
11009                    self.state.market_ui_mode = MarketUiMode::Browse;
11010                    return Ok(());
11011                };
11012                self.state.market_ui_mode = MarketUiMode::ListAmount {
11013                    source,
11014                    pick_index: index,
11015                    item_instance_id: opt.item_instance_id,
11016                    template_id: opt.template_id.clone(),
11017                    label: opt.label.clone(),
11018                    max_qty: opt.quantity.max(1),
11019                    input: String::new(),
11020                };
11021                Ok(())
11022            }
11023            MarketUiMode::ListAmount {
11024                source,
11025                pick_index,
11026                item_instance_id,
11027                template_id,
11028                label,
11029                max_qty,
11030                input,
11031                ..
11032            } => {
11033                let Some(qty_opt) = parse_storage_quantity(&input) else {
11034                    self.state.push_log("Enter a quantity (blank = all).");
11035                    return Ok(());
11036                };
11037                if let Some(q) = qty_opt {
11038                    if q > max_qty {
11039                        self.state.push_log(format!("Only {max_qty} available."));
11040                        return Ok(());
11041                    }
11042                }
11043                self.state.market_ui_mode = MarketUiMode::ListPricingMode {
11044                    source,
11045                    pick_index,
11046                    item_instance_id,
11047                    template_id,
11048                    label,
11049                    quantity: qty_opt,
11050                    max_qty,
11051                    index: 0,
11052                };
11053                Ok(())
11054            }
11055            MarketUiMode::ListPricingMode {
11056                source,
11057                pick_index,
11058                item_instance_id,
11059                template_id,
11060                label,
11061                quantity,
11062                max_qty,
11063                index,
11064            } => {
11065                if index == 0 {
11066                    if self
11067                        .state
11068                        .npc_market_dump_unit_estimate(&template_id)
11069                        .is_none()
11070                    {
11071                        self.state
11072                            .push_log("That item has no NPC value — use a fixed price instead.");
11073                        return Ok(());
11074                    }
11075                    return self
11076                        .submit_market_list_intent(
11077                            source,
11078                            item_instance_id,
11079                            quantity,
11080                            0,
11081                            true,
11082                            &label,
11083                        )
11084                        .await;
11085                }
11086                self.state.market_ui_mode = MarketUiMode::ListPrice {
11087                    source,
11088                    pick_index,
11089                    item_instance_id,
11090                    template_id,
11091                    label,
11092                    quantity,
11093                    max_qty,
11094                    input: String::new(),
11095                };
11096                Ok(())
11097            }
11098            MarketUiMode::ListPrice {
11099                source,
11100                item_instance_id,
11101                label,
11102                quantity,
11103                input,
11104                ..
11105            } => {
11106                let price = input.trim().parse::<u64>().unwrap_or(0);
11107                if price == 0 {
11108                    self.state
11109                        .push_log("Enter a unit price of at least 1 copper.");
11110                    return Ok(());
11111                }
11112                self.submit_market_list_intent(
11113                    source,
11114                    item_instance_id,
11115                    quantity,
11116                    price,
11117                    false,
11118                    &label,
11119                )
11120                .await
11121            }
11122        }
11123    }
11124
11125    async fn submit_market_list_intent(
11126        &mut self,
11127        source: MarketListSourceKind,
11128        item_instance_id: uuid::Uuid,
11129        quantity: Option<u32>,
11130        unit_price_copper: u64,
11131        npc_price: bool,
11132        label: &str,
11133    ) -> anyhow::Result<()> {
11134        let Some(panel) = self.state.market_panel.clone() else {
11135            self.state.market_ui_mode = MarketUiMode::Browse;
11136            return Ok(());
11137        };
11138        let goods = match source {
11139            MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
11140            MarketListSourceKind::TownStorage { building_id } => {
11141                flatland_protocol::GoodsLocation::TownStorage { building_id }
11142            }
11143        };
11144        self.seq += 1;
11145        self.session
11146            .submit_intent(Intent::MarketList {
11147                entity_id: self.state.entity_id,
11148                npc_id: panel.npc_id,
11149                source: goods,
11150                item_instance_id,
11151                quantity,
11152                unit_price_copper,
11153                npc_price,
11154                seq: self.seq,
11155            })
11156            .await?;
11157        self.state.intents_sent += 1;
11158        if npc_price {
11159            self.state
11160                .push_log(format!("Listing {label} at NPC price…"));
11161        } else {
11162            self.state
11163                .push_log(format!("Listing {label} @ {unit_price_copper} cp…"));
11164        }
11165        self.state.market_ui_mode = MarketUiMode::Browse;
11166        Ok(())
11167    }
11168
11169    /// Close shop and return to the Talk/Trade verb menu when inside an NPC session.
11170    pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
11171        let return_to_verbs = self.state.npc_verb_target.is_some();
11172        self.close_shop_menu().await?;
11173        if return_to_verbs {
11174            self.state.show_npc_verb_menu = true;
11175            self.state.npc_verb_notice = None;
11176        }
11177        Ok(())
11178    }
11179
11180    pub fn shop_tab_toggle(&mut self) {
11181        self.state.shop_tab = match self.state.shop_tab {
11182            ShopTab::Buy => ShopTab::Sell,
11183            ShopTab::Sell => ShopTab::Buy,
11184        };
11185        self.state.shop_menu_index = 0;
11186        if self.state.shop_tab == ShopTab::Sell {
11187            self.state.shop_quantity_set_max();
11188        }
11189        self.state.clamp_shop_selection();
11190    }
11191
11192    pub fn shop_menu_move(&mut self, delta: i32) {
11193        self.state.shop_menu_move(delta);
11194    }
11195
11196    pub fn shop_quantity_adjust(&mut self, delta: i32) {
11197        self.state.shop_quantity_adjust(delta);
11198    }
11199
11200    pub fn shop_quantity_set_max(&mut self) {
11201        self.state.shop_quantity_set_max();
11202    }
11203
11204    pub fn shop_quantity_set_min(&mut self) {
11205        self.state.shop_quantity_set_min();
11206    }
11207
11208    pub fn toggle_quest_menu(&mut self) {
11209        self.state.show_quest_menu = !self.state.show_quest_menu;
11210        if self.state.show_quest_menu {
11211            self.state.quest_menu_index = 0;
11212            self.state.quest_withdraw_confirm = false;
11213            self.state.show_workers_menu = false;
11214        }
11215    }
11216
11217    pub fn toggle_workers_menu(&mut self) {
11218        if self.state.show_workers_menu {
11219            self.close_workers_menu_ui();
11220        } else {
11221            self.state.show_workers_menu = true;
11222            // Keep prior selection/scroll focus; only clamp if the roster shrank.
11223            if self.state.workers_menu_index >= self.state.hired_workers.len() {
11224                self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11225            }
11226            self.state.show_quest_menu = false;
11227            self.close_worker_give_picker();
11228            self.close_worker_give_target_picker();
11229            self.close_worker_take_picker();
11230            self.close_worker_teach_picker();
11231            self.cancel_worker_rename();
11232        }
11233    }
11234
11235    /// Close workers UI layers without network (caller should release attend).
11236    pub fn close_workers_menu_ui(&mut self) {
11237        self.state.show_workers_menu = false;
11238        self.cancel_worker_dismissal();
11239        self.close_worker_give_picker();
11240        self.close_worker_give_target_picker();
11241        self.close_worker_take_picker();
11242        self.close_worker_teach_picker();
11243        self.cancel_worker_rename();
11244    }
11245
11246    /// Open the workers menu focused on `instance_id` and pause that worker's job.
11247    pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
11248        let Some(idx) = self
11249            .state
11250            .hired_workers
11251            .iter()
11252            .position(|w| w.instance_id == instance_id)
11253        else {
11254            anyhow::bail!("worker not found");
11255        };
11256        let label = self.state.hired_workers[idx].label.clone();
11257        self.state.show_workers_menu = true;
11258        self.state.workers_menu_index = idx;
11259        self.state.show_quest_menu = false;
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        self.set_worker_attending(instance_id, true).await?;
11266        self.state
11267            .push_log(format!("Managing {label} — job paused while menu is open"));
11268        Ok(())
11269    }
11270
11271    /// Close workers menu and release any attend pause.
11272    pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
11273        self.close_workers_menu_ui();
11274        self.release_worker_attend().await
11275    }
11276
11277    async fn set_worker_attending(
11278        &mut self,
11279        instance_id: &str,
11280        attending: bool,
11281    ) -> anyhow::Result<()> {
11282        if attending {
11283            if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11284                return Ok(());
11285            }
11286            // Switch target: release previous first.
11287            if let Some(prev) = self.state.attending_worker_instance_id.clone() {
11288                if prev != instance_id {
11289                    self.send_attend_hired_worker(&prev, false).await?;
11290                }
11291            }
11292            self.send_attend_hired_worker(instance_id, true).await?;
11293            self.state.attending_worker_instance_id = Some(instance_id.to_string());
11294        } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11295            self.send_attend_hired_worker(instance_id, false).await?;
11296            self.state.attending_worker_instance_id = None;
11297        }
11298        Ok(())
11299    }
11300
11301    pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
11302        let Some(id) = self.state.attending_worker_instance_id.take() else {
11303            return Ok(());
11304        };
11305        self.send_attend_hired_worker(&id, false).await
11306    }
11307
11308    async fn send_attend_hired_worker(
11309        &mut self,
11310        worker_instance_id: &str,
11311        attending: bool,
11312    ) -> anyhow::Result<()> {
11313        self.seq += 1;
11314        self.session
11315            .submit_intent(Intent::AttendHiredWorker {
11316                entity_id: self.state.entity_id,
11317                worker_instance_id: worker_instance_id.to_string(),
11318                attending,
11319                seq: self.seq,
11320            })
11321            .await?;
11322        self.state.intents_sent += 1;
11323        Ok(())
11324    }
11325
11326    pub fn workers_menu_move(&mut self, delta: i32) {
11327        let n = self.state.hired_workers.len();
11328        if n == 0 {
11329            return;
11330        }
11331        let idx = self.state.workers_menu_index as i32;
11332        self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
11333    }
11334
11335    pub fn toggle_workers_menu_compact(&mut self) {
11336        self.state.workers_menu_compact = !self.state.workers_menu_compact;
11337        let mut cfg = crate::client_config::ClientConfig::load();
11338        let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
11339    }
11340
11341    pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
11342        let Some(worker) = self
11343            .state
11344            .hired_workers
11345            .get(self.state.workers_menu_index)
11346            .cloned()
11347        else {
11348            anyhow::bail!("no worker selected");
11349        };
11350        self.dismiss_worker_by_id(&worker.instance_id, &worker.label)
11351            .await
11352    }
11353
11354    /// Open a confirmation dialog before permanently dismissing the selected worker.
11355    pub fn request_worker_dismissal(&mut self) -> anyhow::Result<()> {
11356        let Some(worker) = self
11357            .state
11358            .hired_workers
11359            .get(self.state.workers_menu_index)
11360            .cloned()
11361        else {
11362            anyhow::bail!("no worker selected");
11363        };
11364        self.state.worker_dismiss_confirmation = Some(WorkerDismissConfirmation {
11365            worker_instance_id: worker.instance_id,
11366            worker_label: worker.label,
11367        });
11368        Ok(())
11369    }
11370
11371    pub fn cancel_worker_dismissal(&mut self) {
11372        self.state.worker_dismiss_confirmation = None;
11373    }
11374
11375    pub async fn confirm_worker_dismissal(&mut self) -> anyhow::Result<()> {
11376        let Some(confirm) = self.state.worker_dismiss_confirmation.clone() else {
11377            return Ok(());
11378        };
11379        self.dismiss_worker_by_id(&confirm.worker_instance_id, &confirm.worker_label)
11380            .await?;
11381        self.cancel_worker_dismissal();
11382        Ok(())
11383    }
11384
11385    async fn dismiss_worker_by_id(
11386        &mut self,
11387        worker_instance_id: &str,
11388        worker_label: &str,
11389    ) -> anyhow::Result<()> {
11390        self.seq += 1;
11391        self.session
11392            .submit_intent(Intent::DismissWorker {
11393                entity_id: self.state.entity_id,
11394                worker_instance_id: worker_instance_id.to_string(),
11395                seq: self.seq,
11396            })
11397            .await?;
11398        self.state.intents_sent += 1;
11399        self.state
11400            .hired_workers
11401            .retain(|w| w.instance_id != worker_instance_id);
11402        if self.state.workers_menu_index >= self.state.hired_workers.len() {
11403            self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11404        }
11405        self.state.push_log(format!("Dismissed {worker_label}"));
11406        Ok(())
11407    }
11408
11409    pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
11410        let Some(worker) = self
11411            .state
11412            .hired_workers
11413            .get(self.state.workers_menu_index)
11414            .cloned()
11415        else {
11416            anyhow::bail!("no worker selected");
11417        };
11418        let mode = match worker.mode {
11419            flatland_protocol::WorkerModeView::Companion => "defender",
11420            flatland_protocol::WorkerModeView::Defender => "job_loop",
11421            flatland_protocol::WorkerModeView::JobLoop => "idle",
11422            flatland_protocol::WorkerModeView::Idle => "companion",
11423        };
11424        self.seq += 1;
11425        self.session
11426            .submit_intent(Intent::SetWorkerMode {
11427                entity_id: self.state.entity_id,
11428                worker_instance_id: worker.instance_id,
11429                mode: mode.into(),
11430                seq: self.seq,
11431            })
11432            .await?;
11433        self.state.intents_sent += 1;
11434        Ok(())
11435    }
11436
11437    pub async fn workers_deliver_selected_to_storage(&mut self) -> anyhow::Result<()> {
11438        let Some(worker) = self
11439            .state
11440            .hired_workers
11441            .get(self.state.workers_menu_index)
11442            .cloned()
11443        else {
11444            anyhow::bail!("no worker selected");
11445        };
11446        if !matches!(worker.mode, flatland_protocol::WorkerModeView::Companion) {
11447            anyhow::bail!("switch the worker to companion mode first");
11448        }
11449        if worker.step_label.starts_with("delivering to ")
11450            || worker.step_label == "returning to you"
11451        {
11452            anyhow::bail!("worker is already delivering to storage");
11453        }
11454        self.seq += 1;
11455        self.session
11456            .submit_intent(Intent::DeliverWorkerToNearestStorage {
11457                entity_id: self.state.entity_id,
11458                worker_instance_id: worker.instance_id.clone(),
11459                seq: self.seq,
11460            })
11461            .await?;
11462        self.state.intents_sent += 1;
11463        self.state.push_log(format!(
11464            "{} is delivering carried items to storage",
11465            worker.label
11466        ));
11467        Ok(())
11468    }
11469
11470    pub async fn workers_cancel_delivery_selected(&mut self) -> anyhow::Result<()> {
11471        let Some(worker) = self
11472            .state
11473            .hired_workers
11474            .get(self.state.workers_menu_index)
11475            .cloned()
11476        else {
11477            anyhow::bail!("no worker selected");
11478        };
11479        if !(worker.step_label.starts_with("delivering to ")
11480            || worker.step_label == "returning to you")
11481        {
11482            anyhow::bail!("worker has no active delivery");
11483        }
11484        self.seq += 1;
11485        self.session
11486            .submit_intent(Intent::CancelWorkerDelivery {
11487                entity_id: self.state.entity_id,
11488                worker_instance_id: worker.instance_id,
11489                seq: self.seq,
11490            })
11491            .await?;
11492        self.state.intents_sent += 1;
11493        self.state
11494            .push_log(format!("Canceled delivery for {}", worker.label));
11495        Ok(())
11496    }
11497
11498    pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
11499        if self.state.hired_workers.is_empty() {
11500            return self.hire_worker_laborer().await;
11501        }
11502        self.workers_toggle_mode_selected().await
11503    }
11504
11505    /// Open a nearby-worker picker for the selected inventory stack (inventory `g`).
11506    /// Always shows a chooser so you can pick Bruce vs Cookie when several are close.
11507    pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11508        let row = self
11509            .state
11510            .inventory_selected_row()
11511            .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
11512            .clone();
11513        if row.from != flatland_protocol::InventoryLocation::Root {
11514            anyhow::bail!("select a carried item to give");
11515        }
11516        let Some(instance_id) = row.stack.item_instance_id else {
11517            anyhow::bail!("that stack can't be given");
11518        };
11519        let options = self.nearby_worker_give_targets();
11520        if options.is_empty() {
11521            anyhow::bail!(
11522                "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
11523            );
11524        }
11525        let item_label = row
11526            .stack
11527            .display_name
11528            .as_deref()
11529            .unwrap_or(&row.stack.template_id)
11530            .to_string();
11531        self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
11532            item_instance_id: instance_id,
11533            item_label,
11534            quantity: None,
11535            options,
11536        });
11537        self.state.worker_give_target_picker_index = 0;
11538        self.state.show_worker_give_target_picker = true;
11539        // Let the target picker own keys (inventory would otherwise swallow ↑↓/Enter).
11540        self.state.show_inventory_menu = false;
11541        Ok(())
11542    }
11543
11544    /// Hired workers within give/take range, nearest first.
11545    pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
11546        let (px, py, _) = self.state.player_position_with_z();
11547        let mut options: Vec<WorkerGiveTargetOption> = self
11548            .state
11549            .hired_workers
11550            .iter()
11551            .filter_map(|w| {
11552                let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
11553                if dist > WORKER_GIVE_RANGE_M {
11554                    return None;
11555                }
11556                Some(WorkerGiveTargetOption {
11557                    instance_id: w.instance_id.clone(),
11558                    label: w.label.clone(),
11559                    distance_m: dist,
11560                })
11561            })
11562            .collect();
11563        options.sort_by(|a, b| {
11564            a.distance_m
11565                .partial_cmp(&b.distance_m)
11566                .unwrap_or(std::cmp::Ordering::Equal)
11567        });
11568        options
11569    }
11570
11571    pub fn close_worker_give_target_picker(&mut self) {
11572        self.state.show_worker_give_target_picker = false;
11573        self.state.worker_give_target_picker = None;
11574        self.state.worker_give_target_picker_index = 0;
11575    }
11576
11577    pub fn worker_give_target_picker_move(&mut self, delta: i32) {
11578        let Some(picker) = &self.state.worker_give_target_picker else {
11579            return;
11580        };
11581        let n = picker.options.len();
11582        if n == 0 {
11583            return;
11584        }
11585        let idx = self.state.worker_give_target_picker_index as i32;
11586        self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11587    }
11588
11589    pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11590        let Some(picker) = self.state.worker_give_target_picker.clone() else {
11591            anyhow::bail!("give target picker not open");
11592        };
11593        let Some(opt) = picker
11594            .options
11595            .get(self.state.worker_give_target_picker_index)
11596            .cloned()
11597        else {
11598            anyhow::bail!("no worker selected");
11599        };
11600        let Some(worker) = self
11601            .state
11602            .hired_workers
11603            .iter()
11604            .find(|w| w.instance_id == opt.instance_id)
11605            .cloned()
11606        else {
11607            self.close_worker_give_target_picker();
11608            anyhow::bail!("worker no longer hired");
11609        };
11610        self.give_item_to_worker(
11611            &worker.instance_id,
11612            &worker.label,
11613            worker.x,
11614            worker.y,
11615            picker.item_instance_id,
11616            &picker.item_label,
11617            picker.quantity,
11618        )
11619        .await?;
11620        self.close_worker_give_target_picker();
11621        Ok(())
11622    }
11623
11624    /// Give the selected inventory stack to a hired worker (opens nearby-worker picker).
11625    pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
11626        self.open_worker_give_target_picker()
11627    }
11628
11629    /// Open the workers-menu give picker for the selected worker (must be in range).
11630    pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
11631        let Some(worker) = self
11632            .state
11633            .hired_workers
11634            .get(self.state.workers_menu_index)
11635            .cloned()
11636        else {
11637            anyhow::bail!("select a hired worker first");
11638        };
11639        let (px, py, _) = self.state.player_position_with_z();
11640        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11641        if dist > WORKER_GIVE_RANGE_M {
11642            anyhow::bail!(
11643                "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
11644                worker.label
11645            );
11646        }
11647        let options = self.state.giveable_inventory_options();
11648        if options.is_empty() {
11649            anyhow::bail!("nothing in inventory to give");
11650        }
11651        self.state.worker_give_picker = Some(WorkerGivePicker {
11652            worker_instance_id: worker.instance_id,
11653            worker_label: worker.label,
11654            options,
11655        });
11656        self.state.worker_give_picker_index = 0;
11657        self.state.show_worker_give_picker = true;
11658        Ok(())
11659    }
11660
11661    pub fn close_worker_give_picker(&mut self) {
11662        self.state.show_worker_give_picker = false;
11663        self.state.worker_give_picker = None;
11664        self.state.worker_give_picker_index = 0;
11665    }
11666
11667    pub fn worker_give_picker_move(&mut self, delta: i32) {
11668        let Some(picker) = &self.state.worker_give_picker else {
11669            return;
11670        };
11671        let n = picker.options.len();
11672        if n == 0 {
11673            return;
11674        }
11675        let idx = self.state.worker_give_picker_index as i32;
11676        self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11677    }
11678
11679    /// Confirm the selected row in the workers-menu give picker.
11680    pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
11681        let Some(picker) = self.state.worker_give_picker.clone() else {
11682            anyhow::bail!("give picker not open");
11683        };
11684        let Some(opt) = picker
11685            .options
11686            .get(self.state.worker_give_picker_index)
11687            .cloned()
11688        else {
11689            anyhow::bail!("no item selected");
11690        };
11691        let Some(worker) = self
11692            .state
11693            .hired_workers
11694            .iter()
11695            .find(|w| w.instance_id == picker.worker_instance_id)
11696            .cloned()
11697        else {
11698            self.close_worker_give_picker();
11699            anyhow::bail!("worker no longer hired");
11700        };
11701        self.give_item_to_worker(
11702            &worker.instance_id,
11703            &worker.label,
11704            worker.x,
11705            worker.y,
11706            opt.item_instance_id,
11707            &opt.label,
11708            None,
11709        )
11710        .await?;
11711        // Refresh options (stack may be gone / reduced) or close when empty.
11712        let options = self.state.giveable_inventory_options();
11713        if options.is_empty() {
11714            self.close_worker_give_picker();
11715        } else {
11716            self.state.worker_give_picker = Some(WorkerGivePicker {
11717                worker_instance_id: picker.worker_instance_id,
11718                worker_label: picker.worker_label,
11719                options,
11720            });
11721            if self.state.worker_give_picker_index
11722                >= self
11723                    .state
11724                    .worker_give_picker
11725                    .as_ref()
11726                    .map(|p| p.options.len())
11727                    .unwrap_or(0)
11728            {
11729                self.state.worker_give_picker_index = self
11730                    .state
11731                    .worker_give_picker
11732                    .as_ref()
11733                    .map(|p| p.options.len().saturating_sub(1))
11734                    .unwrap_or(0);
11735            }
11736        }
11737        Ok(())
11738    }
11739
11740    /// Open the workers-menu teach picker for the selected worker (must be in range).
11741    pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
11742        let Some(worker) = self
11743            .state
11744            .hired_workers
11745            .get(self.state.workers_menu_index)
11746            .cloned()
11747        else {
11748            anyhow::bail!("select a hired worker first");
11749        };
11750        let (px, py, _) = self.state.player_position_with_z();
11751        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11752        if dist > WORKER_GIVE_RANGE_M {
11753            anyhow::bail!(
11754                "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
11755                worker.label
11756            );
11757        }
11758        let options = self.state.teachable_blueprint_options(&worker);
11759        if options.is_empty() {
11760            anyhow::bail!("no recipes you know that {} still needs", worker.label);
11761        }
11762        self.state.worker_teach_picker = Some(WorkerTeachPicker {
11763            worker_instance_id: worker.instance_id,
11764            worker_label: worker.label,
11765            worker_level: worker.level,
11766            options,
11767        });
11768        self.state.worker_teach_picker_index = 0;
11769        self.state.show_worker_teach_picker = true;
11770        Ok(())
11771    }
11772
11773    pub fn close_worker_teach_picker(&mut self) {
11774        self.state.show_worker_teach_picker = false;
11775        self.state.worker_teach_picker = None;
11776        self.state.worker_teach_picker_index = 0;
11777    }
11778
11779    pub fn worker_teach_picker_move(&mut self, delta: i32) {
11780        let Some(picker) = &self.state.worker_teach_picker else {
11781            return;
11782        };
11783        let n = picker.options.len();
11784        if n == 0 {
11785            return;
11786        }
11787        let idx = self.state.worker_teach_picker_index as i32;
11788        self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11789    }
11790
11791    pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
11792        let Some(picker) = self.state.worker_teach_picker.clone() else {
11793            anyhow::bail!("teach picker not open");
11794        };
11795        let Some(opt) = picker
11796            .options
11797            .get(self.state.worker_teach_picker_index)
11798            .cloned()
11799        else {
11800            anyhow::bail!("nothing selected");
11801        };
11802        if !opt.level_ok {
11803            anyhow::bail!(
11804                "{} needs level {} (is level {})",
11805                picker.worker_label,
11806                opt.min_level,
11807                opt.worker_level
11808            );
11809        }
11810        if !opt.can_afford {
11811            anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
11812        }
11813        let Some(worker) = self
11814            .state
11815            .hired_workers
11816            .iter()
11817            .find(|w| w.instance_id == picker.worker_instance_id)
11818            .cloned()
11819        else {
11820            anyhow::bail!("worker gone");
11821        };
11822        let (px, py, _) = self.state.player_position_with_z();
11823        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11824        if dist > WORKER_GIVE_RANGE_M {
11825            anyhow::bail!("worker {} too far — stand next to them", worker.label);
11826        }
11827        self.seq += 1;
11828        self.session
11829            .submit_intent(Intent::TeachWorkerBlueprint {
11830                entity_id: self.state.entity_id,
11831                worker_instance_id: picker.worker_instance_id.clone(),
11832                blueprint_id: opt.blueprint_id.clone(),
11833                seq: self.seq,
11834            })
11835            .await?;
11836        self.state.intents_sent += 1;
11837        self.state.push_log(format!(
11838            "Teaching {} to {} ({} cp)",
11839            opt.label, picker.worker_label, opt.cost_copper
11840        ));
11841        self.close_worker_teach_picker();
11842        Ok(())
11843    }
11844
11845    async fn give_item_to_worker(
11846        &mut self,
11847        worker_instance_id: &str,
11848        worker_label: &str,
11849        worker_x: f32,
11850        worker_y: f32,
11851        item_instance_id: uuid::Uuid,
11852        item_label: &str,
11853        quantity: Option<u32>,
11854    ) -> anyhow::Result<()> {
11855        let (px, py, _) = self.state.player_position_with_z();
11856        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
11857        if dist > WORKER_GIVE_RANGE_M {
11858            anyhow::bail!("worker {worker_label} too far — stand next to them");
11859        }
11860        self.seq += 1;
11861        self.session
11862            .submit_intent(Intent::GiveWorkerItem {
11863                entity_id: self.state.entity_id,
11864                worker_instance_id: worker_instance_id.to_string(),
11865                item_instance_id,
11866                quantity,
11867                seq: self.seq,
11868            })
11869            .await?;
11870        self.state.intents_sent += 1;
11871        self.state
11872            .remove_carried_instance(item_instance_id, quantity);
11873        self.state
11874            .push_log(format!("Gave {item_label} to {worker_label}"));
11875        Ok(())
11876    }
11877
11878    /// Equip one item from the player's inventory on the selected worker.
11879    /// The server validates the requested hand/body slot and replaces the
11880    /// previous item into the worker's pack.
11881    pub async fn equip_item_on_worker(
11882        &mut self,
11883        worker_instance_id: &str,
11884        item_instance_id: uuid::Uuid,
11885        slot: &str,
11886    ) -> anyhow::Result<()> {
11887        let Some(worker) = self
11888            .state
11889            .hired_workers
11890            .iter()
11891            .find(|worker| worker.instance_id == worker_instance_id)
11892            .cloned()
11893        else {
11894            anyhow::bail!("worker not found");
11895        };
11896        let (px, py, _) = self.state.player_position_with_z();
11897        if (worker.x - px).hypot(worker.y - py) > WORKER_GIVE_RANGE_M {
11898            anyhow::bail!("worker {} too far — stand next to them", worker.label);
11899        }
11900        self.seq += 1;
11901        self.session
11902            .submit_intent(Intent::EquipWorkerItem {
11903                entity_id: self.state.entity_id,
11904                worker_instance_id: worker.instance_id.clone(),
11905                item_instance_id,
11906                slot: slot.to_string(),
11907                seq: self.seq,
11908            })
11909            .await?;
11910        self.state.intents_sent += 1;
11911        self.state
11912            .push_log(format!("Equipped {slot} on {}", worker.label));
11913        Ok(())
11914    }
11915
11916    /// Open the workers-menu take picker for the selected worker (must be in range).
11917    pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
11918        let Some(worker) = self
11919            .state
11920            .hired_workers
11921            .get(self.state.workers_menu_index)
11922            .cloned()
11923        else {
11924            anyhow::bail!("select a hired worker first");
11925        };
11926        let (px, py, _) = self.state.player_position_with_z();
11927        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11928        if dist > WORKER_GIVE_RANGE_M {
11929            anyhow::bail!(
11930                "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
11931                worker.label
11932            );
11933        }
11934        let options = Self::worker_inventory_options(&worker);
11935        if options.is_empty() {
11936            anyhow::bail!("{} isn't carrying anything", worker.label);
11937        }
11938        let initial_qty = options
11939            .first()
11940            .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
11941            .unwrap_or(1);
11942        self.state.worker_take_picker = Some(WorkerTakePicker {
11943            worker_instance_id: worker.instance_id,
11944            worker_label: worker.label,
11945            options,
11946            quantity: initial_qty,
11947        });
11948        self.state.worker_take_picker_index = 0;
11949        self.state.show_worker_take_picker = true;
11950        Ok(())
11951    }
11952
11953    fn worker_inventory_options(
11954        worker: &flatland_protocol::HiredWorkerView,
11955    ) -> Vec<WorkerGiveOption> {
11956        worker
11957            .inventory
11958            .iter()
11959            .filter_map(|stack| {
11960                let item_instance_id = stack.item_instance_id?;
11961                let label = stack
11962                    .display_name
11963                    .clone()
11964                    .unwrap_or_else(|| stack.template_id.clone());
11965                let label = if stack.quantity > 1 {
11966                    format!("{label} ×{}", stack.quantity)
11967                } else {
11968                    label
11969                };
11970                Some(WorkerGiveOption {
11971                    item_instance_id,
11972                    label,
11973                    quantity: stack.quantity,
11974                    template_id: stack.template_id.clone(),
11975                })
11976            })
11977            .collect()
11978    }
11979
11980    pub fn close_worker_take_picker(&mut self) {
11981        self.state.show_worker_take_picker = false;
11982        self.state.worker_take_picker = None;
11983        self.state.worker_take_picker_index = 0;
11984    }
11985
11986    pub fn worker_take_picker_move(&mut self, delta: i32) {
11987        let Some(picker) = &self.state.worker_take_picker else {
11988            return;
11989        };
11990        let n = picker.options.len();
11991        if n == 0 {
11992            return;
11993        }
11994        let idx = self.state.worker_take_picker_index as i32;
11995        self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11996        self.clamp_worker_take_quantity();
11997    }
11998
11999    pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
12000        let Some(picker) = &mut self.state.worker_take_picker else {
12001            return;
12002        };
12003        let max = picker
12004            .options
12005            .get(self.state.worker_take_picker_index)
12006            .map(|o| o.quantity.max(1))
12007            .unwrap_or(1);
12008        let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
12009        picker.quantity = next as u32;
12010    }
12011
12012    pub fn worker_take_picker_set_quantity_max(&mut self) {
12013        let Some(picker) = &mut self.state.worker_take_picker else {
12014            return;
12015        };
12016        let max = picker
12017            .options
12018            .get(self.state.worker_take_picker_index)
12019            .map(|o| o.quantity.max(1))
12020            .unwrap_or(1);
12021        picker.quantity = max;
12022    }
12023
12024    pub fn worker_take_picker_set_quantity_min(&mut self) {
12025        let Some(picker) = &mut self.state.worker_take_picker else {
12026            return;
12027        };
12028        picker.quantity = 1;
12029        self.clamp_worker_take_quantity();
12030    }
12031
12032    fn clamp_worker_take_quantity(&mut self) {
12033        let Some(picker) = &mut self.state.worker_take_picker else {
12034            return;
12035        };
12036        let max = picker
12037            .options
12038            .get(self.state.worker_take_picker_index)
12039            .map(|o| o.quantity.max(1))
12040            .unwrap_or(1);
12041        if picker.quantity == 0 || picker.quantity > max {
12042            picker.quantity = if max > 1 { 1 } else { max };
12043        }
12044    }
12045
12046    pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
12047        let Some(picker) = self.state.worker_take_picker.clone() else {
12048            anyhow::bail!("take picker not open");
12049        };
12050        let Some(opt) = picker
12051            .options
12052            .get(self.state.worker_take_picker_index)
12053            .cloned()
12054        else {
12055            anyhow::bail!("no item selected");
12056        };
12057        let Some(worker) = self
12058            .state
12059            .hired_workers
12060            .iter()
12061            .find(|w| w.instance_id == picker.worker_instance_id)
12062            .cloned()
12063        else {
12064            self.close_worker_take_picker();
12065            anyhow::bail!("worker no longer hired");
12066        };
12067        let qty = picker.quantity.clamp(1, opt.quantity.max(1));
12068        let intent_qty = if qty >= opt.quantity { None } else { Some(qty) };
12069        self.take_item_from_worker(
12070            &worker.instance_id,
12071            &worker.label,
12072            worker.x,
12073            worker.y,
12074            opt.item_instance_id,
12075            &opt.label,
12076            intent_qty,
12077        )
12078        .await?;
12079        // Leave the picker open — server Interaction / hired-workers sync refresh
12080        // options. Do not optimistic-strip stacks (rejects used to look like success).
12081        Ok(())
12082    }
12083
12084    async fn take_item_from_worker(
12085        &mut self,
12086        worker_instance_id: &str,
12087        worker_label: &str,
12088        worker_x: f32,
12089        worker_y: f32,
12090        item_instance_id: uuid::Uuid,
12091        item_label: &str,
12092        quantity: Option<u32>,
12093    ) -> anyhow::Result<()> {
12094        let (px, py, _) = self.state.player_position_with_z();
12095        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
12096        if dist > WORKER_GIVE_RANGE_M {
12097            anyhow::bail!("worker {worker_label} too far — stand next to them");
12098        }
12099        self.seq += 1;
12100        self.session
12101            .submit_intent(Intent::TakeWorkerItem {
12102                entity_id: self.state.entity_id,
12103                worker_instance_id: worker_instance_id.to_string(),
12104                item_instance_id,
12105                quantity,
12106                seq: self.seq,
12107            })
12108            .await?;
12109        self.state.intents_sent += 1;
12110        let qty_note = quantity.map(|q| format!(" ×{q}")).unwrap_or_default();
12111        self.state.push_log(format!(
12112            "Taking {item_label}{qty_note} from {worker_label}…"
12113        ));
12114        Ok(())
12115    }
12116
12117    pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
12118        if let Some(since) = self.state.pending_worker_hire_since {
12119            if since.elapsed() < WORKER_HIRE_PENDING_TIMEOUT {
12120                anyhow::bail!("hire request still pending — wait for the worker roster update");
12121            }
12122            self.state.pending_worker_hire_since = None;
12123        }
12124        if !self.state.has_worker_lodging() {
12125            anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
12126        }
12127        self.seq += 1;
12128        self.session
12129            .submit_intent(Intent::HireWorker {
12130                entity_id: self.state.entity_id,
12131                def_id: "worker_laborer".into(),
12132                wage_copper_per_interval: 8,
12133                lodging_container_id: None,
12134                job_yaml: None,
12135                seq: self.seq,
12136            })
12137            .await?;
12138        self.state.intents_sent += 1;
12139        self.state.pending_worker_hire_since = Some(Instant::now());
12140        Ok(())
12141    }
12142
12143    pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
12144        let Some(worker) = self
12145            .state
12146            .hired_workers
12147            .get(self.state.workers_menu_index)
12148            .cloned()
12149        else {
12150            anyhow::bail!("select a hired worker first");
12151        };
12152        let lodging = worker.lodging_container_id.clone().or_else(|| {
12153            crate::worker_route_editor::owned_lodging_container_ids(
12154                &self.state.placed_containers,
12155                self.state.character_id,
12156            )
12157            .into_iter()
12158            .next()
12159            .map(|(id, _)| id)
12160        });
12161        let label = worker.label.clone();
12162        let editor = if let Some(route) = &worker.route {
12163            crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
12164                worker.instance_id,
12165                worker.label,
12166                route,
12167                lodging,
12168            )
12169        } else {
12170            crate::worker_route_editor::WorkerRouteEditorState::new(
12171                worker.instance_id,
12172                worker.label,
12173                lodging,
12174            )
12175        };
12176        self.state.worker_route_editor = Some(editor);
12177        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12178            if let Some(collapsed) =
12179                crate::client_config::ClientConfig::load().worker_route_panel_collapsed
12180            {
12181                ed.panel_collapsed = collapsed;
12182            }
12183        }
12184        self.state.show_workers_menu = false;
12185        self.state.push_log(format!(
12186            "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
12187        ));
12188        Ok(())
12189    }
12190
12191    pub fn close_worker_route_editor(&mut self) {
12192        self.state.worker_route_editor = None;
12193    }
12194
12195    pub fn worker_route_editor_toggle_panel(&mut self) {
12196        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12197            ed.toggle_panel_collapsed();
12198            let collapsed = ed.panel_collapsed;
12199            let mut cfg = crate::client_config::ClientConfig::load();
12200            let _ = cfg.save_worker_route_panel_collapsed(collapsed);
12201        }
12202    }
12203
12204    pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
12205        let n = {
12206            let Some(ed) = self.state.worker_route_editor.as_mut() else {
12207                return;
12208            };
12209            ed.append_waypoint(x, y, z);
12210            ed.stop_count()
12211        };
12212        self.state
12213            .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
12214    }
12215
12216    // ---- picker candidate snapshots ----------------------------------
12217
12218    fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
12219        let (px, py, _) = self.state.player_position_with_z();
12220        let inside = self.state.effective_inside_building();
12221        crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
12222            &self.state.placed_containers,
12223            &self.state.buildings,
12224            self.state.character_id,
12225            px,
12226            py,
12227            &self.state.hired_workers,
12228            inside.as_deref(),
12229        )
12230    }
12231
12232    fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
12233        self.state.route_editor_node_candidates()
12234    }
12235
12236    fn re_open_harvest_picker(&mut self, index: usize, picked: std::collections::BTreeSet<String>) {
12237        use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
12238        let nodes = self.state.route_editor_node_candidates();
12239        let index = if nodes.is_empty() {
12240            ROUTE_PICKER_DONE_ROW
12241        } else {
12242            index.max(1).min(nodes.len())
12243        };
12244        self.re_open_sheet(S::HarvestPicker {
12245            index,
12246            picked,
12247            nodes,
12248        });
12249    }
12250
12251    fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
12252        let (px, py, _) = self.state.player_position_with_z();
12253        crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
12254    }
12255
12256    fn re_template_candidates(&self) -> Vec<String> {
12257        let mut extra = Vec::new();
12258        if let Some(ed) = self.state.worker_route_editor.as_ref() {
12259            for stop in &ed.stops {
12260                match stop {
12261                    crate::worker_route_editor::WorkerRouteStop::DepositAt {
12262                        filter: Some(filter),
12263                        ..
12264                    } => extra.extend(filter.iter().cloned()),
12265                    crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
12266                        extra.push(template.clone());
12267                    }
12268                    crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
12269                        if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint)
12270                        {
12271                            extra.push(bp.output.clone());
12272                            for input in &bp.inputs {
12273                                extra.push(input.template_id.clone());
12274                            }
12275                        }
12276                    }
12277                    crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
12278                        for it in items {
12279                            extra.push(it.template.clone());
12280                        }
12281                    }
12282                    _ => {}
12283                }
12284            }
12285            // Worker-known recipe outputs even if the player hasn't learned them yet.
12286            if let Some(worker) = self
12287                .state
12288                .hired_workers
12289                .iter()
12290                .find(|w| w.instance_id == ed.worker_instance_id)
12291            {
12292                for recipe in &worker.known_blueprint_ids {
12293                    if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
12294                        extra.push(bp.output.clone());
12295                    }
12296                }
12297                for stack in &worker.inventory {
12298                    if !stack.template_id.is_empty() && stack.quantity > 0 {
12299                        extra.push(stack.template_id.clone());
12300                    }
12301                }
12302            }
12303        }
12304        crate::worker_route_editor::route_item_template_candidates(
12305            &self.state.placed_containers,
12306            self.state.character_id,
12307            &self.state.inventory,
12308            &self.state.blueprints,
12309            &self.state.resource_nodes,
12310            &extra,
12311            Some(&self.state.item_catalog),
12312        )
12313    }
12314
12315    fn re_blueprint_ids(&self) -> Vec<String> {
12316        let worker_known: Option<&[String]> = self
12317            .state
12318            .worker_route_editor
12319            .as_ref()
12320            .and_then(|ed| {
12321                self.state
12322                    .hired_workers
12323                    .iter()
12324                    .find(|w| w.instance_id == ed.worker_instance_id)
12325            })
12326            .map(|w| w.known_blueprint_ids.as_slice());
12327        crate::worker_route_editor::worker_craft_blueprint_ids(&self.state.blueprints, worker_known)
12328    }
12329
12330    fn re_bed_candidates(&self) -> Vec<(String, String)> {
12331        crate::worker_route_editor::owned_lodging_container_ids(
12332            &self.state.placed_containers,
12333            self.state.character_id,
12334        )
12335    }
12336
12337    fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
12338        self.state
12339            .placed_containers
12340            .iter()
12341            .find(|c| c.id == container_id)
12342            .map(|c| c.contents.clone())
12343            .unwrap_or_default()
12344    }
12345
12346    // ---- sheet navigation (`plans/33`) --------------------------------
12347
12348    fn re_sheet_supports_filter(&self) -> bool {
12349        use crate::worker_route_editor::RouteEditorSheet as S;
12350        self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12351            matches!(
12352                ed.sheet,
12353                S::HarvestPicker { .. }
12354                    | S::SellItem { .. }
12355                    | S::DepositFilter { .. }
12356                    | S::WithdrawItems { .. }
12357                    | S::WithdrawContainers { .. }
12358                    | S::DepositContainers { .. }
12359                    | S::SellNpcs { .. }
12360                    | S::CraftBlueprint { .. }
12361                    | S::BedPicker { .. }
12362            )
12363        })
12364    }
12365
12366    /// Whether a sheet row is shown under the current `/` filter.
12367    pub fn re_sheet_row_visible(&self, row: usize) -> bool {
12368        use crate::worker_route_editor::{
12369            harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
12370            ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
12371        };
12372        let Some(ed) = self.state.worker_route_editor.as_ref() else {
12373            return false;
12374        };
12375        let filter = &ed.sheet_filter;
12376        match &ed.sheet {
12377            S::HarvestPicker { nodes, .. } => harvest_picker_row_matches(nodes, row, filter),
12378            S::SellItem { templates, .. } => {
12379                if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
12380                    return true;
12381                }
12382                let slot = row.saturating_sub(2);
12383                templates.get(slot).is_some_and(|t| {
12384                    let label = self.state.template_display_name(t);
12385                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12386                })
12387            }
12388            S::DepositFilter { rows, .. } => {
12389                if row >= rows.len() {
12390                    return true;
12391                }
12392                rows.get(row).is_some_and(|(t, _)| {
12393                    let label = self.state.template_display_name(t);
12394                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12395                })
12396            }
12397            S::WithdrawItems { lines, .. } => {
12398                if row >= lines.len() {
12399                    return true;
12400                }
12401                lines.get(row).is_some_and(|l| {
12402                    let label = self.state.template_display_name(&l.template);
12403                    list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
12404                })
12405            }
12406            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12407                self.re_container_candidates().get(row).is_some_and(|c| {
12408                    list_filter_row_matches(
12409                        filter,
12410                        Some(c.dist),
12411                        &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
12412                    )
12413                })
12414            }
12415            S::SellNpcs { .. } => {
12416                if row == 0 {
12417                    return true;
12418                }
12419                self.re_npc_candidates().get(row - 1).is_some_and(|n| {
12420                    list_filter_row_matches(
12421                        filter,
12422                        Some(n.dist),
12423                        &[n.label.as_str(), n.id.as_str()],
12424                    )
12425                })
12426            }
12427            S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
12428                let label = self
12429                    .state
12430                    .blueprints
12431                    .iter()
12432                    .find(|b| &b.id == id)
12433                    .map(|b| {
12434                        if b.label.is_empty() {
12435                            id.as_str()
12436                        } else {
12437                            b.label.as_str()
12438                        }
12439                    })
12440                    .unwrap_or(id.as_str());
12441                list_filter_row_matches(filter, None, &[id.as_str(), label])
12442            }),
12443            S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
12444                list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
12445            }),
12446            _ => true,
12447        }
12448    }
12449
12450    fn re_sheet_clamp_index(&mut self) {
12451        let count = self.re_sheet_row_count();
12452        if count == 0 {
12453            return;
12454        }
12455        let cur = self.re_sheet_index();
12456        if self.re_sheet_row_visible(cur) {
12457            return;
12458        }
12459        for offset in 1..count {
12460            if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
12461                self.re_sheet_set_index(cur + offset);
12462                return;
12463            }
12464            if cur >= offset && self.re_sheet_row_visible(cur - offset) {
12465                self.re_sheet_set_index(cur - offset);
12466                return;
12467            }
12468        }
12469    }
12470
12471    fn re_sheet_set_index(&mut self, index: usize) {
12472        use crate::worker_route_editor::RouteEditorSheet as S;
12473        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12474            return;
12475        };
12476        match &mut ed.sheet {
12477            S::AddMenu { index: slot }
12478            | S::WaypointMenu { index: slot }
12479            | S::HarvestPicker { index: slot, .. }
12480            | S::WithdrawContainers { index: slot }
12481            | S::DepositContainers { index: slot }
12482            | S::SellNpcs { index: slot }
12483            | S::CraftBlueprint { index: slot }
12484            | S::BedPicker { index: slot }
12485            | S::FarmPlotPicker { index: slot, .. }
12486            | S::FarmPlantSeed { index: slot, .. }
12487            | S::WithdrawItems { index: slot, .. }
12488            | S::DepositFilter { index: slot, .. }
12489            | S::SellItem { index: slot, .. } => *slot = index,
12490            _ => {}
12491        }
12492    }
12493
12494    pub fn re_focus_sheet_filter(&mut self) {
12495        if !self.re_sheet_supports_filter() {
12496            return;
12497        }
12498        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12499            ed.sheet_filter_focused = true;
12500        }
12501    }
12502
12503    pub fn re_blur_sheet_filter_keep_text(&mut self) {
12504        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12505            return;
12506        };
12507        if !ed.sheet_filter_focused {
12508            return;
12509        }
12510        ed.sheet_filter_focused = false;
12511        self.re_sheet_clamp_index();
12512    }
12513
12514    pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
12515        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12516            return false;
12517        };
12518        if ed.sheet_filter_focused {
12519            ed.sheet_filter_focused = false;
12520            self.re_sheet_clamp_index();
12521            return true;
12522        }
12523        if !ed.sheet_filter.is_empty() {
12524            ed.sheet_filter.clear();
12525            self.re_sheet_clamp_index();
12526            return true;
12527        }
12528        false
12529    }
12530
12531    pub fn re_append_sheet_filter_char(&mut self, ch: char) {
12532        if ch.is_control() {
12533            return;
12534        }
12535        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12536            return;
12537        };
12538        if !ed.sheet_filter_focused {
12539            return;
12540        }
12541        ed.sheet_filter.push(ch);
12542        self.re_sheet_set_index(0);
12543        self.re_sheet_clamp_index();
12544    }
12545
12546    pub fn re_sheet_filter_backspace(&mut self) {
12547        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12548            return;
12549        };
12550        if !ed.sheet_filter_focused {
12551            return;
12552        }
12553        ed.sheet_filter.pop();
12554        self.re_sheet_set_index(0);
12555        self.re_sheet_clamp_index();
12556    }
12557
12558    /// Row count of the current sheet (for cursor wrapping).
12559    pub fn re_sheet_row_count(&self) -> usize {
12560        use crate::worker_route_editor::{
12561            harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
12562        };
12563        let Some(ed) = self.state.worker_route_editor.as_ref() else {
12564            return 0;
12565        };
12566        match &ed.sheet {
12567            S::Stops => ed.stops.len(),
12568            S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
12569            S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
12570            S::WaypointMapPick => 0,
12571            S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
12572            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12573                self.re_container_candidates().len()
12574            }
12575            S::WithdrawItems { lines, .. } => lines.len() + 1, // + Done row
12576            S::DepositFilter { rows, .. } => rows.len() + 1,   // + Done row
12577            S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, // + auto row
12578            S::SellItem { templates, .. } => sell_item_picker_row_count(templates.len()),
12579            S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
12580            S::WaitEntry { .. } => 1,
12581            S::BedPicker { .. } => self.re_bed_candidates().len(),
12582            S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
12583            S::FarmPlantSeed { seeds, .. } => seeds.len(),
12584        }
12585    }
12586
12587    /// Current sheet cursor index (0 for sheets without one).
12588    pub fn re_sheet_index(&self) -> usize {
12589        use crate::worker_route_editor::RouteEditorSheet as S;
12590        let Some(ed) = self.state.worker_route_editor.as_ref() else {
12591            return 0;
12592        };
12593        match &ed.sheet {
12594            S::AddMenu { index }
12595            | S::WaypointMenu { index }
12596            | S::HarvestPicker { index, .. }
12597            | S::WithdrawContainers { index }
12598            | S::DepositContainers { index }
12599            | S::SellNpcs { index }
12600            | S::CraftBlueprint { index }
12601            | S::BedPicker { index }
12602            | S::FarmPlotPicker { index, .. }
12603            | S::FarmPlantSeed { index, .. }
12604            | S::WithdrawItems { index, .. }
12605            | S::DepositFilter { index, .. }
12606            | S::SellItem { index, .. } => *index,
12607            _ => 0,
12608        }
12609    }
12610
12611    /// Move the current sheet's cursor, wrapping within its rows.
12612    pub fn re_sheet_move(&mut self, delta: i32) {
12613        let count = self.re_sheet_row_count();
12614        if count == 0 {
12615            return;
12616        }
12617        let cur = self.re_sheet_index();
12618        let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
12619        self.re_sheet_set_index(next);
12620    }
12621
12622    pub fn re_sheet_page(&mut self, pages: i32) {
12623        let count = self.re_sheet_row_count();
12624        if count == 0 {
12625            return;
12626        }
12627        let cur = self.re_sheet_index();
12628        let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
12629        self.re_sheet_set_index(next);
12630    }
12631
12632    /// `[`/`]` on a sheet: adjust quantity (withdraw lines, wait ticks).
12633    pub fn re_sheet_adjust(&mut self, delta: i32) {
12634        use crate::worker_route_editor::RouteEditorSheet as S;
12635        let index = self.re_sheet_index();
12636        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12637            return;
12638        };
12639        match &mut ed.sheet {
12640            S::WithdrawItems { lines, .. } => {
12641                if let Some(line) = lines.get_mut(index) {
12642                    line.adjust_qty(delta);
12643                }
12644            }
12645            S::WaitEntry { ticks } => {
12646                *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
12647            }
12648            _ => {}
12649        }
12650    }
12651
12652    pub fn re_sheet_back(&mut self) {
12653        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12654            return;
12655        };
12656        use crate::worker_route_editor::RouteEditorSheet as S;
12657        let was_editing = ed.editing_index.is_some();
12658        let from_top_picker = matches!(
12659            ed.sheet,
12660            S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
12661        );
12662        ed.sheet_back();
12663        if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
12664            // Chest retarget commits on pick; Esc here just ends the edit sheet.
12665            self.state
12666                .push_log("Route: left edit sheet — press s to save current stops".to_string());
12667        }
12668    }
12669
12670    /// True when the editor is at its root sheet (Esc should close it).
12671    pub fn re_at_root_sheet(&self) -> bool {
12672        self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12673            matches!(
12674                ed.sheet,
12675                crate::worker_route_editor::RouteEditorSheet::Stops
12676            )
12677        })
12678    }
12679
12680    pub fn re_open_add_menu(&mut self) {
12681        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12682            ed.open_add_menu();
12683        }
12684    }
12685
12686    pub fn re_open_bed_picker(&mut self) {
12687        let beds = self.re_bed_candidates();
12688        if beds.is_empty() {
12689            self.state
12690                .push_log("Route: place a camp bed first".to_string());
12691            return;
12692        }
12693        let current = self
12694            .state
12695            .worker_route_editor
12696            .as_ref()
12697            .and_then(|ed| ed.lodging_container_id.clone());
12698        let index = current
12699            .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
12700            .unwrap_or(0);
12701        self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
12702    }
12703
12704    fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
12705        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12706            ed.open_sheet(sheet);
12707        }
12708    }
12709
12710    /// Confirm a sheet's stop and log the outcome.
12711    fn re_confirm_stop(&mut self, stop: crate::worker_route_editor::WorkerRouteStop, what: String) {
12712        let appended = self
12713            .state
12714            .worker_route_editor
12715            .as_mut()
12716            .is_some_and(|ed| ed.confirm_stop(stop));
12717        if appended {
12718            self.state.push_log(format!("Route: + {what}"));
12719        } else {
12720            self.state
12721                .push_log(format!("Route: {what} already in route — selected it"));
12722        }
12723    }
12724
12725    fn re_open_withdraw_items(&mut self, container_id: String) {
12726        use crate::worker_route_editor::{
12727            RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
12728        };
12729        let contents = self.re_container_contents(&container_id);
12730        // When editing, keep the stop's item picks even if the player retargets
12731        // to a different (possibly empty) chest — otherwise Done has nothing to
12732        // confirm and the edit appears to "revert".
12733        let existing = self
12734            .state
12735            .worker_route_editor
12736            .as_ref()
12737            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12738            .and_then(|stop| match stop {
12739                WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
12740                _ => None,
12741            })
12742            .unwrap_or_default();
12743        let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
12744        // Commit the new chest immediately while editing so Esc-before-Done still
12745        // keeps the retarget (items update only when Done is pressed).
12746        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12747            let _ = ed.retarget_withdraw_container(container_id.clone());
12748        }
12749        self.re_open_sheet(S::WithdrawItems {
12750            container_id,
12751            lines,
12752            index: 0,
12753        });
12754    }
12755
12756    fn re_withdraw_items_activate(&mut self, index: usize) {
12757        use crate::worker_route_editor::{
12758            RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
12759        };
12760        enum Outcome {
12761            Cycled,
12762            Confirmed(String),
12763            Empty,
12764        }
12765        let outcome = {
12766            let Some(ed) = self.state.worker_route_editor.as_mut() else {
12767                return;
12768            };
12769            let S::WithdrawItems {
12770                container_id,
12771                lines,
12772                index: sheet_index,
12773            } = &mut ed.sheet
12774            else {
12775                return;
12776            };
12777            *sheet_index = index;
12778            if index < lines.len() {
12779                lines[index].cycle();
12780                Outcome::Cycled
12781            } else {
12782                let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
12783                if items.is_empty() {
12784                    Outcome::Empty
12785                } else {
12786                    let stop = WorkerRouteStop::WithdrawFrom {
12787                        container_id: container_id.clone(),
12788                        items,
12789                    };
12790                    let summary = stop.summary();
12791                    ed.confirm_stop(stop);
12792                    Outcome::Confirmed(summary)
12793                }
12794            }
12795        };
12796        match outcome {
12797            Outcome::Cycled => {}
12798            Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
12799            Outcome::Empty => self.state.push_log(
12800                "Route: pick at least one item (Space/Enter toggles All/qty)".to_string(),
12801            ),
12802        }
12803    }
12804
12805    fn re_open_deposit_filter(&mut self, container_id: String) {
12806        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12807        // Preserve filter when retargeting the deposit chest while editing.
12808        let existing_filter = self
12809            .state
12810            .worker_route_editor
12811            .as_ref()
12812            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12813            .and_then(|stop| match stop {
12814                WorkerRouteStop::DepositAt { filter, .. } => {
12815                    Some(filter.clone().unwrap_or_default())
12816                }
12817                _ => None,
12818            });
12819        let mut candidates = self.re_template_candidates();
12820        if let Some(ref chosen) = existing_filter {
12821            for t in chosen {
12822                if !candidates.iter().any(|c| c == t) {
12823                    candidates.push(t.clone());
12824                }
12825            }
12826            candidates.sort();
12827            candidates.dedup();
12828        }
12829        let rows: Vec<(String, bool)> = match existing_filter {
12830            Some(chosen) => candidates
12831                .iter()
12832                .map(|t| (t.clone(), chosen.contains(t)))
12833                .collect(),
12834            None => candidates.into_iter().map(|t| (t, false)).collect(),
12835        };
12836        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12837            let _ = ed.retarget_deposit_container(container_id.clone());
12838        }
12839        self.re_open_sheet(S::DepositFilter {
12840            container_id,
12841            rows,
12842            index: 0,
12843        });
12844    }
12845
12846    fn re_deposit_filter_activate(&mut self, index: usize) {
12847        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12848        let mut confirmed: Option<String> = None;
12849        {
12850            let Some(ed) = self.state.worker_route_editor.as_mut() else {
12851                return;
12852            };
12853            let S::DepositFilter {
12854                container_id,
12855                rows,
12856                index: sheet_index,
12857            } = &mut ed.sheet
12858            else {
12859                return;
12860            };
12861            *sheet_index = index;
12862            if index < rows.len() {
12863                rows[index].1 = !rows[index].1;
12864            } else {
12865                // "Done" row.
12866                let chosen: Vec<String> = rows
12867                    .iter()
12868                    .filter(|(_, on)| *on)
12869                    .map(|(t, _)| t.clone())
12870                    .collect();
12871                let filter = if chosen.is_empty() {
12872                    None
12873                } else {
12874                    Some(chosen)
12875                };
12876                let stop = WorkerRouteStop::DepositAt {
12877                    container_id: container_id.clone(),
12878                    filter,
12879                };
12880                confirmed = Some(stop.summary());
12881                ed.confirm_stop(stop);
12882            }
12883        }
12884        if let Some(what) = confirmed {
12885            self.state.push_log(format!("Route: + {what}"));
12886        }
12887    }
12888
12889    fn re_open_sell_item(&mut self, npc_id: Option<String>) {
12890        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12891        // Prefill when editing an existing sell stop.
12892        let (pre_npc, pre_template, pre_all) = self
12893            .state
12894            .worker_route_editor
12895            .as_ref()
12896            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12897            .and_then(|stop| match stop {
12898                WorkerRouteStop::TradeWith {
12899                    npc_id,
12900                    template,
12901                    sell_all,
12902                } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
12903                _ => None,
12904            })
12905            .unwrap_or((None, None, true));
12906        let npc_id = npc_id.or(pre_npc);
12907        let mut templates = crate::worker_route_editor::sellable_route_item_template_candidates(
12908            &self.re_template_candidates(),
12909            &self.state.npcs,
12910            npc_id.as_deref(),
12911        );
12912        // Keep an existing invalid selection visible long enough to remove it
12913        // when editing a route after NPC buy lists change.
12914        if let Some(template) = pre_template.as_ref() {
12915            if !templates.iter().any(|candidate| candidate == template) {
12916                templates.push(template.clone());
12917                templates.sort();
12918            }
12919        }
12920        if templates.is_empty() {
12921            self.state
12922                .push_log("Route: no sellable item templates for that merchant".to_string());
12923            return;
12924        }
12925        let mut picked = std::collections::BTreeSet::new();
12926        if let Some(t) = pre_template {
12927            picked.insert(t);
12928        }
12929        self.re_open_sheet(S::SellItem {
12930            npc_id,
12931            templates,
12932            index: if picked.is_empty() {
12933                crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
12934            } else {
12935                2
12936            },
12937            sell_all: pre_all,
12938            picked,
12939        });
12940    }
12941
12942    fn re_sell_item_activate(&mut self, index: usize) {
12943        use crate::worker_route_editor::{
12944            RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
12945        };
12946        let mut batch_log: Option<String> = None;
12947        {
12948            let Some(ed) = self.state.worker_route_editor.as_mut() else {
12949                return;
12950            };
12951            let S::SellItem {
12952                npc_id,
12953                templates,
12954                index: sheet_index,
12955                sell_all,
12956                picked,
12957            } = &mut ed.sheet
12958            else {
12959                return;
12960            };
12961            *sheet_index = index;
12962            if index == ROUTE_PICKER_DONE_ROW {
12963                if picked.is_empty() {
12964                    batch_log =
12965                        Some("Route: pick at least one item (Space toggles, Done confirms)".into());
12966                } else {
12967                    let picks: Vec<String> = picked.iter().cloned().collect();
12968                    let npc = npc_id.clone();
12969                    let all = *sell_all;
12970                    let added = ed.confirm_trade_picks(npc, &picks, all);
12971                    batch_log = Some(format!("Route: + {added} sell stop(s)"));
12972                }
12973            } else if index == SELL_ITEM_TOGGLE_ROW {
12974                *sell_all = !*sell_all;
12975            } else if let Some(template) = templates.get(index.saturating_sub(2)) {
12976                let sellable = crate::worker_route_editor::sellable_route_item_template_candidates(
12977                    std::slice::from_ref(template),
12978                    &self.state.npcs,
12979                    npc_id.as_deref(),
12980                )
12981                .iter()
12982                .any(|candidate| candidate == template);
12983                if !sellable && !picked.contains(template) {
12984                    return;
12985                }
12986                if picked.contains(template) {
12987                    picked.remove(template);
12988                } else {
12989                    picked.insert(template.clone());
12990                }
12991            }
12992        }
12993        if let Some(msg) = batch_log {
12994            self.state.push_log(msg);
12995        }
12996    }
12997
12998    /// Enter on the stop list: open the selected stop's sheet, prefilled.
12999    pub fn re_edit_selected_stop(&mut self) {
13000        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13001        let Some(stop) = self
13002            .state
13003            .worker_route_editor
13004            .as_ref()
13005            .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
13006        else {
13007            self.state
13008                .push_log("Route: no stop selected — press a to add one".to_string());
13009            return;
13010        };
13011        if let Some(ed) = self.state.worker_route_editor.as_mut() {
13012            ed.begin_edit_selected();
13013        }
13014        match stop {
13015            WorkerRouteStop::Waypoint { .. } => {
13016                self.re_open_sheet(S::WaypointMenu { index: 0 });
13017            }
13018            WorkerRouteStop::HarvestNode { node_id } => {
13019                let nodes = self.state.route_editor_node_candidates();
13020                if nodes.is_empty() {
13021                    self.re_cancel_edit();
13022                    self.state
13023                        .push_log("Route: no harvestable nodes visible to retarget".to_string());
13024                } else {
13025                    let mut picked = std::collections::BTreeSet::new();
13026                    picked.insert(node_id.clone());
13027                    let index = nodes
13028                        .iter()
13029                        .position(|n| n.id == node_id)
13030                        .map(|i| i + 1)
13031                        .unwrap_or(1);
13032                    self.re_open_harvest_picker(index, picked);
13033                }
13034            }
13035            WorkerRouteStop::WithdrawFrom { container_id, .. } => {
13036                // Open the container picker so the player can retarget storage
13037                // (previously jumped straight to items on the same chest).
13038                let containers = self.re_container_candidates();
13039                if containers.is_empty() {
13040                    self.re_cancel_edit();
13041                    self.state
13042                        .push_log("Route: place a storage chest first".to_string());
13043                } else {
13044                    let index = containers
13045                        .iter()
13046                        .position(|c| c.id == container_id)
13047                        .unwrap_or(0);
13048                    self.re_open_sheet(S::WithdrawContainers { index });
13049                }
13050            }
13051            WorkerRouteStop::DepositAt { container_id, .. } => {
13052                let containers = self.re_container_candidates();
13053                if containers.is_empty() {
13054                    self.re_cancel_edit();
13055                    self.state
13056                        .push_log("Route: place a storage chest first".to_string());
13057                } else {
13058                    let index = containers
13059                        .iter()
13060                        .position(|c| c.id == container_id)
13061                        .unwrap_or(0);
13062                    self.re_open_sheet(S::DepositContainers { index });
13063                }
13064            }
13065            WorkerRouteStop::TradeWith { npc_id, .. } => {
13066                let npcs = self.re_npc_candidates();
13067                // Row 0 is "auto / nearest"; rows 1.. map to npcs.
13068                let index = npc_id
13069                    .as_ref()
13070                    .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
13071                    .unwrap_or(0);
13072                self.re_open_sheet(S::SellNpcs { index });
13073            }
13074            WorkerRouteStop::CraftAt { blueprint, .. } => {
13075                let bps = self.re_blueprint_ids();
13076                let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
13077                if bps.is_empty() {
13078                    self.re_cancel_edit();
13079                    self.state
13080                        .push_log("Route: no known blueprints to retarget".to_string());
13081                } else {
13082                    self.re_open_sheet(S::CraftBlueprint { index });
13083                }
13084            }
13085            WorkerRouteStop::CultivatePlot { .. } => {
13086                self.re_open_farm_plot_picker(
13087                    crate::worker_route_editor::FarmPlotAction::Cultivate,
13088                );
13089            }
13090            WorkerRouteStop::PlantPlot { .. } => {
13091                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
13092            }
13093            WorkerRouteStop::HarvestPlot { .. } => {
13094                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
13095            }
13096            WorkerRouteStop::RestIfNeeded => {
13097                self.re_cancel_edit();
13098                self.state
13099                    .push_log("Route: rest has no settings (change the bed with l)".to_string());
13100            }
13101            WorkerRouteStop::Wait { wait_ticks } => {
13102                self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
13103            }
13104        }
13105    }
13106
13107    fn re_cancel_edit(&mut self) {
13108        if let Some(ed) = self.state.worker_route_editor.as_mut() {
13109            ed.editing_index = None;
13110        }
13111    }
13112
13113    // ---- mouse clicks inside the overlay ------------------------------
13114
13115    pub fn worker_route_editor_ui_click(
13116        &mut self,
13117        click: crate::worker_route_editor::RouteEditorClick,
13118    ) {
13119        use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
13120        match click {
13121            RouteEditorClick::SelectStop(i) => {
13122                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13123                    ed.sheet = S::Stops;
13124                    ed.select_stop(i);
13125                }
13126            }
13127            RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
13128            RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
13129            RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
13130        }
13131    }
13132
13133    /// Activate (Enter / click) a row of the current sheet.
13134    pub fn re_sheet_row_activate(&mut self, row: usize) {
13135        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13136        let Some(sheet) = self
13137            .state
13138            .worker_route_editor
13139            .as_ref()
13140            .map(|ed| ed.sheet.clone())
13141        else {
13142            return;
13143        };
13144        match sheet {
13145            S::Stops => {
13146                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13147                    ed.select_stop(row);
13148                }
13149            }
13150            S::AddMenu { .. } => match row {
13151                0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
13152                1 => {
13153                    if self.re_node_candidates().is_empty() {
13154                        self.state.push_log(
13155                            "Route: no harvestable nodes visible in this region".to_string(),
13156                        );
13157                    } else {
13158                        self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
13159                    }
13160                }
13161                2 | 3 => {
13162                    if self.re_container_candidates().is_empty() {
13163                        self.state
13164                            .push_log("Route: place a storage chest first".to_string());
13165                    } else if row == 2 {
13166                        self.re_open_sheet(S::WithdrawContainers { index: 0 });
13167                    } else {
13168                        self.re_open_sheet(S::DepositContainers { index: 0 });
13169                    }
13170                }
13171                4 => {
13172                    if self.re_template_candidates().is_empty() {
13173                        self.state.push_log(
13174                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
13175                                .to_string(),
13176                        );
13177                    } else {
13178                        self.re_open_sheet(S::SellNpcs { index: 0 });
13179                    }
13180                }
13181                5 => {
13182                    if self.re_blueprint_ids().is_empty() {
13183                        self.state.push_log(
13184                            "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
13185                                .to_string(),
13186                        );
13187                    } else {
13188                        self.re_open_sheet(S::CraftBlueprint { index: 0 });
13189                    }
13190                }
13191                6 => self.re_confirm_stop(
13192                    WorkerRouteStop::RestIfNeeded,
13193                    "rest at lodging (if needed)".into(),
13194                ),
13195                7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
13196                8 => self.re_open_farm_plot_picker(
13197                    crate::worker_route_editor::FarmPlotAction::Cultivate,
13198                ),
13199                9 => {
13200                    self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant)
13201                }
13202                10 => self
13203                    .re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
13204                _ => {}
13205            },
13206            S::WaypointMenu { .. } => match row {
13207                0 => {
13208                    let (x, y, z) = self.state.player_position_with_z();
13209                    let stop = WorkerRouteStop::Waypoint { x, y, z };
13210                    self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13211                }
13212                1 => {
13213                    self.re_open_sheet(S::WaypointMapPick);
13214                    self.state.push_log(
13215                        "Route: click the map to place the waypoint (Esc to finish)".to_string(),
13216                    );
13217                }
13218                _ => {}
13219            },
13220            S::HarvestPicker { .. } => {
13221                let mut log: Option<String> = None;
13222                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13223                    let S::HarvestPicker {
13224                        index: sheet_index,
13225                        picked,
13226                        nodes,
13227                    } = &mut ed.sheet
13228                    else {
13229                        return;
13230                    };
13231                    *sheet_index = row;
13232                    if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
13233                        if picked.is_empty() {
13234                            log = Some(
13235                                "Route: pick at least one node (Space toggles, Done confirms)"
13236                                    .into(),
13237                            );
13238                        } else {
13239                            let ids: Vec<String> = picked.iter().cloned().collect();
13240                            let added = ed.confirm_harvest_picks(&ids);
13241                            log = Some(format!("Route: + {added} harvest stop(s)"));
13242                        }
13243                    } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
13244                        if picked.contains(&n.id) {
13245                            picked.remove(&n.id);
13246                        } else {
13247                            picked.insert(n.id.clone());
13248                        }
13249                    }
13250                }
13251                if let Some(msg) = log {
13252                    self.state.push_log(msg);
13253                }
13254            }
13255            S::WithdrawContainers { .. } => {
13256                let containers = self.re_container_candidates();
13257                if let Some(c) = containers.get(row) {
13258                    let id = c.id.clone();
13259                    self.re_open_withdraw_items(id);
13260                }
13261            }
13262            S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
13263            S::DepositContainers { .. } => {
13264                let containers = self.re_container_candidates();
13265                if let Some(c) = containers.get(row) {
13266                    let id = c.id.clone();
13267                    self.re_open_deposit_filter(id);
13268                }
13269            }
13270            S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
13271            S::SellNpcs { .. } => {
13272                let npcs = self.re_npc_candidates();
13273                let npc_id = if row == 0 {
13274                    None
13275                } else {
13276                    npcs.get(row - 1).map(|n| n.id.clone())
13277                };
13278                if row == 0 || npc_id.is_some() {
13279                    self.re_open_sell_item(npc_id);
13280                }
13281            }
13282            S::SellItem { .. } => self.re_sell_item_activate(row),
13283            S::CraftBlueprint { .. } => {
13284                let bps = self.re_blueprint_ids();
13285                if let Some(bp) = bps.get(row) {
13286                    let stop = WorkerRouteStop::CraftAt {
13287                        device: "hand".into(),
13288                        blueprint: bp.clone(),
13289                        qty: None,
13290                    };
13291                    self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
13292                }
13293            }
13294            S::WaitEntry { ticks } => {
13295                let stop = WorkerRouteStop::Wait { wait_ticks: ticks };
13296                self.re_confirm_stop(stop, format!("wait {ticks}t"));
13297            }
13298            S::BedPicker { .. } => {
13299                let beds = self.re_bed_candidates();
13300                if let Some((id, name)) = beds.get(row) {
13301                    let (id, name) = (id.clone(), name.clone());
13302                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13303                        ed.lodging_container_id = Some(id.clone());
13304                        ed.sheet = S::Stops;
13305                    }
13306                    self.state
13307                        .push_log(format!("Route: rest bed set to {name}"));
13308                }
13309            }
13310            S::FarmPlotPicker { action, .. } => {
13311                let plots = self.re_farm_plot_candidates();
13312                let Some(plot) = plots.get(row).cloned() else {
13313                    return;
13314                };
13315                match action {
13316                    crate::worker_route_editor::FarmPlotAction::Cultivate => {
13317                        let label = plot_route_label(&plot);
13318                        self.re_confirm_stop(
13319                            WorkerRouteStop::CultivatePlot {
13320                                plot_id: plot.plot_id,
13321                            },
13322                            format!("cultivate {label}"),
13323                        );
13324                    }
13325                    crate::worker_route_editor::FarmPlotAction::Harvest => {
13326                        let label = plot_route_label(&plot);
13327                        self.re_confirm_stop(
13328                            WorkerRouteStop::HarvestPlot {
13329                                plot_id: plot.plot_id,
13330                            },
13331                            format!("harvest {label}"),
13332                        );
13333                    }
13334                    crate::worker_route_editor::FarmPlotAction::Plant => {
13335                        let seeds = self.re_farm_seed_candidates();
13336                        if seeds.is_empty() {
13337                            self.state.push_log(
13338                                "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
13339                            );
13340                            return;
13341                        }
13342                        self.re_open_sheet(S::FarmPlantSeed {
13343                            plot_id: plot.plot_id,
13344                            seeds,
13345                            index: 0,
13346                        });
13347                    }
13348                }
13349            }
13350            S::FarmPlantSeed { plot_id, seeds, .. } => {
13351                if let Some(seed) = seeds.get(row).cloned() {
13352                    self.re_confirm_stop(
13353                        WorkerRouteStop::PlantPlot {
13354                            plot_id,
13355                            seed_template: seed.clone(),
13356                        },
13357                        format!("plant {seed}"),
13358                    );
13359                }
13360            }
13361            S::WaypointMapPick => {}
13362        }
13363    }
13364
13365    fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
13366        use crate::worker_route_editor::RouteEditorSheet as S;
13367        if self.re_farm_plot_candidates().is_empty() {
13368            self.state
13369                .push_log("Route: no farmable plots visible — claim land or get farm access first");
13370            return;
13371        }
13372        self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
13373    }
13374
13375    fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
13376        self.state
13377            .property_plots
13378            .iter()
13379            .filter(|p| p.is_mine || p.may_farm)
13380            .cloned()
13381            .collect()
13382    }
13383
13384    /// Seed templates for a plant-plot route stop — does **not** require seeds on the
13385    /// player. Unions inventory, owned storage, withdraw lines already in the draft,
13386    /// and catalog `seed_for` templates so routes can withdraw-then-plant.
13387    fn re_farm_seed_candidates(&self) -> Vec<String> {
13388        let mut set = std::collections::BTreeSet::new();
13389        let looks_like_seed = |id: &str, catalog: &std::collections::HashMap<String, ItemCatalogEntryView>| {
13390            catalog.get(id).is_some_and(|e| e.is_farm_seed()) || id.ends_with("_seed")
13391        };
13392        for (id, _, _) in self.state.farm_seed_entries() {
13393            set.insert(id);
13394        }
13395        for c in &self.state.placed_containers {
13396            let mine = match (self.state.character_id, c.owner_character_id) {
13397                (Some(a), Some(b)) => a == b,
13398                _ => false,
13399            };
13400            if !mine {
13401                continue;
13402            }
13403            for s in &c.contents {
13404                if s.quantity > 0
13405                    && (s.props.contains_key("seed_for")
13406                        || looks_like_seed(&s.template_id, &self.state.item_catalog))
13407                {
13408                    set.insert(s.template_id.clone());
13409                }
13410            }
13411        }
13412        if let Some(ed) = self.state.worker_route_editor.as_ref() {
13413            for stop in &ed.stops {
13414                if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } =
13415                    stop
13416                {
13417                    for it in items {
13418                        if looks_like_seed(&it.template, &self.state.item_catalog) {
13419                            set.insert(it.template.clone());
13420                        }
13421                    }
13422                }
13423                if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
13424                    seed_template,
13425                    ..
13426                } = stop
13427                {
13428                    if !seed_template.is_empty() {
13429                        set.insert(seed_template.clone());
13430                    }
13431                }
13432            }
13433        }
13434        for (id, entry) in &self.state.item_catalog {
13435            if entry.is_farm_seed() {
13436                set.insert(id.clone());
13437            }
13438        }
13439        set.into_iter().collect()
13440    }
13441
13442    // ---- map clicks (sheet-scoped accelerators) ------------------------
13443
13444    /// Map click while the route editor is open. When a picker sheet is open
13445    /// the click feeds *that* sheet (chest clicks choose the withdraw/deposit
13446    /// container, NPC clicks the merchant, node clicks the harvest target);
13447    /// otherwise the click quick-adds the nearest target (context-aware).
13448    pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
13449        use crate::worker_route_editor as wre;
13450        use wre::RouteEditorSheet as S;
13451        if self.state.worker_route_editor.is_none() {
13452            return;
13453        }
13454        let sheet = self
13455            .state
13456            .worker_route_editor
13457            .as_ref()
13458            .map(|ed| ed.sheet.clone())
13459            .unwrap_or(S::Stops);
13460        match sheet {
13461            S::WaypointMapPick => {
13462                let (_, _, z) = self.state.player_position_with_z();
13463                let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
13464                self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13465                // Stay in map-pick mode when adding (not editing) for fast pathing.
13466                let editing = self
13467                    .state
13468                    .worker_route_editor
13469                    .as_ref()
13470                    .is_some_and(|ed| ed.editing_index.is_some());
13471                if !editing {
13472                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13473                        ed.sheet = S::WaypointMapPick;
13474                    }
13475                }
13476            }
13477            S::HarvestPicker { .. } => {
13478                if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
13479                    let mut log: Option<String> = None;
13480                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13481                        let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
13482                            return;
13483                        };
13484                        let selected = if picked.contains(&node.id) {
13485                            picked.remove(&node.id);
13486                            false
13487                        } else {
13488                            picked.insert(node.id.clone());
13489                            true
13490                        };
13491                        log = Some(format!(
13492                            "Route: {} {}",
13493                            if selected { "selected" } else { "deselected" },
13494                            resource_node_route_label(node)
13495                        ));
13496                    }
13497                    if let Some(msg) = log {
13498                        self.state.push_log(msg);
13499                    }
13500                }
13501            }
13502            S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
13503                // Chest click picks (or switches) the withdraw source.
13504                let inside = self.state.effective_inside_building();
13505                if let Some(cid) = wre::pick_storage_container_at(
13506                    &self.state.placed_containers,
13507                    self.state.character_id,
13508                    x,
13509                    y,
13510                    inside.as_deref(),
13511                ) {
13512                    self.re_open_withdraw_items(cid);
13513                }
13514            }
13515            S::DepositContainers { .. } | S::DepositFilter { .. } => {
13516                let inside = self.state.effective_inside_building();
13517                if let Some(cid) = wre::pick_storage_container_at(
13518                    &self.state.placed_containers,
13519                    self.state.character_id,
13520                    x,
13521                    y,
13522                    inside.as_deref(),
13523                ) {
13524                    self.re_open_deposit_filter(cid);
13525                }
13526            }
13527            S::SellNpcs { .. } => {
13528                if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13529                    self.re_open_sell_item(Some(npc_id));
13530                }
13531            }
13532            S::SellItem { .. } => {
13533                if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13534                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13535                        if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
13536                            *slot = Some(npc_id.clone());
13537                        }
13538                    }
13539                    self.state
13540                        .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
13541                }
13542            }
13543            // Stop list / other sheets: context-aware quick add.
13544            _ => self.worker_route_editor_quick_add_click(x, y),
13545        }
13546    }
13547
13548    /// Quick-add map click with no picker open: nearest of bed/chest/merchant/
13549    /// node wins; duplicates select the existing stop; a click on a merchant
13550    /// while a sell stop is selected pins it.
13551    fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
13552        use crate::worker_route_editor as wre;
13553        let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
13554            let dx = ax - bx;
13555            let dy = ay - by;
13556            (dx * dx + dy * dy).sqrt()
13557        };
13558
13559        // 1. Retarget the selected stop when the click names its target:
13560        //    sell stop → pin the merchant; withdraw stop → set the source chest.
13561        let selected_stop_kind = self
13562            .state
13563            .worker_route_editor
13564            .as_ref()
13565            .and_then(|ed| ed.stops.get(ed.selected_stop_index))
13566            .map(|s| match s {
13567                wre::WorkerRouteStop::TradeWith { .. } => 1,
13568                wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
13569                _ => 0,
13570            })
13571            .unwrap_or(0);
13572        if selected_stop_kind == 1 {
13573            if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13574                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13575                    ed.set_selected_trade_npc(npc_id.clone());
13576                }
13577                self.state
13578                    .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
13579                return;
13580            }
13581        }
13582        if selected_stop_kind == 2 {
13583            let inside = self.state.effective_inside_building();
13584            if let Some(cid) = wre::pick_storage_container_at(
13585                &self.state.placed_containers,
13586                self.state.character_id,
13587                x,
13588                y,
13589                inside.as_deref(),
13590            ) {
13591                let name = self
13592                    .state
13593                    .placed_containers
13594                    .iter()
13595                    .find(|c| c.id == cid)
13596                    .map(|c| c.display_name.clone())
13597                    .unwrap_or_else(|| "container".into());
13598                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13599                    ed.set_selected_withdraw_container(cid.clone());
13600                }
13601                self.state
13602                    .push_log(format!("Route: withdraw source → {name}"));
13603                return;
13604            }
13605        }
13606
13607        // 2. Nearest candidate of any kind wins. Category order is the
13608        //    tie-break for exact overlaps (bed over chest over merchant over node).
13609        enum Target {
13610            Bed(String),
13611            Container(String),
13612            Npc(String, String),
13613            Node(String, String),
13614        }
13615        let mut best: Option<(f32, u8, Target)> = None;
13616        let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
13617            let better = match best {
13618                None => true,
13619                Some((bd, brank, _)) => {
13620                    d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank)
13621                }
13622            };
13623            if better {
13624                *best = Some((d, rank, t));
13625            }
13626        };
13627        let inside = self.state.effective_inside_building();
13628        if let Some(bed_id) = wre::pick_lodging_container_at(
13629            &self.state.placed_containers,
13630            self.state.character_id,
13631            x,
13632            y,
13633            inside.as_deref(),
13634        ) {
13635            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
13636                // A bed that's already the rest bed doubles as a storage chest —
13637                // a second click on it means "deposit here", not "set bed again".
13638                let already_bed =
13639                    self.state.worker_route_editor.as_ref().is_some_and(|ed| {
13640                        ed.lodging_container_id.as_deref() == Some(bed_id.as_str())
13641                    });
13642                if already_bed {
13643                    consider(
13644                        dist(x, y, c.x, c.y),
13645                        1,
13646                        Target::Container(bed_id),
13647                        &mut best,
13648                    );
13649                } else {
13650                    consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
13651                }
13652            }
13653        }
13654        if let Some(cid) = wre::pick_storage_container_at(
13655            &self.state.placed_containers,
13656            self.state.character_id,
13657            x,
13658            y,
13659            inside.as_deref(),
13660        ) {
13661            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
13662                consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
13663            }
13664        }
13665        if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13666            if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
13667                consider(
13668                    dist(x, y, n.x, n.y),
13669                    2,
13670                    Target::Npc(npc_id, label),
13671                    &mut best,
13672                );
13673            }
13674        }
13675        if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
13676            let d = dist(x, y, node.x, node.y);
13677            let label = resource_node_route_label(node);
13678            consider(d, 3, Target::Node(node.id.clone(), label), &mut best);
13679        }
13680
13681        match best.map(|(_, _, t)| t) {
13682            Some(Target::Bed(bed_id)) => {
13683                let name = self
13684                    .state
13685                    .placed_containers
13686                    .iter()
13687                    .find(|c| c.id == bed_id)
13688                    .map(|c| c.display_name.clone())
13689                    .unwrap_or_else(|| "camp bed".into());
13690                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13691                    ed.lodging_container_id = Some(bed_id.clone());
13692                }
13693                self.state
13694                    .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
13695            }
13696            Some(Target::Container(cid)) => {
13697                let name = self
13698                    .state
13699                    .placed_containers
13700                    .iter()
13701                    .find(|c| c.id == cid)
13702                    .map(|c| c.display_name.clone())
13703                    .unwrap_or_else(|| "container".into());
13704                let added = self
13705                    .state
13706                    .worker_route_editor
13707                    .as_mut()
13708                    .is_some_and(|ed| ed.append_deposit_at(&cid));
13709                if added {
13710                    self.state
13711                        .push_log(format!("Route: + deposit at {name} ({cid})"));
13712                } else {
13713                    self.state.push_log(format!(
13714                        "Route: {name} already in route — selected it (d to remove)"
13715                    ));
13716                }
13717            }
13718            Some(Target::Npc(npc_id, label)) => {
13719                // Quick-add sell: first storage template (the full sell flow
13720                // lives in the a → Sell sheet).
13721                let template = self.re_template_candidates().into_iter().next();
13722                let Some(template) = template else {
13723                    self.state.push_log(
13724                        "Route: no items in your storage to sell — stock a chest first".to_string(),
13725                    );
13726                    return;
13727                };
13728                let added = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
13729                    ed.append_trade_with(template.clone(), Some(npc_id.clone()), true)
13730                });
13731                if added {
13732                    self.state
13733                        .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
13734                } else {
13735                    self.state.push_log(format!(
13736                        "Route: {label} already sells {template} — selected it (d to remove)"
13737                    ));
13738                }
13739            }
13740            Some(Target::Node(id, label)) => {
13741                let added = self
13742                    .state
13743                    .worker_route_editor
13744                    .as_mut()
13745                    .is_some_and(|ed| ed.append_harvest_node(&id));
13746                if added {
13747                    self.state
13748                        .push_log(format!("Route: + harvest node {label}"));
13749                } else {
13750                    self.state.push_log(format!(
13751                        "Route: {label} already in route — selected it (d to remove)"
13752                    ));
13753                }
13754            }
13755            None => {}
13756        }
13757    }
13758
13759    pub fn worker_route_editor_select(&mut self, delta: i32) {
13760        let Some(ed) = self.state.worker_route_editor.as_mut() else {
13761            return;
13762        };
13763        if ed.stops.is_empty() {
13764            return;
13765        }
13766        let n = ed.stops.len() as i32;
13767        let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
13768        ed.selected_stop_index = next;
13769    }
13770
13771    pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
13772        let Some(ed) = self.state.worker_route_editor.as_mut() else {
13773            return;
13774        };
13775        if delta < 0 {
13776            ed.move_selected_up();
13777        } else if delta > 0 {
13778            ed.move_selected_down();
13779        }
13780    }
13781
13782    pub fn worker_route_editor_delete_selected(&mut self) {
13783        let removed = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
13784            let before = ed.stop_count();
13785            ed.remove_selected_stop();
13786            ed.stop_count() < before
13787        });
13788        if removed {
13789            self.state.push_log("Route: removed selected stop");
13790        }
13791    }
13792
13793    /// Clear all stops. Saving afterwards parks the worker in idle mode
13794    /// instead of leaving it in a broken job loop.
13795    pub fn worker_route_editor_clear_stops(&mut self) {
13796        let Some(ed) = self.state.worker_route_editor.as_mut() else {
13797            return;
13798        };
13799        if ed.stops.is_empty() {
13800            self.state
13801                .push_log("Route: already empty — s saves an idle worker".to_string());
13802            return;
13803        }
13804        ed.stops.clear();
13805        ed.selected_stop_index = 0;
13806        self.state.push_log(
13807            "Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string(),
13808        );
13809    }
13810
13811    pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
13812        if self.state.pending_worker_job_ack.is_some() {
13813            anyhow::bail!("route save still pending — wait for server ack");
13814        }
13815        let Some(ed) = self.state.worker_route_editor.clone() else {
13816            anyhow::bail!("route editor not open");
13817        };
13818        // Empty route = stand down: park the worker in idle mode rather than
13819        // erroring out or leaving it marching a ghost loop.
13820        let (job_yaml, idle) = if ed.stops.is_empty() {
13821            (ed.build_idle_job_yaml(), true)
13822        } else {
13823            (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
13824        };
13825        let worker_id = ed.worker_instance_id.clone();
13826        let route_view = if idle { None } else { Some(ed.to_route_view()) };
13827        let mode = if idle {
13828            flatland_protocol::WorkerModeView::Idle
13829        } else {
13830            flatland_protocol::WorkerModeView::JobLoop
13831        };
13832        let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
13833            .state
13834            .hired_workers
13835            .iter()
13836            .find(|w| w.instance_id == worker_id)
13837            .map(|w| {
13838                (
13839                    w.route.clone(),
13840                    w.mode,
13841                    w.step_label.clone(),
13842                    w.last_error.clone(),
13843                )
13844            })
13845            .unwrap_or((
13846                None,
13847                flatland_protocol::WorkerModeView::Idle,
13848                String::new(),
13849                None,
13850            ));
13851        self.seq += 1;
13852        let seq = self.seq;
13853        self.session
13854            .submit_intent(Intent::SetWorkerJob {
13855                entity_id: self.state.entity_id,
13856                worker_instance_id: worker_id.clone(),
13857                job_yaml,
13858                seq,
13859            })
13860            .await?;
13861        self.state.intents_sent += 1;
13862        if let Some(w) = self
13863            .state
13864            .hired_workers
13865            .iter_mut()
13866            .find(|w| w.instance_id == worker_id)
13867        {
13868            w.route = route_view;
13869            w.mode = mode;
13870            w.last_error = None;
13871            if idle {
13872                w.step_label.clear();
13873                w.route_stop_index = None;
13874            }
13875        }
13876        self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
13877            seq,
13878            worker_instance_id: worker_id,
13879            worker_label: ed.worker_label.clone(),
13880            idle,
13881            stop_count: ed.stops.len(),
13882            prev_route,
13883            prev_mode,
13884            prev_step_label,
13885            prev_last_error,
13886        });
13887        self.state.push_log(format!(
13888            "Route: saving for {}… (waiting for server)",
13889            ed.worker_label
13890        ));
13891        // Keep editor open until IntentAck; reject Interaction reverts optimistic state.
13892        Ok(())
13893    }
13894    pub fn quest_menu_move(&mut self, delta: i32) {
13895        let n = self.state.active_quest_entries().len();
13896        if n == 0 {
13897            return;
13898        }
13899        let idx = self.state.quest_menu_index as i32;
13900        self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
13901    }
13902
13903    pub fn quest_menu_page(&mut self, pages: i32) {
13904        let n = self.state.active_quest_entries().len();
13905        self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
13906    }
13907
13908    pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
13909        let Some(offer) = self.state.selected_quest_offer().cloned() else {
13910            anyhow::bail!("no quest offer");
13911        };
13912        self.seq += 1;
13913        let seq = self.seq;
13914        self.session
13915            .submit_intent(Intent::AcceptQuest {
13916                entity_id: self.state.entity_id,
13917                quest_id: offer.quest_id,
13918                seq,
13919            })
13920            .await?;
13921        self.state.intents_sent += 1;
13922        Ok(())
13923    }
13924
13925    pub fn quest_offer_move(&mut self, delta: i32) {
13926        self.state.move_quest_offer_selection(delta);
13927    }
13928
13929    pub fn quest_offer_decline(&mut self) {
13930        self.state.clear_quest_offers();
13931        if !self.state.show_npc_chat
13932            && !self.state.show_shop_menu
13933            && self.state.npc_verb_target.is_some()
13934        {
13935            self.state.show_npc_verb_menu = true;
13936        }
13937    }
13938
13939    pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
13940        if !self.state.show_quest_menu {
13941            return Ok(());
13942        }
13943        let active: Vec<_> = self
13944            .state
13945            .active_quest_entries()
13946            .into_iter()
13947            .cloned()
13948            .collect();
13949        let Some(entry) = active.get(self.state.quest_menu_index) else {
13950            return Ok(());
13951        };
13952        if self.state.quest_withdraw_confirm {
13953            if !entry.can_withdraw {
13954                anyhow::bail!("quest cannot be withdrawn");
13955            }
13956            self.seq += 1;
13957            let seq = self.seq;
13958            self.session
13959                .submit_intent(Intent::WithdrawQuest {
13960                    entity_id: self.state.entity_id,
13961                    quest_id: entry.quest_id.clone(),
13962                    seq,
13963                })
13964                .await?;
13965            self.state.intents_sent += 1;
13966            self.state.quest_withdraw_confirm = false;
13967            return Ok(());
13968        }
13969        self.seq += 1;
13970        let seq = self.seq;
13971        self.session
13972            .submit_intent(Intent::TrackQuest {
13973                entity_id: self.state.entity_id,
13974                quest_id: entry.quest_id.clone(),
13975                seq,
13976            })
13977            .await?;
13978        self.state.intents_sent += 1;
13979        Ok(())
13980    }
13981
13982    pub fn quest_request_withdraw(&mut self) {
13983        if self.state.show_quest_menu {
13984            self.state.quest_withdraw_confirm = true;
13985        }
13986    }
13987
13988    pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
13989        if !self.state.is_alive() {
13990            anyhow::bail!("you are dead");
13991        }
13992        let Some(catalog) = self.state.shop_catalog.clone() else {
13993            anyhow::bail!("no shop open");
13994        };
13995        self.seq += 1;
13996        let seq = self.seq;
13997        match self.state.shop_tab {
13998            ShopTab::Buy => {
13999                let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
14000                    anyhow::bail!("nothing selected");
14001                };
14002                if offer.already_owned {
14003                    anyhow::bail!("already owned");
14004                }
14005                self.session
14006                    .submit_intent(Intent::ShopBuy {
14007                        entity_id: self.state.entity_id,
14008                        npc_id: catalog.npc_id.clone(),
14009                        offer_id: offer.offer_id.clone(),
14010                        quantity: self.state.shop_quantity,
14011                        seq,
14012                    })
14013                    .await?;
14014            }
14015            ShopTab::Sell => {
14016                let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
14017                    anyhow::bail!("nothing to sell");
14018                };
14019                if line.quantity == 0 {
14020                    anyhow::bail!("you have no {}", line.label);
14021                }
14022                let quantity = self.state.shop_quantity.min(line.quantity).max(1);
14023                self.session
14024                    .submit_intent(Intent::ShopSell {
14025                        entity_id: self.state.entity_id,
14026                        npc_id: catalog.npc_id.clone(),
14027                        template_id: line.template_id.clone(),
14028                        quantity,
14029                        seq,
14030                    })
14031                    .await?;
14032            }
14033        }
14034        self.state.intents_sent += 1;
14035        Ok(())
14036    }
14037
14038    pub fn craft_menu_move(&mut self, delta: i32) {
14039        let n = self.state.craft_filtered_indices().len();
14040        if n == 0 {
14041            return;
14042        }
14043        let idx = self.state.craft_menu_index as i32;
14044        let next = (idx + delta).rem_euclid(n as i32);
14045        self.state.craft_menu_index = next as usize;
14046        self.state.clamp_craft_batch_quantity();
14047    }
14048
14049    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
14050        self.state.craft_batch_adjust_quantity(delta);
14051    }
14052
14053    pub fn craft_batch_set_max(&mut self) {
14054        self.state.craft_batch_set_max();
14055    }
14056
14057    pub fn craft_batch_set_min(&mut self) {
14058        self.state.craft_batch_set_min();
14059    }
14060
14061    pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
14062        let Some(blueprint) = self.state.craft_selected_blueprint().cloned() else {
14063            anyhow::bail!("no blueprints in this tab");
14064        };
14065        if !self.state.can_craft_blueprint(&blueprint) {
14066            let hint = self
14067                .state
14068                .craft_missing_hint(&blueprint)
14069                .unwrap_or_else(|| "missing materials".into());
14070            anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
14071        }
14072        let count = self.state.craft_batch_quantity;
14073        let max = self.state.max_craft_batches(&blueprint);
14074        if max == 0 {
14075            anyhow::bail!("cannot craft {}", blueprint.label);
14076        }
14077        let batches = count.min(max);
14078        self.craft(&blueprint.id, Some(batches)).await?;
14079        // Keep the craft menu open so progress / blockers stay visible.
14080        Ok(())
14081    }
14082
14083    pub async fn move_by(
14084        &mut self,
14085        forward: f32,
14086        strafe: f32,
14087        vertical: f32,
14088        sprint: bool,
14089        sneak: bool,
14090    ) -> anyhow::Result<()> {
14091        if !self.state.is_alive() {
14092            anyhow::bail!("you are dead");
14093        }
14094        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
14095            self.last_move_forward = forward;
14096            self.last_move_strafe = strafe;
14097        }
14098        self.seq += 1;
14099        self.session
14100            .submit_intent(Intent::Move {
14101                entity_id: self.state.entity_id,
14102                forward,
14103                strafe,
14104                vertical,
14105                sprint: sprint && !sneak,
14106                sneak,
14107                seq: self.seq,
14108            })
14109            .await?;
14110        self.state.intents_sent += 1;
14111        Ok(())
14112    }
14113
14114    pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
14115        if !self.state.connected {
14116            crate::harvest_trace!("harvest_nearest rejected: not connected");
14117            anyhow::bail!("not connected");
14118        }
14119        if !self.state.is_alive() {
14120            crate::harvest_trace!("harvest_nearest rejected: player dead");
14121            anyhow::bail!("you are dead");
14122        }
14123        if self.state.harvest_in_progress {
14124            if self.state.harvest_state_stale() {
14125                self.state.clear_harvest_state();
14126            } else {
14127                anyhow::bail!("already harvesting");
14128            }
14129        }
14130        let (px, py) = self
14131            .state
14132            .player
14133            .as_ref()
14134            .map(|p| (p.transform.position.x, p.transform.position.y))
14135            .unwrap_or((0.0, 0.0));
14136
14137        let available = self
14138            .state
14139            .resource_nodes
14140            .iter()
14141            .filter(|n| !n.harvest_off)
14142            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14143            .count();
14144        let node_id = self
14145            .state
14146            .resource_nodes
14147            .iter()
14148            .filter(|n| !n.harvest_off)
14149            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14150            .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
14151            .min_by(|a, b| {
14152                let da = distance(px, py, a.x, a.y);
14153                let db = distance(px, py, b.x, b.y);
14154                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
14155            })
14156            .map(|n| n.id.clone());
14157
14158        let Some(node_id) = node_id else {
14159            let has_loot = self
14160                .state
14161                .ground_drops
14162                .iter()
14163                .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14164            if has_loot {
14165                return self.pickup_nearest().await;
14166            }
14167            anyhow::bail!(
14168                "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
14169            );
14170        };
14171
14172        self.seq += 1;
14173        let seq = self.seq;
14174        crate::harvest_trace!(
14175            entity_id = self.state.entity_id,
14176            node_id = %node_id,
14177            seq,
14178            px,
14179            py,
14180            available_nodes = available,
14181            "submitting harvest intent"
14182        );
14183        self.session
14184            .submit_intent(Intent::Harvest {
14185                entity_id: self.state.entity_id,
14186                node_id,
14187                seq,
14188            })
14189            .await?;
14190        self.state.intents_sent += 1;
14191        self.state.harvest_in_progress = true;
14192        self.state.harvest_started_at = Some(Instant::now());
14193        self.state.push_log("Harvesting…");
14194        crate::harvest_trace!(
14195            entity_id = self.state.entity_id,
14196            seq,
14197            "harvest intent queued to session"
14198        );
14199        Ok(())
14200    }
14201
14202    pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
14203        if !self.state.is_alive() {
14204            anyhow::bail!("you are dead");
14205        }
14206        let blueprint_id = self
14207            .state
14208            .blueprints
14209            .iter()
14210            .find(|bp| self.state.can_craft_blueprint(bp))
14211            .map(|bp| bp.id.clone())
14212            .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
14213        self.craft(&blueprint_id, None).await
14214    }
14215
14216    pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
14217        if !self.state.is_alive() {
14218            anyhow::bail!("you are dead");
14219        }
14220        self.seq += 1;
14221        self.session
14222            .submit_intent(Intent::Craft {
14223                entity_id: self.state.entity_id,
14224                blueprint_id: blueprint_id.to_string(),
14225                count,
14226                seq: self.seq,
14227            })
14228            .await?;
14229        self.state.intents_sent += 1;
14230        let (label, batches) = self
14231            .state
14232            .blueprints
14233            .iter()
14234            .find(|b| b.id == blueprint_id)
14235            .map(|b| {
14236                let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
14237                (b.label.as_str(), n)
14238            })
14239            .unwrap_or((blueprint_id, count.unwrap_or(1)));
14240        self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
14241        self.state.craft_channel_blueprint_id = Some(blueprint_id.to_string());
14242        Ok(())
14243    }
14244
14245    pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
14246        if !self.state.is_alive() {
14247            anyhow::bail!("you are dead");
14248        }
14249        let target_id = match self.state.nearest_interact_target() {
14250            Some(id) => id,
14251            None => {
14252                anyhow::bail!("nothing to interact with nearby");
14253            }
14254        };
14255        if self.state.npcs.iter().any(|n| n.id == target_id) {
14256            self.state.show_npc_verb_menu = true;
14257            self.state.npc_verb_target = Some(target_id);
14258            self.state.npc_verb_index = 0;
14259            self.state.npc_verb_notice = None;
14260            return Ok(());
14261        }
14262        if self
14263            .state
14264            .hired_workers
14265            .iter()
14266            .any(|w| w.instance_id == target_id)
14267        {
14268            return self.open_workers_menu_for(&target_id).await;
14269        }
14270        if let Ok(peer_id) = target_id.parse::<EntityId>() {
14271            if self
14272                .state
14273                .hired_workers
14274                .iter()
14275                .any(|w| w.entity_id == peer_id)
14276            {
14277                if let Some(w) = self
14278                    .state
14279                    .hired_workers
14280                    .iter()
14281                    .find(|w| w.entity_id == peer_id)
14282                {
14283                    let id = w.instance_id.clone();
14284                    return self.open_workers_menu_for(&id).await;
14285                }
14286            }
14287            if let Some(entity) = self
14288                .state
14289                .entities
14290                .iter()
14291                .find(|e| e.id == peer_id && e.id != self.state.entity_id)
14292            {
14293                self.state.player_verbs.open_for(peer_id, &entity.label);
14294                return Ok(());
14295            }
14296        }
14297        self.seq += 1;
14298        self.session
14299            .submit_intent(Intent::Interact {
14300                entity_id: self.state.entity_id,
14301                target_id: target_id.clone(),
14302                seq: self.seq,
14303            })
14304            .await?;
14305        self.state.intents_sent += 1;
14306        Ok(())
14307    }
14308
14309    /// Context-sensitive world use: loot/chest → plot farm → interact → claim → harvest.
14310    pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
14311        if !self.state.is_alive() {
14312            anyhow::bail!("you are dead");
14313        }
14314        let (px, py) = self.state.player_position();
14315        let has_loot = self
14316            .state
14317            .ground_drops
14318            .iter()
14319            .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14320        if has_loot {
14321            return self.pickup_nearest().await;
14322        }
14323        if self
14324            .state
14325            .placed_containers
14326            .iter()
14327            .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
14328        {
14329            return self.pickup_nearest_container().await;
14330        }
14331
14332        // Resource nodes beat hired workers / NPCs that stand in interact range.
14333        if self.state.harvestable_node_in_range() {
14334            return self.harvest_nearest().await;
14335        }
14336
14337        if let Some(plot) = self.state.my_plot_under_player().cloned() {
14338            // Double-tap f within 1.2s sells; otherwise farm the plot.
14339            const SELL_WINDOW: Duration = Duration::from_millis(1200);
14340            let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
14341                && self
14342                    .state
14343                    .sell_plot_armed_at
14344                    .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
14345            if sell_armed {
14346                return self.confirm_sell_plot_to_crown(plot.plot_id).await;
14347            }
14348            self.state.sell_plot_confirm = None;
14349            self.state.sell_plot_armed_at = None;
14350
14351            // Prefer farm actions on your own land unless an NPC/player/door/board
14352            // is in interact range (those still win).
14353            let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
14354                self.state.npcs.iter().any(|n| n.id == id)
14355                    || self.state.hired_workers.iter().any(|w| w.instance_id == id)
14356                    || self.state.doors.iter().any(|d| d.id == id)
14357                    || self.state.interactables.iter().any(|i| {
14358                        i.id == id
14359                            && matches!(i.kind.as_str(), "quest_board" | "well" | "exit" | "enter")
14360                    })
14361                    || id.parse::<EntityId>().is_ok_and(|eid| {
14362                        self.state
14363                            .entities
14364                            .iter()
14365                            .any(|e| e.id == eid && e.id != self.state.entity_id)
14366                    })
14367            });
14368            if !blocking_interact {
14369                // On your plot, `f` is harvest only — use `c` / `p` for till and plant.
14370                match self.harvest_nearest().await {
14371                    Ok(()) => return Ok(()),
14372                    Err(err) => {
14373                        let msg = err.to_string();
14374                        if !(msg.contains("no harvestable")
14375                            || msg.contains("press p")
14376                            || msg.contains("press f")
14377                            || msg.contains("nothing"))
14378                        {
14379                            return Err(err);
14380                        }
14381                    }
14382                }
14383                return Ok(());
14384            }
14385        }
14386        if self.state.nearest_interact_target().is_some() {
14387            return self.interact_nearest().await;
14388        }
14389        // Prefer a clear "move closer" when a board is visible but out of reach,
14390        // instead of silently falling through to harvest.
14391        if let Some((label, dist)) = self.state.nearest_quest_board() {
14392            if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
14393                anyhow::bail!(
14394                    "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
14395                );
14396            }
14397        }
14398
14399        match self.harvest_nearest().await {
14400            Ok(()) => Ok(()),
14401            Err(err) => {
14402                let msg = err.to_string();
14403                if msg.contains("no harvestable")
14404                    || msg.contains("press p")
14405                    || msg.contains("press f")
14406                {
14407                    anyhow::bail!(
14408                        "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
14409                    );
14410                }
14411                Err(err)
14412            }
14413        }
14414    }
14415
14416    /// Enter claim-mode footprint editor on unclaimed crown land (`k`).
14417    pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
14418        if !self.state.is_alive() {
14419            anyhow::bail!("you are dead");
14420        }
14421        if self.state.claim_mode.is_some() {
14422            anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
14423        }
14424        let zone = self
14425            .state
14426            .free_property_zone_under_player()
14427            .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
14428        let zone_id = zone.id.clone();
14429        let label = zone
14430            .label
14431            .as_deref()
14432            .filter(|s| !s.trim().is_empty())
14433            .unwrap_or(zone.id.as_str())
14434            .to_string();
14435        self.enter_claim_mode(&zone_id);
14436        self.state.push_log(format!(
14437            "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
14438        ));
14439        Ok(())
14440    }
14441
14442    /// Enter claim mode over a property zone (default 4×4 or min size).
14443    pub fn enter_claim_mode(&mut self, zone_id: &str) {
14444        let Some(zone) = self
14445            .state
14446            .property_zones
14447            .iter()
14448            .find(|z| z.id == zone_id)
14449            .cloned()
14450        else {
14451            self.state.push_log("unknown property zone");
14452            return;
14453        };
14454        self.state.sell_plot_confirm = None;
14455        self.state.sell_plot_armed_at = None;
14456        let min_area = self
14457            .state
14458            .property_plot_settings
14459            .as_ref()
14460            .map(|s| s.min_plot_area_m2)
14461            .unwrap_or(4.0)
14462            .max(1.0);
14463        let min_side = min_area.sqrt().ceil().max(1.0) as u32;
14464        let side = 4u32.max(min_side);
14465        let (px, py) = self.state.player_position();
14466        let anchor_x = px.floor();
14467        let anchor_y = py.floor();
14468        self.state.claim_mode = Some(ClaimModeState {
14469            zone_id: zone.id.clone(),
14470            width_m: side,
14471            height_m: side,
14472            anchor_x,
14473            anchor_y,
14474        });
14475        let label = zone
14476            .label
14477            .as_deref()
14478            .filter(|s| !s.trim().is_empty())
14479            .unwrap_or(zone.id.as_str());
14480        self.state.push_log(format!(
14481            "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
14482        ));
14483    }
14484
14485    pub fn cancel_claim_mode(&mut self) {
14486        if self.state.claim_mode.take().is_some() {
14487            self.state.push_log("Claim cancelled");
14488        }
14489    }
14490
14491    /// Enter relocate mode for a placed container (1×1 ghost).
14492    pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
14493        if !self.state.is_alive() {
14494            anyhow::bail!("you are dead");
14495        }
14496        if self.state.relocate_mode.is_some() {
14497            anyhow::bail!("already relocating — Enter confirm, Esc cancel");
14498        }
14499        if self.state.claim_mode.is_some() {
14500            anyhow::bail!("finish or cancel claim mode first");
14501        }
14502        let chest = self
14503            .state
14504            .placed_containers
14505            .iter()
14506            .find(|c| c.id == container_id)
14507            .cloned()
14508            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
14509        let (px, py) = self.state.player_position();
14510        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
14511            anyhow::bail!("too far from {}", chest.display_name);
14512        }
14513        if chest.locked && !chest.accessible {
14514            anyhow::bail!(
14515                "need the matching key for {} before moving it",
14516                chest.display_name
14517            );
14518        }
14519        let label = if chest.display_name.trim().is_empty() {
14520            chest.template_id.clone()
14521        } else {
14522            chest.display_name.clone()
14523        };
14524        self.state.relocate_mode = Some(RelocateModeState {
14525            container_id: chest.id.clone(),
14526            label: label.clone(),
14527            cursor_x: chest.x.floor() + 0.5,
14528            cursor_y: chest.y.floor() + 0.5,
14529        });
14530        self.state.push_log(format!(
14531            "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
14532        ));
14533        Ok(())
14534    }
14535
14536    /// World `Shift+m`: relocate nearest owned/accessible placed container.
14537    pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
14538        let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
14539            anyhow::bail!("no chest nearby to relocate");
14540        };
14541        if chest.locked && !chest.accessible {
14542            anyhow::bail!(
14543                "need the matching key for {} before moving it",
14544                chest.display_name
14545            );
14546        }
14547        // Prefer own chests: if owner is set and we're not the owner, skip unless accessible unlocked?
14548        // Server enforces ownership; client starts on nearest accessible chest in range.
14549        self.begin_relocate_container(&chest.id)
14550    }
14551
14552    pub fn cancel_relocate_mode(&mut self) {
14553        if self.state.relocate_mode.take().is_some() {
14554            self.state.push_log("Relocate cancelled");
14555        }
14556    }
14557
14558    pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
14559        let Some(mode) = self.state.relocate_mode.as_mut() else {
14560            return;
14561        };
14562        let max_x = self.state.world_width_m.max(1.0);
14563        let max_y = self.state.world_height_m.max(1.0);
14564        let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
14565        let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
14566        mode.cursor_x = nx.floor() + 0.5;
14567        mode.cursor_y = ny.floor() + 0.5;
14568    }
14569
14570    pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
14571        let Some(mode) = self.state.relocate_mode.as_mut() else {
14572            return;
14573        };
14574        let max_x = self.state.world_width_m.max(1.0);
14575        let max_y = self.state.world_height_m.max(1.0);
14576        mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
14577        mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
14578    }
14579
14580    pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
14581        if !self.state.is_alive() {
14582            anyhow::bail!("you are dead");
14583        }
14584        let Some(mode) = self.state.relocate_mode.clone() else {
14585            anyhow::bail!("not relocating");
14586        };
14587        let (px, py) = self.state.player_position();
14588        let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
14589        if dist > 8.0 {
14590            anyhow::bail!("destination too far (max 8 m)");
14591        }
14592        self.seq += 1;
14593        self.session
14594            .submit_intent(Intent::MovePlacedContainer {
14595                entity_id: self.state.entity_id,
14596                container_id: mode.container_id.clone(),
14597                x: mode.cursor_x,
14598                y: mode.cursor_y,
14599                seq: self.seq,
14600            })
14601            .await?;
14602        self.state.intents_sent += 1;
14603        self.state.relocate_mode = None;
14604        self.state.push_log(format!("Moving {}…", mode.label));
14605        Ok(())
14606    }
14607
14608    pub fn claim_set_preset(&mut self, w: u32, h: u32) {
14609        let Some(mode) = self.state.claim_mode.as_mut() else {
14610            return;
14611        };
14612        mode.width_m = w.max(1);
14613        mode.height_m = h.max(1);
14614    }
14615
14616    pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
14617        let Some(mode) = self.state.claim_mode.as_mut() else {
14618            return;
14619        };
14620        let w = (mode.width_m as i32 + dw).max(1) as u32;
14621        let h = (mode.height_m as i32 + dh).max(1) as u32;
14622        mode.width_m = w;
14623        mode.height_m = h;
14624    }
14625
14626    /// Nudge the claim footprint SW corner one meter cell (WASD / arrows).
14627    pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
14628        let Some(mode) = self.state.claim_mode.as_mut() else {
14629            return;
14630        };
14631        let max_x = self.state.world_width_m.max(1.0);
14632        let max_y = self.state.world_height_m.max(1.0);
14633        let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
14634        let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
14635        mode.anchor_x = nx.floor();
14636        mode.anchor_y = ny.floor();
14637    }
14638
14639    pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
14640        if !self.state.is_alive() {
14641            anyhow::bail!("you are dead");
14642        }
14643        let Some(mode) = self.state.claim_mode.clone() else {
14644            anyhow::bail!("not in claim mode");
14645        };
14646        let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
14647            self.state.claim_quote()
14648        else {
14649            anyhow::bail!("cannot quote claim");
14650        };
14651        if !valid {
14652            anyhow::bail!(reason);
14653        }
14654        if !can_afford {
14655            anyhow::bail!(
14656                "not enough copper (need {})",
14657                crate::currency::format_copper(purchase)
14658            );
14659        }
14660        let (x0, y0, x1, y1) = self
14661            .state
14662            .claim_footprint_rect()
14663            .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
14664        let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
14665        self.seq += 1;
14666        self.session
14667            .submit_intent(Intent::BuyPlot {
14668                entity_id: self.state.entity_id,
14669                zone_id: mode.zone_id,
14670                x0,
14671                y0,
14672                x1,
14673                y1,
14674                seq: self.seq,
14675            })
14676            .await?;
14677        self.state.intents_sent += 1;
14678        self.state.claim_mode = None;
14679        self.state.push_log(format!(
14680            "Buying plot for {}",
14681            crate::currency::format_copper(purchase)
14682        ));
14683        Ok(())
14684    }
14685
14686    pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
14687        if !self.state.is_alive() {
14688            anyhow::bail!("you are dead");
14689        }
14690        let zone_id = self
14691            .state
14692            .claim_mode
14693            .as_ref()
14694            .map(|m| m.zone_id.clone())
14695            .or_else(|| {
14696                self.state
14697                    .free_property_zone_under_player()
14698                    .map(|z| z.id.clone())
14699            })
14700            .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
14701        self.seq += 1;
14702        self.session
14703            .submit_intent(Intent::BuyPlotAllFree {
14704                entity_id: self.state.entity_id,
14705                zone_id,
14706                seq: self.seq,
14707            })
14708            .await?;
14709        self.state.intents_sent += 1;
14710        self.state.claim_mode = None;
14711        self.state.push_log("Claiming largest free plot…");
14712        Ok(())
14713    }
14714
14715    pub async fn confirm_sell_plot_to_crown(&mut self, plot_id: uuid::Uuid) -> anyhow::Result<()> {
14716        if !self.state.is_alive() {
14717            anyhow::bail!("you are dead");
14718        }
14719        self.seq += 1;
14720        self.session
14721            .submit_intent(Intent::SellPlotToCrown {
14722                entity_id: self.state.entity_id,
14723                plot_id,
14724                seq: self.seq,
14725            })
14726            .await?;
14727        self.state.intents_sent += 1;
14728        self.state.sell_plot_confirm = None;
14729        self.state.sell_plot_armed_at = None;
14730        self.state.push_log("Selling plot to the crown…");
14731        Ok(())
14732    }
14733
14734    pub async fn set_plot_farm_public(
14735        &mut self,
14736        plot_id: uuid::Uuid,
14737        public: bool,
14738        public_tax_discount_bps: u32,
14739    ) -> anyhow::Result<()> {
14740        self.seq += 1;
14741        self.session
14742            .submit_intent(Intent::SetPlotFarmPublic {
14743                entity_id: self.state.entity_id,
14744                plot_id,
14745                public,
14746                public_tax_discount_bps,
14747                seq: self.seq,
14748            })
14749            .await?;
14750        self.state.intents_sent += 1;
14751        Ok(())
14752    }
14753
14754    pub async fn plot_farm_allow_upsert(
14755        &mut self,
14756        plot_id: uuid::Uuid,
14757        character_id: Option<uuid::Uuid>,
14758        character_name: String,
14759        tax_discount_bps: u32,
14760    ) -> anyhow::Result<()> {
14761        self.seq += 1;
14762        self.session
14763            .submit_intent(Intent::PlotFarmAllowUpsert {
14764                entity_id: self.state.entity_id,
14765                plot_id,
14766                character_id,
14767                character_name,
14768                tax_discount_bps,
14769                seq: self.seq,
14770            })
14771            .await?;
14772        self.state.intents_sent += 1;
14773        Ok(())
14774    }
14775
14776    pub async fn plot_farm_allow_remove(
14777        &mut self,
14778        plot_id: uuid::Uuid,
14779        character_id: uuid::Uuid,
14780    ) -> anyhow::Result<()> {
14781        self.seq += 1;
14782        self.session
14783            .submit_intent(Intent::PlotFarmAllowRemove {
14784                entity_id: self.state.entity_id,
14785                plot_id,
14786                character_id,
14787                seq: self.seq,
14788            })
14789            .await?;
14790        self.state.intents_sent += 1;
14791        Ok(())
14792    }
14793
14794    pub fn open_farm_access_panel(&mut self) {
14795        let Some(plot) = self.state.my_plot_under_player() else {
14796            self.state
14797                .push_log("Stand on your deed plot to manage farm access");
14798            return;
14799        };
14800        self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
14801        self.state.farm_access_index = 0;
14802        self.state.show_farm_access = true;
14803    }
14804
14805    pub fn close_farm_access_panel(&mut self) {
14806        self.state.show_farm_access = false;
14807        self.state.farm_access_name_draft.clear();
14808        self.state.farm_access_index = 0;
14809    }
14810
14811    pub fn farm_access_move(&mut self, delta: i32) {
14812        let n = self.farm_access_row_count().max(1);
14813        let idx = self.state.farm_access_index as i32 + delta;
14814        self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
14815    }
14816
14817    pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
14818        let Some(plot) = self.state.my_plot_under_player() else {
14819            return vec![FarmAccessRow::PublicToggle];
14820        };
14821        let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
14822        for g in &plot.farm_allow {
14823            rows.push(FarmAccessRow::AllowRemove {
14824                character_id: g.character_id,
14825                label: if g.character_label.trim().is_empty() {
14826                    g.character_id.to_string()[..8].to_string()
14827                } else {
14828                    g.character_label.clone()
14829                },
14830                tax_discount_bps: g.tax_discount_bps,
14831            });
14832        }
14833        for e in &self.state.entities {
14834            if e.id == self.state.entity_id || e.label.trim().is_empty() {
14835                continue;
14836            }
14837            if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
14838                continue;
14839            }
14840            if self
14841                .state
14842                .npcs
14843                .iter()
14844                .any(|n| n.id == e.label || n.label == e.label)
14845            {
14846                continue;
14847            }
14848            if plot
14849                .farm_allow
14850                .iter()
14851                .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
14852            {
14853                continue;
14854            }
14855            rows.push(FarmAccessRow::NearbyAdd {
14856                name: e.label.clone(),
14857            });
14858        }
14859        rows
14860    }
14861
14862    pub fn farm_access_row_count(&self) -> usize {
14863        self.farm_access_rows().len().max(1)
14864    }
14865
14866    pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
14867        let Some(plot) = self.state.my_plot_under_player().cloned() else {
14868            self.close_farm_access_panel();
14869            return Ok(());
14870        };
14871        let rows = self.farm_access_rows();
14872        let Some(row) = rows.get(self.state.farm_access_index) else {
14873            return Ok(());
14874        };
14875        match row {
14876            FarmAccessRow::PublicToggle => {
14877                self.set_plot_farm_public(
14878                    plot.plot_id,
14879                    !plot.farm_public,
14880                    plot.public_tax_discount_bps,
14881                )
14882                .await
14883            }
14884            FarmAccessRow::PublicDiscount => Ok(()),
14885            FarmAccessRow::AllowRemove { character_id, .. } => {
14886                self.plot_farm_allow_remove(plot.plot_id, *character_id)
14887                    .await
14888            }
14889            FarmAccessRow::NearbyAdd { name } => {
14890                let disc = self
14891                    .state
14892                    .farm_access_discount_bps
14893                    .max(plot.public_tax_discount_bps);
14894                self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
14895                    .await
14896            }
14897        }
14898    }
14899
14900    pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
14901        let Some(plot) = self.state.my_plot_under_player().cloned() else {
14902            return Ok(());
14903        };
14904        let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
14905        self.state.farm_access_discount_bps = next;
14906        self.state.farm_access_index = 1;
14907        self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
14908            .await
14909    }
14910
14911    /// Till the cell under you on a farmable plot (`c`).
14912    pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
14913        if self.state.farmable_plot_under_player().is_none() {
14914            anyhow::bail!("stand on a farmable plot to cultivate");
14915        }
14916        let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
14917            let (px, py) = self.state.player_position();
14918            if self
14919                .state
14920                .terrain_at(px, py)
14921                .is_some_and(|k| k == TerrainKindView::Tilled)
14922            {
14923                anyhow::bail!("already tilled — stand on bare soil and press c");
14924            }
14925            anyhow::bail!("cannot till this cell — move onto soil on your plot");
14926        };
14927        self.cultivate_at(tx, ty).await
14928    }
14929
14930    /// Plant seeds on empty tilled soil under you (`p`).
14931    pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
14932        if self.state.farmable_plot_under_player().is_none() {
14933            anyhow::bail!("stand on a farmable plot to plant");
14934        }
14935        if !self.state.underfoot_free_tilled_plant_slot() {
14936            anyhow::bail!("stand on empty tilled soil and press p");
14937        }
14938        let seeds = self.state.farm_seed_entries();
14939        if seeds.is_empty() {
14940            anyhow::bail!("no seeds in inventory — buy seeds from Eli");
14941        }
14942        if seeds.len() == 1 {
14943            return self.plant_seeds(seeds[0].0.clone(), 1).await;
14944        }
14945        self.open_plant_menu();
14946        Ok(())
14947    }
14948
14949    /// Open the wall/roof build picker on the owned plot underfoot (`B`).
14950    pub fn open_plot_build_menu(&mut self) -> anyhow::Result<()> {
14951        let Some(plot) = self.state.my_plot_under_player() else {
14952            anyhow::bail!("stand on your plot to build");
14953        };
14954        if plot.building_id.is_some() {
14955            anyhow::bail!("this plot already has a building");
14956        }
14957        let building_now = self
14958            .state
14959            .timed_channel
14960            .as_ref()
14961            .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
14962        if !building_now && self.state.building_materials.is_empty() {
14963            anyhow::bail!("no building materials loaded — wait a moment and try again");
14964        }
14965        self.state.show_plot_build_menu = true;
14966        self.state.show_craft_menu = false;
14967        self.state.show_shop_menu = false;
14968        self.state.shop_catalog = None;
14969        self.state.show_stats = false;
14970        self.state.show_inventory_menu = false;
14971        self.state.plot_build_focus_wall = true;
14972        let walls = self.state.plot_build_wall_options().len();
14973        let roofs = self.state.plot_build_roof_options().len();
14974        if walls > 0 {
14975            self.state.plot_build_wall_index = self.state.plot_build_wall_index.min(walls - 1);
14976        } else {
14977            self.state.plot_build_wall_index = 0;
14978        }
14979        if roofs > 0 {
14980            self.state.plot_build_roof_index = self.state.plot_build_roof_index.min(roofs - 1);
14981        } else {
14982            self.state.plot_build_roof_index = 0;
14983        }
14984        Ok(())
14985    }
14986
14987    pub fn close_plot_build_menu(&mut self) {
14988        self.state.show_plot_build_menu = false;
14989    }
14990
14991    pub fn plot_build_menu_move(&mut self, delta: i32) {
14992        let walls = self.state.plot_build_wall_options();
14993        let roofs = self.state.plot_build_roof_options();
14994        if self.state.plot_build_focus_wall {
14995            if walls.is_empty() {
14996                return;
14997            }
14998            let n = walls.len() as i32;
14999            let cur = self.state.plot_build_wall_index as i32;
15000            self.state.plot_build_wall_index = ((cur + delta).rem_euclid(n)) as usize;
15001        } else {
15002            if roofs.is_empty() {
15003                return;
15004            }
15005            let n = roofs.len() as i32;
15006            let cur = self.state.plot_build_roof_index as i32;
15007            self.state.plot_build_roof_index = ((cur + delta).rem_euclid(n)) as usize;
15008        }
15009    }
15010
15011    pub fn plot_build_menu_toggle_focus(&mut self) {
15012        self.state.plot_build_focus_wall = !self.state.plot_build_focus_wall;
15013    }
15014
15015    /// Confirm selection from the B menu.
15016    pub async fn plot_build_menu_confirm(&mut self) -> anyhow::Result<()> {
15017        let wall = self
15018            .state
15019            .plot_build_selected_wall()
15020            .ok_or_else(|| anyhow::anyhow!("pick a wall material"))?
15021            .id
15022            .clone();
15023        let roof = self
15024            .state
15025            .plot_build_selected_roof()
15026            .ok_or_else(|| anyhow::anyhow!("pick a roof material"))?
15027            .id
15028            .clone();
15029        // Keep the B menu open so it switches to the in-progress panel.
15030        self.start_plot_build(&wall, &roof).await
15031    }
15032
15033    /// Cancel an in-progress plot build (materials returned).
15034    pub async fn plot_build_menu_cancel_build(&mut self) -> anyhow::Result<()> {
15035        self.seq += 1;
15036        self.session
15037            .submit_intent(Intent::CancelPlotBuild {
15038                entity_id: self.state.entity_id,
15039                seq: self.seq,
15040            })
15041            .await?;
15042        self.state.intents_sent += 1;
15043        Ok(())
15044    }
15045
15046    /// Start timed shell craft on the plot's solid tilled rectangle.
15047    pub async fn start_plot_build(
15048        &mut self,
15049        wall_material_id: &str,
15050        roof_material_id: &str,
15051    ) -> anyhow::Result<()> {
15052        let Some(plot) = self.state.my_plot_under_player() else {
15053            anyhow::bail!("stand on your plot to build");
15054        };
15055        if plot.building_id.is_some() {
15056            anyhow::bail!("this plot already has a building");
15057        }
15058        let plot_id = plot.plot_id;
15059        self.seq += 1;
15060        self.session
15061            .submit_intent(Intent::StartPlotBuild {
15062                entity_id: self.state.entity_id,
15063                plot_id,
15064                wall_material_id: wall_material_id.to_string(),
15065                roof_material_id: roof_material_id.to_string(),
15066                seq: self.seq,
15067            })
15068            .await?;
15069        self.state.intents_sent += 1;
15070        Ok(())
15071    }
15072
15073    /// Lock/unlock nearest exterior door when you hold its key (`l` / `L`).
15074    pub async fn toggle_nearby_door_lock(&mut self) -> anyhow::Result<()> {
15075        let (px, py) = self.state.player_position();
15076        let mut best: Option<(f32, String, bool)> = None;
15077        for d in &self.state.doors {
15078            if d.lock_id.is_none() {
15079                continue;
15080            }
15081            let dist = (d.x - px).hypot(d.y - py);
15082            if dist > DOOR_INTERACTION_RADIUS_M {
15083                continue;
15084            }
15085            if best.as_ref().is_none_or(|(bd, _, _)| dist < *bd) {
15086                best = Some((dist, d.id.clone(), d.locked));
15087            }
15088        }
15089        let Some((_, door_id, locked_now)) = best else {
15090            anyhow::bail!("no lockable door nearby");
15091        };
15092        let locked = !locked_now;
15093        self.seq += 1;
15094        self.session
15095            .submit_intent(Intent::SetDoorLocked {
15096                entity_id: self.state.entity_id,
15097                door_id,
15098                locked,
15099                seq: self.seq,
15100            })
15101            .await?;
15102        self.state.intents_sent += 1;
15103        Ok(())
15104    }
15105
15106    /// Enter through a nearby open player-building door (`Enter`).
15107    pub async fn enter_nearby_open_door(&mut self) -> anyhow::Result<()> {
15108        if !self.state.is_alive() {
15109            anyhow::bail!("you are dead");
15110        }
15111        if self.state.effective_inside_building().is_some() {
15112            anyhow::bail!("already inside");
15113        }
15114        let (px, py) = self.state.player_position();
15115        let mut best: Option<(f32, String)> = None;
15116        for d in &self.state.doors {
15117            if !d.open || d.locked {
15118                continue;
15119            }
15120            let player_house = self
15121                .state
15122                .buildings
15123                .iter()
15124                .find(|b| b.id == d.building_id)
15125                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15126            if !player_house {
15127                continue;
15128            }
15129            let dist = (d.x - px).hypot(d.y - py);
15130            if dist > DOOR_INTERACTION_RADIUS_M {
15131                continue;
15132            }
15133            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15134                best = Some((dist, d.id.clone()));
15135            }
15136        }
15137        let Some((_, door_id)) = best else {
15138            anyhow::bail!("no open house door nearby — open with f first");
15139        };
15140        self.seq += 1;
15141        self.session
15142            .submit_intent(Intent::EnterBuildingDoor {
15143                entity_id: self.state.entity_id,
15144                door_id,
15145                seq: self.seq,
15146            })
15147            .await?;
15148        self.state.intents_sent += 1;
15149        Ok(())
15150    }
15151
15152    /// Exit through a nearby exterior portal from inside a player house (`Enter`).
15153    /// Works even when the door is closed or locked.
15154    pub async fn exit_nearby_building_door(&mut self) -> anyhow::Result<()> {
15155        if !self.state.is_alive() {
15156            anyhow::bail!("you are dead");
15157        }
15158        let Some(bid) = self.state.effective_inside_building() else {
15159            anyhow::bail!("not inside a building");
15160        };
15161        let (px, py) = self.state.player_position();
15162        let mut best: Option<(f32, String)> = None;
15163        for d in &self.state.doors {
15164            if d.building_id != bid || d.portal.is_none() {
15165                continue;
15166            }
15167            let player_house = self
15168                .state
15169                .buildings
15170                .iter()
15171                .find(|b| b.id == d.building_id)
15172                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15173            if !player_house {
15174                continue;
15175            }
15176            let dist = (d.x - px).hypot(d.y - py);
15177            if dist > 1.5 {
15178                continue;
15179            }
15180            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15181                best = Some((dist, d.id.clone()));
15182            }
15183        }
15184        let Some((_, door_id)) = best else {
15185            anyhow::bail!("stand by the door to exit");
15186        };
15187        self.seq += 1;
15188        self.session
15189            .submit_intent(Intent::ExitBuildingDoor {
15190                entity_id: self.state.entity_id,
15191                door_id,
15192                seq: self.seq,
15193            })
15194            .await?;
15195        self.state.intents_sent += 1;
15196        Ok(())
15197    }
15198
15199    /// Confirm interior layout for your player building (copper charged on server).
15200    pub async fn confirm_interior_edit(
15201        &mut self,
15202        building_id: String,
15203        rooms: Vec<flatland_protocol::InteriorRoomEdit>,
15204        room_doors: Vec<flatland_protocol::InteriorRoomDoorEdit>,
15205    ) -> anyhow::Result<()> {
15206        self.seq += 1;
15207        self.session
15208            .submit_intent(Intent::ConfirmInteriorEdit {
15209                entity_id: self.state.entity_id,
15210                building_id,
15211                rooms,
15212                room_doors,
15213                seq: self.seq,
15214            })
15215            .await?;
15216        self.state.intents_sent += 1;
15217        Ok(())
15218    }
15219
15220    pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
15221        if !self.state.is_alive() {
15222            anyhow::bail!("you are dead");
15223        }
15224        self.seq += 1;
15225        self.session
15226            .submit_intent(Intent::Cultivate {
15227                entity_id: self.state.entity_id,
15228                x,
15229                y,
15230                seq: self.seq,
15231            })
15232            .await?;
15233        self.state.intents_sent += 1;
15234        Ok(())
15235    }
15236
15237    pub async fn plant_seeds(
15238        &mut self,
15239        seed_template_id: String,
15240        quantity: u32,
15241    ) -> anyhow::Result<()> {
15242        if !self.state.is_alive() {
15243            anyhow::bail!("you are dead");
15244        }
15245        self.seq += 1;
15246        self.session
15247            .submit_intent(Intent::PlantSeeds {
15248                entity_id: self.state.entity_id,
15249                seed_template_id: seed_template_id.clone(),
15250                quantity,
15251                seq: self.seq,
15252            })
15253            .await?;
15254        self.state.intents_sent += 1;
15255        self.state
15256            .push_log(format!("Planting {quantity}× {seed_template_id}…"));
15257        Ok(())
15258    }
15259
15260    pub fn open_plant_menu(&mut self) {
15261        if self.state.farm_seed_entries().is_empty() {
15262            self.state.push_log("No seeds in inventory to plant");
15263            return;
15264        }
15265        self.state.show_plant_menu = true;
15266        self.state.plant_menu_index = 0;
15267        self.state.plant_quantity = 1;
15268        self.state.clamp_plant_menu();
15269    }
15270
15271    pub fn close_plant_menu(&mut self) {
15272        self.state.show_plant_menu = false;
15273    }
15274
15275    pub fn plant_menu_move(&mut self, delta: i32) {
15276        let n = self.state.farm_seed_entries().len();
15277        if n == 0 {
15278            return;
15279        }
15280        let idx = self.state.plant_menu_index as i32 + delta;
15281        self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
15282        self.state.clamp_plant_menu();
15283    }
15284
15285    pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
15286        let next = self.state.plant_quantity as i32 + delta;
15287        self.state.plant_quantity = next.max(1) as u32;
15288        self.state.clamp_plant_menu();
15289    }
15290
15291    pub fn plant_menu_set_quantity_max(&mut self) {
15292        if let Some((_, max, _)) = self.state.plant_menu_selection() {
15293            self.state.plant_quantity = max;
15294        }
15295        self.state.clamp_plant_menu();
15296    }
15297
15298    pub fn plant_menu_set_quantity_min(&mut self) {
15299        self.state.plant_quantity = 1;
15300        self.state.clamp_plant_menu();
15301    }
15302
15303    pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
15304        let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
15305            self.close_plant_menu();
15306            anyhow::bail!("no seeds to plant");
15307        };
15308        self.close_plant_menu();
15309        self.plant_seeds(seed, qty).await?;
15310        self.state.push_log(format!("Planted {qty}× {label}"));
15311        Ok(())
15312    }
15313
15314    /// Activate the ability or consumable bound to hotbar slot `1`–`9`.
15315    /// Heals prefer T2/self; other abilities prefer T1. Consumables need no target.
15316    pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
15317        if !self.state.is_alive() {
15318            anyhow::bail!("you are dead");
15319        }
15320        let binding = self
15321            .state
15322            .hotbar_ability(slot)
15323            .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
15324            .to_string();
15325        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
15326            let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
15327            if qty == 0 {
15328                anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
15329            }
15330            return self.use_item(template_id).await;
15331        }
15332        let ability_id = binding;
15333        if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
15334            return self
15335                .cast_ability(&ability_id, Some(self.state.entity_id))
15336                .await;
15337        }
15338        let is_heal = ability_id == "heal_touch"
15339            || self
15340                .state
15341                .ability_meta
15342                .get(&ability_id)
15343                .map(|meta| meta.is_heal)
15344                .unwrap_or(false);
15345        let target = if is_heal {
15346            Some(
15347                self.state
15348                    .target_for_slot(2)
15349                    .unwrap_or(self.state.entity_id),
15350            )
15351        } else {
15352            self.state
15353                .target_for_slot(1)
15354                .or_else(|| self.state.target_for_slot(2))
15355        };
15356        let Some(target_id) = target else {
15357            anyhow::bail!("no target — Tab to select, then press the hotbar key");
15358        };
15359        self.cast_ability(&ability_id, Some(target_id)).await
15360    }
15361
15362    /// Bind or clear a hotbar slot (`1`–`9`) via [`Intent::SetHotbarSlot`].
15363    /// `ability_id` may be a learned ability or `item:<template_id>` for consumables.
15364    pub async fn set_hotbar_slot(
15365        &mut self,
15366        slot: u8,
15367        ability_id: Option<&str>,
15368    ) -> anyhow::Result<()> {
15369        if !self.state.is_alive() {
15370            anyhow::bail!("you are dead");
15371        }
15372        if !(1..=9).contains(&slot) {
15373            anyhow::bail!("hotbar slot must be 1–9");
15374        }
15375        let ability_id = ability_id
15376            .map(str::trim)
15377            .filter(|id| !id.is_empty())
15378            .map(str::to_string);
15379        self.seq += 1;
15380        self.session
15381            .submit_intent(Intent::SetHotbarSlot {
15382                entity_id: self.state.entity_id,
15383                slot,
15384                ability_id: ability_id.clone(),
15385                seq: self.seq,
15386            })
15387            .await?;
15388        self.state.intents_sent += 1;
15389        let idx = (slot - 1) as usize;
15390        if self.state.hotbar.len() < 9 {
15391            self.state.hotbar.resize(9, None);
15392        }
15393        if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
15394            *slot_mut = ability_id.clone();
15395        }
15396        match ability_id {
15397            Some(id) => {
15398                let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
15399                    format!("use {tid}")
15400                } else {
15401                    id
15402                };
15403                self.state.push_log(format!("Hotbar {slot} → {label}"))
15404            }
15405            None => self.state.push_log(format!("Hotbar {slot} cleared")),
15406        }
15407        Ok(())
15408    }
15409
15410    pub fn npc_verb_options(&self) -> Vec<NpcVerbChoice> {
15411        self.state.npc_verb_options()
15412    }
15413
15414    pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
15415        let Some(npc_id) = self.state.npc_verb_target.clone() else {
15416            return Ok(());
15417        };
15418        let options = self.npc_verb_options();
15419        let choice = options
15420            .get(self.state.npc_verb_index)
15421            .cloned()
15422            .unwrap_or_else(GameState::talk_choice);
15423        match choice.action {
15424            NpcVerbAction::QuestGive { quest_id } => {
15425                self.submit_npc_quest_turn_in(&npc_id, Some(&quest_id))
15426                    .await?;
15427                self.state.show_npc_verb_menu = false;
15428            }
15429            NpcVerbAction::Talk => {
15430                self.open_npc_talk(&npc_id, None).await?;
15431            }
15432            NpcVerbAction::QuestTalk { quest_id } => {
15433                self.open_npc_talk(&npc_id, Some(&quest_id)).await?;
15434            }
15435            NpcVerbAction::Trade | NpcVerbAction::Bank | NpcVerbAction::Storage | NpcVerbAction::Market => {
15436                self.seq += 1;
15437                self.session
15438                    .submit_intent(Intent::Interact {
15439                        entity_id: self.state.entity_id,
15440                        target_id: npc_id,
15441                        seq: self.seq,
15442                    })
15443                    .await?;
15444                self.state.intents_sent += 1;
15445            }
15446        }
15447        Ok(())
15448    }
15449
15450    async fn open_npc_talk(&mut self, npc_id: &str, quest_id: Option<&str>) -> anyhow::Result<()> {
15451        self.seq += 1;
15452        self.session
15453            .submit_intent(Intent::NpcTalkOpen {
15454                entity_id: self.state.entity_id,
15455                npc_id: npc_id.to_string(),
15456                quest_id: quest_id.map(str::to_string),
15457                seq: self.seq,
15458            })
15459            .await?;
15460        self.state.intents_sent += 1;
15461        Ok(())
15462    }
15463
15464    async fn submit_npc_quest_turn_in(
15465        &mut self,
15466        npc_id: &str,
15467        quest_id: Option<&str>,
15468    ) -> anyhow::Result<()> {
15469        let catalog_ref = self.state.npc_quest_catalog_ref(npc_id);
15470        let pending: Vec<(String, u32, String)> = self
15471            .state
15472            .quest_log
15473            .iter()
15474            .filter(|q| {
15475                q.status == flatland_protocol::QuestStatusView::Active
15476                    && quest_id.is_none_or(|id| q.quest_id == id)
15477            })
15478            .flat_map(|q| q.objectives.iter())
15479            .filter(|o| {
15480                !o.done
15481                    && o.kind == "give_item"
15482                    && o.npc_ref.as_deref() == Some(catalog_ref.as_str())
15483            })
15484            .filter_map(|o| {
15485                let template = o.item_template.clone()?;
15486                let remaining = o.required.saturating_sub(o.current);
15487                if remaining == 0 {
15488                    return None;
15489                }
15490                Some((template, remaining, o.label.clone()))
15491            })
15492            .collect();
15493        if pending.is_empty() {
15494            self.state.push_log("Nothing to turn in here.");
15495            return Ok(());
15496        }
15497        let mut sent = 0u32;
15498        for (template, remaining, label) in pending {
15499            let held = self.state.count_inventory_template(&template);
15500            let qty = remaining.min(held);
15501            if qty == 0 {
15502                self.state.push_log(format!("Need {label}"));
15503                continue;
15504            }
15505            self.seq += 1;
15506            self.session
15507                .submit_intent(Intent::QuestGiveItem {
15508                    entity_id: self.state.entity_id,
15509                    npc_id: npc_id.to_string(),
15510                    template_id: template,
15511                    quantity: qty,
15512                    seq: self.seq,
15513                })
15514                .await?;
15515            self.state.intents_sent += 1;
15516            sent += 1;
15517        }
15518        if sent > 0 {
15519            self.state.push_log("Turning in quest items.");
15520        }
15521        Ok(())
15522    }
15523
15524    pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
15525        let Some(chat) = self.state.npc_chat.clone() else {
15526            return Ok(());
15527        };
15528        let message = chat.input.trim().to_string();
15529        if message.is_empty() || chat.pending {
15530            return Ok(());
15531        }
15532        if let Some(c) = self.state.npc_chat.as_mut() {
15533            c.lines.push(format!("You: {message}"));
15534            c.input.clear();
15535            c.pending = true;
15536        }
15537        self.seq += 1;
15538        self.session
15539            .submit_intent(Intent::NpcTalkSay {
15540                entity_id: self.state.entity_id,
15541                npc_id: chat.npc_id,
15542                message,
15543                seq: self.seq,
15544            })
15545            .await?;
15546        self.state.intents_sent += 1;
15547        Ok(())
15548    }
15549
15550    pub async fn npc_talk_topic(&mut self, index: usize) -> anyhow::Result<()> {
15551        let topic = self
15552            .state
15553            .npc_chat
15554            .as_ref()
15555            .and_then(|c| c.suggested_topics.get(index))
15556            .cloned();
15557        let Some(topic) = topic else {
15558            return Ok(());
15559        };
15560        if let Some(c) = self.state.npc_chat.as_mut() {
15561            if c.pending {
15562                return Ok(());
15563            }
15564            c.input = topic;
15565        }
15566        self.npc_talk_send().await
15567    }
15568
15569    pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
15570        let return_to_verbs = self.state.npc_verb_target.is_some();
15571        let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
15572            self.state.show_npc_chat = false;
15573            if return_to_verbs {
15574                self.state.show_npc_verb_menu = true;
15575            }
15576            return Ok(());
15577        };
15578        self.seq += 1;
15579        self.session
15580            .submit_intent(Intent::NpcTalkClose {
15581                entity_id: self.state.entity_id,
15582                npc_id,
15583                seq: self.seq,
15584            })
15585            .await?;
15586        self.state.intents_sent += 1;
15587        self.state.show_npc_chat = false;
15588        self.state.npc_chat = None;
15589        if return_to_verbs {
15590            self.state.show_npc_verb_menu = true;
15591            self.state.npc_verb_notice = None;
15592        }
15593        Ok(())
15594    }
15595
15596    /// Esc/back inside Talk, Trade, quest-offer, or the verb menu — pop one layer, not the whole session.
15597    pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
15598        if self.state.show_quest_offer
15599            && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
15600        {
15601            self.quest_offer_decline();
15602            return Ok(());
15603        }
15604        if self.state.show_npc_chat {
15605            return self.npc_talk_close().await;
15606        }
15607        if self.state.show_shop_menu {
15608            return self.back_from_shop_menu().await;
15609        }
15610        if self.state.bank_panel.is_some() {
15611            if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
15612                self.bank_transfer_back();
15613                return Ok(());
15614            }
15615            return self.close_bank_panel().await;
15616        }
15617        if self.state.storage_panel.is_some() {
15618            if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
15619                self.storage_ui_back();
15620                return Ok(());
15621            }
15622            return self.close_storage_panel().await;
15623        }
15624        if self.state.market_panel.is_some() {
15625            if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
15626                self.market_ui_back();
15627                return Ok(());
15628            }
15629            if self.state.market_buy_confirm.is_some() {
15630                self.state.market_buy_confirm = None;
15631                return Ok(());
15632            }
15633            return self.close_market_panel().await;
15634        }
15635        if self.state.show_npc_verb_menu {
15636            self.state.show_npc_verb_menu = false;
15637            self.state.npc_verb_target = None;
15638        }
15639        Ok(())
15640    }
15641
15642    pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
15643        self.seq += 1;
15644        self.session
15645            .submit_intent(Intent::TestDamage {
15646                entity_id: self.state.entity_id,
15647                amount,
15648                seq: self.seq,
15649            })
15650            .await?;
15651        self.state.intents_sent += 1;
15652        Ok(())
15653    }
15654
15655    pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
15656        self.cycle_combat_target_slot(1, reverse).await
15657    }
15658
15659    pub async fn cycle_combat_target_slot(
15660        &mut self,
15661        slot_index: u8,
15662        reverse: bool,
15663    ) -> anyhow::Result<()> {
15664        if !self.state.is_alive() {
15665            anyhow::bail!("you are dead");
15666        }
15667        let candidates = self.state.candidates_for_slot(slot_index);
15668        if candidates.is_empty() {
15669            anyhow::bail!("no targets nearby");
15670        }
15671        let current = self.state.target_for_slot(slot_index);
15672        let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
15673        let next_idx = match idx {
15674            None => 0,
15675            Some(i) if reverse => {
15676                if i == 0 {
15677                    candidates.len() - 1
15678                } else {
15679                    i - 1
15680                }
15681            }
15682            Some(i) => (i + 1) % candidates.len(),
15683        };
15684        if idx == Some(next_idx) && candidates.len() == 1 {
15685            self.clear_combat_target_slot(slot_index).await?;
15686            return Ok(());
15687        }
15688        let (target_id, label) = candidates[next_idx].clone();
15689        self.set_combat_target_slot(slot_index, target_id, &label)
15690            .await
15691    }
15692
15693    pub async fn set_combat_target_slot(
15694        &mut self,
15695        slot_index: u8,
15696        target_id: EntityId,
15697        label: &str,
15698    ) -> anyhow::Result<()> {
15699        if !self.state.is_alive() {
15700            anyhow::bail!("you are dead");
15701        }
15702        self.seq += 1;
15703        self.session
15704            .submit_intent(Intent::SetTargetSlot {
15705                entity_id: self.state.entity_id,
15706                slot_index,
15707                target_id,
15708                seq: self.seq,
15709            })
15710            .await?;
15711        self.state.intents_sent += 1;
15712        if slot_index == 1 {
15713            self.state.combat_target = Some(target_id);
15714            self.state.combat_target_label = Some(label.to_string());
15715        }
15716        self.state
15717            .push_log(format!("Slot {slot_index} target: {label}"));
15718        Ok(())
15719    }
15720
15721    pub async fn set_combat_target(
15722        &mut self,
15723        target_id: EntityId,
15724        label: &str,
15725    ) -> anyhow::Result<()> {
15726        self.set_combat_target_slot(1, target_id, label).await
15727    }
15728
15729    pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
15730        if slot_index == 1 && self.state.combat_target.is_none() {
15731            return Ok(());
15732        }
15733        self.seq += 1;
15734        self.session
15735            .submit_intent(Intent::ClearTargetSlot {
15736                entity_id: self.state.entity_id,
15737                slot_index,
15738                seq: self.seq,
15739            })
15740            .await?;
15741        if slot_index == 1 {
15742            self.state.combat_target = None;
15743            self.state.combat_target_label = None;
15744        }
15745        self.state.intents_sent += 1;
15746        self.state
15747            .push_log(format!("Slot {slot_index} target cleared"));
15748        Ok(())
15749    }
15750
15751    pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
15752        self.clear_combat_target_slot(1).await
15753    }
15754
15755    pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
15756        if !self.state.is_alive() {
15757            anyhow::bail!("you are dead");
15758        }
15759        self.seq += 1;
15760        self.session
15761            .submit_intent(Intent::AdvanceRotation {
15762                entity_id: self.state.entity_id,
15763                slot_index,
15764                seq: self.seq,
15765            })
15766            .await?;
15767        self.state.intents_sent += 1;
15768        Ok(())
15769    }
15770
15771    pub async fn assign_slot_preset(
15772        &mut self,
15773        slot_index: u8,
15774        preset_id: &str,
15775    ) -> anyhow::Result<()> {
15776        if !self.state.is_alive() {
15777            anyhow::bail!("you are dead");
15778        }
15779        self.seq += 1;
15780        self.session
15781            .submit_intent(Intent::AssignSlotPreset {
15782                entity_id: self.state.entity_id,
15783                slot_index,
15784                preset_id: preset_id.to_string(),
15785                seq: self.seq,
15786            })
15787            .await?;
15788        self.state.intents_sent += 1;
15789        if let Some(slot) = self
15790            .state
15791            .combat_slots
15792            .iter_mut()
15793            .find(|s| s.slot_index == slot_index)
15794        {
15795            slot.preset_id = Some(preset_id.to_string());
15796            if let Some(preset) = self
15797                .state
15798                .rotation_presets
15799                .iter()
15800                .find(|p| p.id == preset_id)
15801            {
15802                slot.preset_label = Some(preset.label.clone());
15803                slot.rotation = preset.abilities.clone();
15804                slot.rotation_index = 0;
15805            }
15806        }
15807        self.state
15808            .push_log(format!("T{slot_index} loadout → {preset_id}"));
15809        Ok(())
15810    }
15811
15812    pub async fn cast_ability(
15813        &mut self,
15814        ability_id: &str,
15815        target_id: Option<EntityId>,
15816    ) -> anyhow::Result<()> {
15817        if !self.state.is_alive() {
15818            anyhow::bail!("you are dead");
15819        }
15820        let allows_ground = self.state.ability_allows_ground(ability_id);
15821        let requires_ground = self.state.ability_requires_ground(ability_id);
15822        if requires_ground && self.state.ground_target.is_none() {
15823            anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
15824        }
15825        let (resolved_target_id, target_point) = if allows_ground {
15826            if let Some((x, y, z)) = self.state.ground_target {
15827                (
15828                    target_id.unwrap_or(self.state.entity_id),
15829                    Some(flatland_protocol::AimPoint { x, y, z }),
15830                )
15831            } else {
15832                (
15833                    target_id
15834                        .or_else(|| self.state.target_for_slot(2))
15835                        .or_else(|| self.state.target_for_slot(1))
15836                        .unwrap_or(self.state.entity_id),
15837                    None,
15838                )
15839            }
15840        } else {
15841            (
15842                target_id
15843                    .or_else(|| self.state.target_for_slot(2))
15844                    .or_else(|| self.state.target_for_slot(1))
15845                    .unwrap_or(self.state.entity_id),
15846                None,
15847            )
15848        };
15849        self.seq += 1;
15850        self.session
15851            .submit_intent(Intent::Cast {
15852                entity_id: self.state.entity_id,
15853                ability_id: ability_id.to_string(),
15854                target_id: resolved_target_id,
15855                target_point,
15856                seq: self.seq,
15857            })
15858            .await?;
15859        self.state.intents_sent += 1;
15860        match target_point {
15861            Some(point) => self.state.push_log(format!(
15862                "Cast {ability_id} → ({:.1}, {:.1})",
15863                point.x, point.y
15864            )),
15865            None => self
15866                .state
15867                .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
15868        }
15869        Ok(())
15870    }
15871
15872    pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
15873        self.seq += 1;
15874        self.session
15875            .submit_intent(Intent::UpsertRotationPreset {
15876                entity_id: self.state.entity_id,
15877                preset: preset.clone(),
15878                seq: self.seq,
15879            })
15880            .await?;
15881        self.state.intents_sent += 1;
15882        if let Some(existing) = self
15883            .state
15884            .rotation_presets
15885            .iter_mut()
15886            .find(|p| p.id == preset.id)
15887        {
15888            *existing = preset.clone();
15889        } else {
15890            self.state.rotation_presets.push(preset.clone());
15891        }
15892        for slot in &mut self.state.combat_slots {
15893            if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
15894                slot.preset_label = Some(preset.label.clone());
15895                slot.rotation = preset.abilities.clone();
15896            }
15897        }
15898        self.state
15899            .push_log(format!("Saved rotation: {}", preset.label));
15900        Ok(())
15901    }
15902
15903    pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
15904        self.seq += 1;
15905        self.session
15906            .submit_intent(Intent::DeleteRotationPreset {
15907                entity_id: self.state.entity_id,
15908                preset_id: preset_id.to_string(),
15909                seq: self.seq,
15910            })
15911            .await?;
15912        self.state.intents_sent += 1;
15913        self.state.rotation_presets.retain(|p| p.id != preset_id);
15914        for slot in &mut self.state.combat_slots {
15915            if slot.preset_id.as_deref() == Some(preset_id) {
15916                slot.preset_id = None;
15917                slot.preset_label = None;
15918                slot.rotation.clear();
15919                slot.rotation_index = 0;
15920            }
15921        }
15922        self.state
15923            .push_log(format!("Deleted rotation: {preset_id}"));
15924        Ok(())
15925    }
15926
15927    pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
15928        if !self.state.is_alive() {
15929            anyhow::bail!("you are dead");
15930        }
15931        let enabled = !self
15932            .state
15933            .combat_slots
15934            .iter()
15935            .find(|s| s.slot_index == slot_index)
15936            .map(|s| s.auto_enabled)
15937            .unwrap_or(false);
15938        self.seq += 1;
15939        self.session
15940            .submit_intent(Intent::SetAutoAttack {
15941                entity_id: self.state.entity_id,
15942                slot_index,
15943                enabled,
15944                seq: self.seq,
15945            })
15946            .await?;
15947        if slot_index == 1 {
15948            self.state.auto_attack = enabled;
15949        }
15950        self.state.intents_sent += 1;
15951        self.state.push_log(format!(
15952            "T{slot_index} auto {}",
15953            if enabled { "ON" } else { "OFF" }
15954        ));
15955        Ok(())
15956    }
15957
15958    pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
15959        if !self.state.connected {
15960            anyhow::bail!("not connected");
15961        }
15962        if !self.state.is_alive() {
15963            anyhow::bail!("you are dead");
15964        }
15965        let (px, py) = self.state.player_position();
15966        if self
15967            .state
15968            .ground_drops
15969            .iter()
15970            .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
15971        {
15972            anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
15973        }
15974        self.seq += 1;
15975        self.session
15976            .submit_intent(Intent::Pickup {
15977                entity_id: self.state.entity_id,
15978                drop_id: None,
15979                seq: self.seq,
15980            })
15981            .await?;
15982        self.state.intents_sent += 1;
15983        self.state.push_audio(crate::social::AudioCue::LootPickup);
15984        Ok(())
15985    }
15986
15987    pub async fn dodge(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
15988        if !self.state.is_alive() {
15989            anyhow::bail!("you are dead");
15990        }
15991        // Only currently held WASD counts — sticky last_move would skip smart dodge after walking.
15992        self.seq += 1;
15993        self.session
15994            .submit_intent(Intent::Dodge {
15995                entity_id: self.state.entity_id,
15996                forward,
15997                strafe,
15998                seq: self.seq,
15999            })
16000            .await?;
16001        self.state.intents_sent += 1;
16002        self.state.push_log("Dodge!");
16003        self.state.push_audio(crate::social::AudioCue::CombatDodge);
16004        Ok(())
16005    }
16006
16007    pub async fn lunge(&mut self) -> anyhow::Result<()> {
16008        if !self.state.is_alive() {
16009            anyhow::bail!("you are dead");
16010        }
16011        let (forward, strafe) = self.last_move_axes();
16012        self.seq += 1;
16013        self.session
16014            .submit_intent(Intent::Lunge {
16015                entity_id: self.state.entity_id,
16016                forward,
16017                strafe,
16018                seq: self.seq,
16019            })
16020            .await?;
16021        self.state.intents_sent += 1;
16022        self.state.push_log("Lunge!");
16023        Ok(())
16024    }
16025
16026    pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16027        if !self.state.is_alive() {
16028            anyhow::bail!("you are dead");
16029        }
16030        self.seq += 1;
16031        self.session
16032            .submit_intent(Intent::DirectionalJump {
16033                entity_id: self.state.entity_id,
16034                forward,
16035                strafe,
16036                seq: self.seq,
16037            })
16038            .await?;
16039        self.state.intents_sent += 1;
16040        self.state.push_log("Jump!");
16041        Ok(())
16042    }
16043
16044    /// Remembered WASD axes for lunge when not currently moving.
16045    pub fn last_move_axes(&self) -> (f32, f32) {
16046        (self.last_move_forward, self.last_move_strafe)
16047    }
16048
16049    pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
16050        if !self.state.is_alive() {
16051            anyhow::bail!("you are dead");
16052        }
16053        self.seq += 1;
16054        self.session
16055            .submit_intent(Intent::Block {
16056                entity_id: self.state.entity_id,
16057                enabled,
16058                seq: self.seq,
16059            })
16060            .await?;
16061        self.state.intents_sent += 1;
16062        if enabled {
16063            self.state.push_log("Blocking");
16064            self.state.push_audio(crate::social::AudioCue::CombatBlock);
16065        }
16066        Ok(())
16067    }
16068
16069    pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
16070        if !self.state.is_alive() {
16071            anyhow::bail!("you are dead");
16072        }
16073        self.seq += 1;
16074        self.session
16075            .submit_intent(Intent::EquipMainhand {
16076                entity_id: self.state.entity_id,
16077                template_id,
16078                instance_id: None,
16079                seq: self.seq,
16080            })
16081            .await?;
16082        self.state.intents_sent += 1;
16083        Ok(())
16084    }
16085
16086    /// Equip paperdoll: activate selected slot (unequip filled, or equip first matching candidate).
16087    pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
16088        let idx = self.state.equip_menu_index;
16089        let slots = equip_paperdoll_rows(&self.state);
16090        let Some(row) = slots.get(idx) else {
16091            return Ok(());
16092        };
16093        match row {
16094            EquipPaperdollRow::Body { slot, filled } => {
16095                if *filled {
16096                    self.equip_worn(*slot, None).await
16097                } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
16098                    self.equip_worn(*slot, Some(inst)).await
16099                } else {
16100                    self.state
16101                        .push_log(format!("No item for {}", body_slot_label(*slot)));
16102                    Ok(())
16103                }
16104            }
16105            EquipPaperdollRow::Mainhand { filled } => {
16106                if *filled {
16107                    self.unequip_mainhand().await
16108                } else if let Some(tid) = first_inventory_weapon(&self.state) {
16109                    self.equip_mainhand(Some(tid)).await
16110                } else {
16111                    self.state.push_log("No weapon in inventory".to_string());
16112                    Ok(())
16113                }
16114            }
16115            EquipPaperdollRow::Offhand { filled, locked } => {
16116                if *locked {
16117                    self.state
16118                        .push_log("Offhand locked — two-handed weapon equipped".to_string());
16119                    Ok(())
16120                } else if *filled {
16121                    self.unequip_offhand().await
16122                } else if let Some(tid) = first_inventory_offhand(&self.state) {
16123                    self.equip_offhand(Some(tid)).await
16124                } else {
16125                    self.state
16126                        .push_log("No offhand item in inventory".to_string());
16127                    Ok(())
16128                }
16129            }
16130        }
16131    }
16132
16133    pub async fn say(
16134        &mut self,
16135        channel: flatland_protocol::ChatChannel,
16136        text: &str,
16137    ) -> anyhow::Result<()> {
16138        self.say_to(channel, text, None).await
16139    }
16140
16141    pub async fn say_to(
16142        &mut self,
16143        channel: flatland_protocol::ChatChannel,
16144        text: &str,
16145        to_entity: Option<EntityId>,
16146    ) -> anyhow::Result<()> {
16147        self.seq += 1;
16148        self.session
16149            .submit_intent(Intent::Say {
16150                entity_id: self.state.entity_id,
16151                channel,
16152                text: text.to_string(),
16153                to_entity,
16154                seq: self.seq,
16155            })
16156            .await?;
16157        self.state.intents_sent += 1;
16158        Ok(())
16159    }
16160
16161    pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
16162        let Some(peer) = self.state.player_verbs.target_entity else {
16163            return Ok(());
16164        };
16165        let label = self.state.player_verbs.target_label.clone();
16166        let choice = crate::social::PlayerVerbState::options()
16167            .get(self.state.player_verbs.index)
16168            .copied()
16169            .unwrap_or("Whisper");
16170        self.state.player_verbs.close();
16171        match choice {
16172            "Trade" => {
16173                // Only request — peer must accept (Y in chat). Do not auto-respond;
16174                // TradeRespond requires a pending inbound request from the peer.
16175                self.seq += 1;
16176                self.session
16177                    .submit_intent(Intent::TradeRequest {
16178                        entity_id: self.state.entity_id,
16179                        peer_entity_id: peer,
16180                        seq: self.seq,
16181                    })
16182                    .await?;
16183                self.state.intents_sent += 1;
16184                self.state.social_chat.push_system(format!(
16185                    "Trade request sent to {label} — waiting for accept"
16186                ));
16187            }
16188            "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
16189            _ => self.state.social_chat.focus_nearby(),
16190        }
16191        Ok(())
16192    }
16193
16194    pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
16195        let Some(pending) = self.state.social_chat.pending_trade.take() else {
16196            return Ok(());
16197        };
16198        self.seq += 1;
16199        self.session
16200            .submit_intent(Intent::TradeRespond {
16201                entity_id: self.state.entity_id,
16202                peer_entity_id: pending.from_entity,
16203                accept,
16204                seq: self.seq,
16205            })
16206            .await?;
16207        self.state.intents_sent += 1;
16208        if accept {
16209            self.state
16210                .social_chat
16211                .push_system(format!("Accepted trade with {}", pending.from_name));
16212        } else {
16213            self.state
16214                .social_chat
16215                .push_system(format!("Declined trade with {}", pending.from_name));
16216        }
16217        Ok(())
16218    }
16219
16220    pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
16221        let text = self.state.social_chat.buffer.trim().to_string();
16222        if text.is_empty() {
16223            return Ok(());
16224        }
16225        self.state.social_chat.buffer.clear();
16226        if crate::social::is_chat_slash_line(&text) {
16227            match crate::social::parse_chat_slash(&text) {
16228                Some(cmd) => return self.apply_chat_slash(cmd).await,
16229                None => {
16230                    self.state.social_chat.push_system(format!(
16231                        "Unknown command — {}",
16232                        crate::social::chat_slash_help_text()
16233                    ));
16234                    return Ok(());
16235                }
16236            }
16237        }
16238        let thread = self.state.social_chat.thread;
16239        let channel = thread.channel();
16240        let to = thread.to_entity();
16241        if let Some(peer) = to {
16242            let label = self.state.social_chat.peer_label.clone();
16243            self.state
16244                .social_chat
16245                .remember_whisper_peer(peer, &label, channel);
16246        }
16247        self.say_to(channel, &text, to).await
16248    }
16249
16250    async fn apply_chat_slash(
16251        &mut self,
16252        cmd: crate::social::ChatSlashCommand,
16253    ) -> anyhow::Result<()> {
16254        use crate::social::{chat_slash_help_text, ChatSlashCommand};
16255        match cmd {
16256            ChatSlashCommand::Help => {
16257                self.state
16258                    .social_chat
16259                    .push_system(chat_slash_help_text().to_string());
16260                Ok(())
16261            }
16262            ChatSlashCommand::Nearby { message } => {
16263                self.state.social_chat.focus_nearby();
16264                self.state
16265                    .social_chat
16266                    .push_system("Nearby speech — everyone close can hear");
16267                if let Some(msg) = message {
16268                    self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
16269                        .await
16270                } else {
16271                    Ok(())
16272                }
16273            }
16274            ChatSlashCommand::Reply { message } => {
16275                let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16276                    self.state
16277                        .social_chat
16278                        .push_system("No one to reply to — wait for a whisper, or /whisper Name");
16279                    return Ok(());
16280                };
16281                let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
16282                self.state
16283                    .social_chat
16284                    .set_whisper_thread(peer.entity_id, &peer.label, stone);
16285                self.state.social_chat.push_system(format!(
16286                    "Replying to {} — type and Enter · /nearby",
16287                    peer.label
16288                ));
16289                if let Some(msg) = message {
16290                    self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
16291                } else {
16292                    Ok(())
16293                }
16294            }
16295            ChatSlashCommand::Whisper { name, message } => {
16296                let (peer_id, label, stone) = if let Some(name) = name {
16297                    match self.resolve_whisper_target(&name) {
16298                        Ok(t) => t,
16299                        Err(err) => {
16300                            self.state.social_chat.push_system(err);
16301                            return Ok(());
16302                        }
16303                    }
16304                } else {
16305                    let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16306                        self.state.social_chat.push_system(
16307                            "Usage: /whisper Name [message] · or /reply after someone whispers you",
16308                        );
16309                        return Ok(());
16310                    };
16311                    (
16312                        peer.entity_id,
16313                        peer.label,
16314                        peer.channel == flatland_protocol::ChatChannel::WhisperStone,
16315                    )
16316                };
16317                self.state
16318                    .social_chat
16319                    .set_whisper_thread(peer_id, &label, stone);
16320                let channel = if stone {
16321                    flatland_protocol::ChatChannel::WhisperStone
16322                } else {
16323                    flatland_protocol::ChatChannel::Whisper
16324                };
16325                if let Some(msg) = message {
16326                    self.state
16327                        .social_chat
16328                        .push_system(format!("Whisper → {label}"));
16329                    self.say_to(channel, &msg, Some(peer_id)).await
16330                } else {
16331                    self.state.social_chat.push_system(format!(
16332                        "Whispering {label} — type and Enter · Esc / /nearby cancels"
16333                    ));
16334                    Ok(())
16335                }
16336            }
16337        }
16338    }
16339
16340    /// Resolve `/whisper Name` against AOI players (label match).
16341    fn resolve_whisper_target(&self, name: &str) -> Result<(EntityId, String, bool), String> {
16342        let needle = name.trim().to_ascii_lowercase();
16343        if needle.is_empty() {
16344            return Err("Usage: /whisper Name [message]".into());
16345        }
16346        let mut candidates: Vec<(EntityId, String)> = self
16347            .state
16348            .entities
16349            .iter()
16350            .filter(|e| e.id != self.state.entity_id)
16351            .filter(|e| !e.label.trim().is_empty())
16352            .filter(|e| e.vitals.is_some())
16353            .filter(|e| !self.state.npcs.iter().any(|n| n.id == e.id.to_string()))
16354            .filter(|e| !self.state.hired_workers.iter().any(|w| w.entity_id == e.id))
16355            .map(|e| (e.id, e.label.clone()))
16356            .collect();
16357
16358        // Also allow matching the last whisper peer by name even if they left AOI briefly.
16359        if let Some(last) = &self.state.social_chat.last_whisper_peer {
16360            if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
16361                candidates.push((last.entity_id, last.label.clone()));
16362            }
16363        }
16364
16365        let exact: Vec<_> = candidates
16366            .iter()
16367            .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
16368            .cloned()
16369            .collect();
16370        let pool = if exact.len() == 1 {
16371            exact
16372        } else if exact.len() > 1 {
16373            return Err(format!(
16374                "Several players named '{name}' nearby — move closer and try again"
16375            ));
16376        } else {
16377            let starts: Vec<_> = candidates
16378                .iter()
16379                .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
16380                .cloned()
16381                .collect();
16382            if starts.len() == 1 {
16383                starts
16384            } else if starts.len() > 1 {
16385                let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
16386                return Err(format!(
16387                    "Ambiguous name '{name}' — matches: {}",
16388                    names.join(", ")
16389                ));
16390            } else {
16391                let contains: Vec<_> = candidates
16392                    .iter()
16393                    .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
16394                    .cloned()
16395                    .collect();
16396                if contains.len() == 1 {
16397                    contains
16398                } else if contains.is_empty() {
16399                    return Err(format!(
16400                        "No player matching '{name}' in range — get closer or check the spelling"
16401                    ));
16402                } else {
16403                    let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
16404                    return Err(format!(
16405                        "Ambiguous name '{name}' — matches: {}",
16406                        names.join(", ")
16407                    ));
16408                }
16409            }
16410        };
16411
16412        let (id, label) = pool.into_iter().next().unwrap();
16413        let stone = self
16414            .state
16415            .social_chat
16416            .last_whisper_peer
16417            .as_ref()
16418            .is_some_and(|p| {
16419                p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone
16420            });
16421        Ok((id, label, stone))
16422    }
16423
16424    pub async fn trade_present_selected(
16425        &mut self,
16426        item_instance_id: uuid::Uuid,
16427    ) -> anyhow::Result<()> {
16428        self.trade_present_quantity(item_instance_id, None).await
16429    }
16430
16431    pub async fn trade_present_quantity(
16432        &mut self,
16433        item_instance_id: uuid::Uuid,
16434        quantity: Option<u32>,
16435    ) -> anyhow::Result<()> {
16436        self.seq += 1;
16437        self.session
16438            .submit_intent(Intent::TradePresent {
16439                entity_id: self.state.entity_id,
16440                item_instance_id,
16441                quantity,
16442                seq: self.seq,
16443            })
16444            .await?;
16445        self.state.intents_sent += 1;
16446        self.state.trade_ui.qty_entry = None;
16447        self.state.trade_ui.picking_inventory = false;
16448        Ok(())
16449    }
16450
16451    /// Confirm the trade quantity prompt (or present whole stack when qty==1).
16452    pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
16453        if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
16454            let qty = self.state.trade_ui.present_quantity();
16455            return self
16456                .trade_present_quantity(entry.item_instance_id, qty)
16457                .await;
16458        }
16459        if !self.state.trade_ui.picking_inventory {
16460            return Ok(());
16461        }
16462        let stacks = self.state.trade_presentable_stacks();
16463        let Some(stack) = stacks.get(self.state.trade_ui.inventory_index).copied() else {
16464            return Ok(());
16465        };
16466        let Some(id) = stack.item_instance_id else {
16467            return Ok(());
16468        };
16469        let label = stack
16470            .display_name
16471            .clone()
16472            .unwrap_or_else(|| stack.template_id.clone());
16473        if stack.quantity <= 1 {
16474            self.trade_present_quantity(id, Some(1)).await
16475        } else {
16476            self.state
16477                .trade_ui
16478                .begin_qty_entry(id, label, stack.quantity);
16479            Ok(())
16480        }
16481    }
16482
16483    pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
16484        self.seq += 1;
16485        self.session
16486            .submit_intent(Intent::TradeSetReady {
16487                entity_id: self.state.entity_id,
16488                ready,
16489                seq: self.seq,
16490            })
16491            .await?;
16492        self.state.intents_sent += 1;
16493        Ok(())
16494    }
16495
16496    pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
16497        self.seq += 1;
16498        self.session
16499            .submit_intent(Intent::TradeCancel {
16500                entity_id: self.state.entity_id,
16501                seq: self.seq,
16502            })
16503            .await?;
16504        self.state.intents_sent += 1;
16505        self.state.trade_ui.close();
16506        Ok(())
16507    }
16508
16509    pub async fn destroy_whisper_stone(
16510        &mut self,
16511        item_instance_id: uuid::Uuid,
16512    ) -> anyhow::Result<()> {
16513        self.seq += 1;
16514        self.session
16515            .submit_intent(Intent::DestroyWhisperStone {
16516                entity_id: self.state.entity_id,
16517                item_instance_id,
16518                seq: self.seq,
16519            })
16520            .await?;
16521        self.state.intents_sent += 1;
16522        Ok(())
16523    }
16524
16525    pub async fn stop(&mut self) -> anyhow::Result<()> {
16526        self.seq += 1;
16527        self.session
16528            .submit_intent(Intent::Stop {
16529                entity_id: self.state.entity_id,
16530                seq: self.seq,
16531            })
16532            .await?;
16533        self.state.intents_sent += 1;
16534        Ok(())
16535    }
16536
16537    pub fn disconnect(&self) {
16538        self.session.disconnect();
16539    }
16540}
16541
16542fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
16543    let dx = ax - bx;
16544    let dy = ay - by;
16545    (dx * dx + dy * dy).sqrt()
16546}
16547
16548#[cfg(test)]
16549mod tests {
16550    use std::collections::BTreeMap;
16551
16552    use super::*;
16553    use flatland_protocol::{
16554        BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
16555    };
16556
16557    fn sample_state() -> GameState {
16558        let mut state = GameState {
16559            session_id: 1,
16560            entity_id: 1,
16561            character_id: None,
16562            tick: 0,
16563            chunk_rev: 0,
16564            content_rev: 0,
16565            publish_rev: 0,
16566            entities: vec![EntityState {
16567                id: 1,
16568                label: "You".into(),
16569                transform: Transform {
16570                    position: WorldCoord::surface(128.0, 128.0),
16571                    yaw: 0.0,
16572                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
16573                },
16574                vitals: None,
16575                attributes: None,
16576                skills: None,
16577                inside_building: None,
16578                tile_id: None,
16579                paperdoll_ref: None,
16580                draw_scale: 1.0,
16581                presentation_state: None,
16582                sprite_mode: None,
16583                progression_xp: None,
16584                combat_cues: vec![],
16585                statuses: vec![],
16586            }],
16587            player: None,
16588            resource_nodes: vec![ResourceNodeView {
16589                id: "oak-1".into(),
16590                label: "Oak".into(),
16591                x: 130.0,
16592                y: 128.0,
16593                z: 0.0,
16594                item_template: "oak_log".into(),
16595                state: ResourceNodeState::Available,
16596                blocking: true,
16597                blocking_radius_m: 0.8,
16598                harvest_off: false,
16599                tile_id: None,
16600                yaw: 0.0,
16601                pitch: 0.0,
16602                roll: 0.0,
16603                draw_scale: 1.0,
16604                sprite_mode: None,
16605                growth_progress: None,
16606                presentation_state: None,
16607                channel_start_tick: None,
16608                channel_end_tick: None,
16609                harvest_drop_templates: vec![],
16610            }],
16611            ground_drops: vec![],
16612            placed_containers: vec![],
16613            buildings: vec![BuildingView {
16614                id: "broker-hut".into(),
16615                label: "Broker".into(),
16616                x: 148.0,
16617                y: 118.0,
16618                width_m: 8.0,
16619                depth_m: 6.0,
16620                interior_blueprint: Some("broker_hut".into()),
16621                tags: vec![],
16622                market_boundary_zone_ids: vec![],
16623                market_max_volume: None,
16624                wall_set: None,
16625                roof_set: None,
16626            }],
16627            doors: vec![flatland_protocol::DoorView {
16628                id: "door-1".into(),
16629                building_id: "broker-hut".into(),
16630                x: 148.0,
16631                y: 118.0,
16632                open: false,
16633                portal: Some("front".into()),
16634                locked: false,
16635                accessible: true,
16636                lock_id: None,
16637            }],
16638            interior_map: None,
16639            npcs: vec![],
16640            blueprints: vec![],
16641            building_materials: vec![],
16642            world_x0: 0.0,
16643            world_y0: 0.0,
16644            world_width_m: 256.0,
16645            world_height_m: 256.0,
16646            terrain_zones: Vec::new(),
16647            z_platforms: Vec::new(),
16648            z_transitions: Vec::new(),
16649            z_bands_outdoor_backup: None,
16650            world_clock: flatland_protocol::WorldClock::default(),
16651            inventory: std::collections::HashMap::new(),
16652            inventory_hints: std::collections::HashMap::new(),
16653            item_catalog: std::collections::HashMap::new(),
16654            logs: VecDeque::new(),
16655            intents_sent: 0,
16656            ticks_received: 0,
16657            connected: true,
16658            disconnect_reason: None,
16659            show_stats: false,
16660            hud_log_hidden: false,
16661            show_equip_menu: false,
16662            equip_menu_index: 0,
16663            show_craft_menu: false,
16664            show_plot_build_menu: false,
16665            plot_build_focus_wall: true,
16666            plot_build_wall_index: 0,
16667            plot_build_roof_index: 0,
16668            craft_menu_index: 0,
16669            craft_batch_quantity: 1,
16670            craft_tab: CraftTab::Ready,
16671            craft_filter: String::new(),
16672            craft_filter_focused: false,
16673            craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
16674            show_shop_menu: false,
16675            shop_catalog: None,
16676            bank_panel: None,
16677            bank_menu_index: 0,
16678            bank_ui_mode: BankUiMode::Menu,
16679            storage_panel: None,
16680            market_panel: None,
16681            market_menu_index: 0,
16682            market_filter: String::new(),
16683            market_filter_focused: false,
16684            market_category_filter: None,
16685            market_buy_confirm: None,
16686            market_ui_mode: MarketUiMode::Browse,
16687            storage_menu_index: 0,
16688            storage_ui_mode: StorageUiMode::Menu,
16689            shop_tab: ShopTab::default(),
16690            shop_menu_index: 0,
16691            shop_quantity: 1,
16692            shop_trade_log: VecDeque::new(),
16693            show_npc_verb_menu: false,
16694            npc_verb_target: None,
16695            npc_verb_index: 0,
16696            npc_verb_notice: None,
16697            player_verbs: crate::social::PlayerVerbState::default(),
16698            social_chat: crate::social::SocialChatState::default(),
16699            trade_ui: crate::social::TradeUiState::default(),
16700            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
16701            show_npc_chat: false,
16702            npc_chat: None,
16703            show_inventory_menu: false,
16704            inventory_menu_index: 0,
16705            inventory_tab: InventoryTab::OnPerson,
16706            inventory_filter: String::new(),
16707            inventory_filter_focused: false,
16708            show_move_picker: false,
16709            show_rename_prompt: false,
16710            rename_plot_id: None,
16711            highlighted_plot_id: None,
16712            show_worker_rename: false,
16713            rename_buffer: String::new(),
16714            move_picker_index: 0,
16715            move_picker: None,
16716            show_grant_picker: false,
16717            grant_picker_index: 0,
16718            grant_picker: None,
16719            show_destroy_picker: false,
16720            destroy_confirm_pending: false,
16721            destroy_picker: None,
16722            combat_target: None,
16723            combat_target_label: None,
16724            ground_target: None,
16725            combat_fx: Vec::new(),
16726            ground_hazards: Vec::new(),
16727            property_zones: Vec::new(),
16728            tax_zones: Vec::new(),
16729            growth_zones: Vec::new(),
16730            biome_zones: Vec::new(),
16731            terrain_kind_nav: Vec::new(),
16732            property_plots: Vec::new(),
16733            property_plot_settings: None,
16734            claim_mode: None,
16735            relocate_mode: None,
16736            sell_plot_confirm: None,
16737            sell_plot_armed_at: None,
16738            show_plant_menu: false,
16739            plant_menu_index: 0,
16740            show_farm_access: false,
16741            farm_access_name_draft: String::new(),
16742            farm_access_discount_bps: 0,
16743            farm_access_index: 0,
16744            plant_quantity: 1,
16745            in_combat: false,
16746            auto_attack: true,
16747            combat_has_los: false,
16748            attack_cd_ticks: 0,
16749            gcd_ticks: 0,
16750            weapon_ability_id: "unarmed".into(),
16751            mainhand_template_id: None,
16752            mainhand_label: None,
16753            mainhand_instance_id: None,
16754            offhand_template_id: None,
16755            offhand_label: None,
16756            offhand_instance_id: None,
16757            mainhand_hand_slots: 1,
16758            defense: None,
16759            worn: BTreeMap::new(),
16760            carry_mass: 0.0,
16761            carry_mass_max: 0.0,
16762            encumbrance: flatland_protocol::EncumbranceState::Light,
16763            move_speed_mps: 0.0,
16764            move_speed_mult: 0.0,
16765            inventory_stacks: Vec::new(),
16766            keychain_stacks: Vec::new(),
16767            whisper_pouch_stacks: Vec::new(),
16768            combat_target_detail: None,
16769            statuses: Vec::new(),
16770            cast_progress: None,
16771            timed_channel: None,
16772            plot_build_offer: None,
16773            ability_cooldowns: Vec::new(),
16774            blocking_active: false,
16775            max_target_slots: 1,
16776            combat_slots: Vec::new(),
16777            rotation_presets: Vec::new(),
16778            known_abilities: Vec::new(),
16779            ability_meta: std::collections::HashMap::new(),
16780            ability_mastery: std::collections::HashMap::new(),
16781            hotbar: vec![None; 9],
16782            max_abilities_per_rotation: 0,
16783            show_loadout_menu: false,
16784            show_keychain_menu: false,
16785            keychain_menu_index: 0,
16786            show_rotation_editor: false,
16787            loadout_menu_index: 0,
16788            loadout_hotbar_slot: 1,
16789            loadout_ability_index: 0,
16790            loadout_focus_presets: false,
16791            rotation_editor: RotationEditorState::default(),
16792            harvest_in_progress: false,
16793            harvest_started_at: None,
16794            pending_craft_ack: None,
16795            craft_channel_blueprint_id: None,
16796            pending_worker_job_ack: None,
16797            attending_worker_instance_id: None,
16798            quest_log: Vec::new(),
16799            interactables: Vec::new(),
16800            ledger: None,
16801            career: None,
16802            character_sheet_tab: CharacterSheetTab::Character,
16803            ledger_period: LedgerPeriod::Day,
16804            show_quest_offer: false,
16805            pending_quest_offers: Vec::new(),
16806                quest_offer_index: 0,
16807            show_quest_menu: false,
16808            quest_menu_index: 0,
16809            quest_withdraw_confirm: false,
16810            hired_workers: Vec::new(),
16811            show_workers_menu: false,
16812            workers_menu_index: 0,
16813            worker_dismiss_confirmation: None,
16814            workers_menu_compact: false,
16815            worker_step_display: BTreeMap::new(),
16816            worker_error_display: BTreeMap::new(),
16817            worker_health_ring_until: BTreeMap::new(),
16818            pending_worker_hire_since: None,
16819            show_worker_give_picker: false,
16820            worker_give_picker_index: 0,
16821            worker_give_picker: None,
16822            show_worker_give_target_picker: false,
16823            worker_give_target_picker_index: 0,
16824            worker_give_target_picker: None,
16825            show_worker_take_picker: false,
16826            worker_take_picker_index: 0,
16827            worker_take_picker: None,
16828            show_worker_teach_picker: false,
16829            worker_teach_picker_index: 0,
16830            worker_teach_picker: None,
16831            worker_route_editor: None,
16832            progression_curve: None,
16833        };
16834        state.player = state.entities.first().cloned();
16835        state
16836    }
16837
16838    #[test]
16839    fn template_display_name_uses_item_catalog_for_uuid_ids() {
16840        let mut state = sample_state();
16841        let id = "7ac81ed5-e3d3-4b7f-a7fb-11d6c34a0995";
16842        assert_eq!(state.template_display_name(id), "Unknown item");
16843        state.item_catalog.insert(
16844            id.into(),
16845            ItemCatalogEntryView {
16846                template_id: id.into(),
16847                display_name: "Emerald".into(),
16848                category: "resource".into(),
16849                seed_for: None,
16850            },
16851        );
16852        assert_eq!(state.template_display_name(id), "Emerald");
16853    }
16854
16855    #[test]
16856    fn whisper_cancels_when_peer_walks_out_of_range() {
16857        let mut state = sample_state();
16858        state.player = state.entities.first().cloned();
16859        let mut peer = state.entities[0].clone();
16860        peer.id = 2;
16861        peer.label = "Ada".into();
16862        peer.transform.position = WorldCoord::surface(129.0, 128.0); // 1m — in range
16863        state.entities.push(peer.clone());
16864        state.social_chat.focus_whisper(2, "Ada");
16865        state.refresh_whisper_range();
16866        assert!(matches!(
16867            state.social_chat.thread,
16868            crate::social::ChatThreadKind::Whisper { peer: 2 }
16869        ));
16870
16871        peer.transform.position = WorldCoord::surface(132.0, 128.0); // 4m — out of range
16872        state.entities[1] = peer;
16873        state.refresh_whisper_range();
16874        assert_eq!(
16875            state.social_chat.thread,
16876            crate::social::ChatThreadKind::Nearby
16877        );
16878        assert!(!state.social_chat.input_focused);
16879    }
16880
16881    #[test]
16882    fn probe_use_world_hired_worker_manage() {
16883        let mut state = sample_state();
16884        state
16885            .hired_workers
16886            .push(flatland_protocol::HiredWorkerView {
16887                instance_id: "worker-1".into(),
16888                entity_id: 42,
16889                def_id: "worker_laborer".into(),
16890                label: "Sam".into(),
16891                x: 129.0,
16892                y: 128.0,
16893                z: 0.0,
16894                mode: flatland_protocol::WorkerModeView::JobLoop,
16895                state: flatland_protocol::WorkerStateView::Working,
16896                step_label: "cultivate".into(),
16897                vitals: flatland_protocol::WorkerVitalsSummary {
16898                    health_pct: 100.0,
16899                    stamina_pct: 100.0,
16900                    mana_pct: 100.0,
16901                    hunger_pct: 100.0,
16902                    thirst_pct: 100.0,
16903                },
16904                carry_pct: 0.0,
16905                last_error: None,
16906                wage_copper_per_interval: 1,
16907                effective_wage_copper: 1,
16908                wage_meters_walked: 0.0,
16909                lodging_container_id: None,
16910                route: None,
16911                route_stop_index: None,
16912                known_blueprint_ids: Vec::new(),
16913                level: 1,
16914                worker_xp: 0.0,
16915                inventory: Vec::new(),
16916                equipment: flatland_protocol::WorkerEquipmentView::default(),
16917                issue_hint: None,
16918            });
16919        let probe = state.probe_use_world();
16920        let primary = probe.primary.expect("primary");
16921        assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
16922        assert_eq!(primary.id, "worker-1");
16923        assert!(primary.hint_line().contains("Manage"));
16924        assert!(primary.hint_line().contains("Sam"));
16925        assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
16926    }
16927
16928    fn sample_hired_worker(x: f32, y: f32) -> flatland_protocol::HiredWorkerView {
16929        flatland_protocol::HiredWorkerView {
16930            instance_id: "worker-1".into(),
16931            entity_id: 42,
16932            def_id: "worker_laborer".into(),
16933            label: "Sam".into(),
16934            x,
16935            y,
16936            z: 0.0,
16937            mode: flatland_protocol::WorkerModeView::JobLoop,
16938            state: flatland_protocol::WorkerStateView::Working,
16939            step_label: "follow".into(),
16940            vitals: flatland_protocol::WorkerVitalsSummary {
16941                health_pct: 100.0,
16942                stamina_pct: 100.0,
16943                mana_pct: 100.0,
16944                hunger_pct: 100.0,
16945                thirst_pct: 100.0,
16946            },
16947            carry_pct: 0.0,
16948            last_error: None,
16949            wage_copper_per_interval: 1,
16950            effective_wage_copper: 1,
16951            wage_meters_walked: 0.0,
16952            lodging_container_id: None,
16953            route: None,
16954            route_stop_index: None,
16955            known_blueprint_ids: Vec::new(),
16956            level: 1,
16957            worker_xp: 0.0,
16958            inventory: Vec::new(),
16959            equipment: flatland_protocol::WorkerEquipmentView::default(),
16960            issue_hint: None,
16961        }
16962    }
16963
16964    #[test]
16965    fn probe_harvest_beats_closer_hired_worker() {
16966        let mut state = sample_state();
16967        state.resource_nodes[0].x = 129.0;
16968        state.resource_nodes[0].y = 128.0;
16969        state.hired_workers.push(sample_hired_worker(128.2, 128.0));
16970        let probe = state.probe_use_world();
16971        let primary = probe.primary.expect("primary");
16972        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
16973        assert_eq!(primary.id, "oak-1");
16974        assert!(state.harvestable_node_in_range());
16975        assert_eq!(
16976            state.nearest_interact_target().as_deref(),
16977            Some("worker-1"),
16978            "harvest is not Interact — worker remains the interact target"
16979        );
16980    }
16981
16982    #[test]
16983    fn probe_door_beats_closer_hired_worker() {
16984        let mut state = sample_state();
16985        state.doors[0].x = 129.2;
16986        state.doors[0].y = 128.0;
16987        state.hired_workers.push(sample_hired_worker(128.3, 128.0));
16988        let probe = state.probe_use_world();
16989        let primary = probe.primary.expect("primary");
16990        assert!(
16991            matches!(
16992                primary.kind,
16993                crate::UseWorldKind::EnterDoor
16994                    | crate::UseWorldKind::OpenDoor
16995                    | crate::UseWorldKind::CloseDoor
16996                    | crate::UseWorldKind::ExitDoor
16997            ),
16998            "door should win over closer worker, got {:?}",
16999            primary.kind
17000        );
17001        assert_eq!(primary.id, "door-1");
17002        assert_eq!(state.nearest_interact_target().as_deref(), Some("door-1"));
17003    }
17004
17005    #[test]
17006    fn probe_worker_when_no_resource_or_door_in_range() {
17007        let mut state = sample_state();
17008        // oak stays at 130,128 (2m, out of harvest); door stays at hut (~22m)
17009        state.hired_workers.push(sample_hired_worker(129.0, 128.0));
17010        let probe = state.probe_use_world();
17011        let primary = probe.primary.expect("primary");
17012        assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17013        assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17014        assert!(!state.harvestable_node_in_range());
17015    }
17016
17017    #[test]
17018    fn market_clerk_verb_options_include_market() {
17019        let mut state = sample_state();
17020        state.npcs.push(flatland_protocol::NpcView {
17021            id: "mira_market".into(),
17022            label: "Mira".into(),
17023            role: "market_clerk".into(),
17024            x: 129.0,
17025            y: 128.0,
17026            building_id: Some("town_market".into()),
17027            entity_id: None,
17028            life_state: None,
17029            hp_pct: None,
17030            can_trade: false,
17031            buy_templates: vec![],
17032            tile_id: None,
17033            behavior_state: None,
17034            presentation_state: None,
17035            sprite_mode: None,
17036            paperdoll_ref: None,
17037            draw_scale: 1.0,
17038            yaw: None,
17039            perception_fov_deg: None,
17040            perception_sight_m: None,
17041            perception_hear_m: None,
17042            quest_verbs: Vec::new(),
17043        });
17044        state.npc_verb_target = Some("mira_market".into());
17045        assert_eq!(
17046            state
17047                .npc_verb_options()
17048                .iter()
17049                .map(|v| v.label.as_str())
17050                .collect::<Vec<_>>(),
17051            vec!["Market", "Talk"]
17052        );
17053    }
17054
17055    #[test]
17056    fn butcher_verb_options_include_turn_in_for_give_item() {
17057        let mut state = sample_state();
17058        state.npcs.push(flatland_protocol::NpcView {
17059            id: "town_butcher_1".into(),
17060            label: "Brutus".into(),
17061            role: "butcher".into(),
17062            x: 129.0,
17063            y: 128.0,
17064            building_id: None,
17065            entity_id: None,
17066            life_state: None,
17067            hp_pct: None,
17068            can_trade: true,
17069            buy_templates: vec!["raw_venison".into()],
17070            tile_id: None,
17071            behavior_state: None,
17072            presentation_state: None,
17073            sprite_mode: None,
17074            paperdoll_ref: None,
17075            draw_scale: 1.0,
17076            yaw: None,
17077            perception_fov_deg: None,
17078            perception_sight_m: None,
17079            perception_hear_m: None,
17080            quest_verbs: Vec::new(),
17081        });
17082        state.quest_log.push(flatland_protocol::QuestLogEntry {
17083            quest_id: "deer_threat".into(),
17084            title: "Deer threat".into(),
17085            description: String::new(),
17086            status: flatland_protocol::QuestStatusView::Active,
17087            current_step_id: Some("deliver".into()),
17088            current_step_title: "Deliver venison".into(),
17089            current_step_index: 0,
17090            objectives: vec![flatland_protocol::QuestObjectiveProgress {
17091                label: "Give 3 Raw venison to Brutus".into(),
17092                current: 0,
17093                required: 3,
17094                done: false,
17095                kind: "give_item".into(),
17096                npc_ref: Some("town_butcher_1".into()),
17097                item_template: Some("raw_venison".into()),
17098                blueprint_id: None,
17099                building_id: None,
17100            }],
17101            current_step_reward: flatland_protocol::QuestRewardView::default(),
17102            completion_reward: flatland_protocol::QuestRewardView::default(),
17103            steps: Vec::new(),
17104            is_tracked: true,
17105            can_withdraw: true,
17106        });
17107        state.npc_verb_target = Some("town_butcher_1".into());
17108        assert_eq!(
17109            state
17110                .npc_verb_options()
17111                .iter()
17112                .map(|v| v.label.as_str())
17113                .collect::<Vec<_>>(),
17114            vec!["Turn in: Deer threat", "Talk", "Trade"]
17115        );
17116    }
17117
17118    #[test]
17119    fn ada_verb_options_include_quest_offer() {
17120        let mut state = sample_state();
17121        state.npcs.push(flatland_protocol::NpcView {
17122            id: "ada_broker".into(),
17123            label: "Ada".into(),
17124            role: "broker".into(),
17125            x: 129.0,
17126            y: 128.0,
17127            building_id: None,
17128            entity_id: None,
17129            life_state: None,
17130            hp_pct: None,
17131            can_trade: true,
17132            buy_templates: vec![],
17133            tile_id: None,
17134            behavior_state: None,
17135            presentation_state: None,
17136            sprite_mode: None,
17137            paperdoll_ref: Some("ada_broker".into()),
17138            draw_scale: 1.0,
17139            yaw: None,
17140            perception_fov_deg: None,
17141            perception_sight_m: None,
17142            perception_hear_m: None,
17143            quest_verbs: vec![flatland_protocol::NpcQuestVerb {
17144                quest_id: "ada_goblin_hunt".into(),
17145                label: "Ask about goblins".into(),
17146                kind: flatland_protocol::NpcQuestVerb::KIND_OFFER.into(),
17147            }],
17148        });
17149        state.npc_verb_target = Some("ada_broker".into());
17150        assert_eq!(
17151            state
17152                .npc_verb_options()
17153                .iter()
17154                .map(|v| v.label.as_str())
17155                .collect::<Vec<_>>(),
17156            vec!["Ask about goblins", "Talk", "Trade"]
17157        );
17158    }
17159
17160    #[test]
17161    fn market_list_excludes_currency_stacks() {
17162        let mut state = sample_state();
17163        state.inventory_stacks = vec![
17164            flatland_protocol::ItemStack {
17165                template_id: "copper_coin".into(),
17166                quantity: 50,
17167                item_instance_id: Some(uuid::Uuid::from_u128(10)),
17168                display_name: Some("Copper Coin".into()),
17169                ..Default::default()
17170            },
17171            flatland_protocol::ItemStack {
17172                template_id: "oak_log".into(),
17173                quantity: 2,
17174                item_instance_id: Some(uuid::Uuid::from_u128(11)),
17175                display_name: Some("Oak Log".into()),
17176                ..Default::default()
17177            },
17178            flatland_protocol::ItemStack {
17179                template_id: "whisper_stone".into(),
17180                quantity: 1,
17181                item_instance_id: Some(uuid::Uuid::from_u128(12)),
17182                display_name: Some("Whisper Stone".into()),
17183                category: Some("quest".into()),
17184                listable: Some(false),
17185                ..Default::default()
17186            },
17187        ];
17188        let opts = state.market_list_item_options(&MarketListSourceKind::Person);
17189        assert_eq!(opts.len(), 1);
17190        assert!(opts[0].label.contains("Oak"));
17191    }
17192
17193    #[test]
17194    fn market_browse_filters_by_category_and_search() {
17195        let mut state = sample_state();
17196        state.market_panel = Some(flatland_protocol::MarketPanel {
17197            npc_id: "mira_market".into(),
17198            npc_label: "Mira".into(),
17199            building_id: "town_market".into(),
17200            building_label: "Town Market".into(),
17201            used_volume: 0.0,
17202            max_volume: 100.0,
17203            listings: vec![
17204                flatland_protocol::MarketListingView {
17205                    listing_id: uuid::Uuid::from_u128(1),
17206                    seller_character_id: uuid::Uuid::from_u128(2),
17207                    seller_label: "Ada".into(),
17208                    hall_building_id: "town_market".into(),
17209                    hall_label: "Town Market".into(),
17210                    template_id: "oak_log".into(),
17211                    display_name: "Oak Log".into(),
17212                    category: "resource".into(),
17213                    quantity: 3,
17214                    unit_price_copper: 10,
17215                    line_total_copper: 30,
17216                    npc_price: false,
17217                    npc_dump_unit_copper: None,
17218                    mine: false,
17219                },
17220                flatland_protocol::MarketListingView {
17221                    listing_id: uuid::Uuid::from_u128(3),
17222                    seller_character_id: uuid::Uuid::from_u128(2),
17223                    seller_label: "Ada".into(),
17224                    hall_building_id: "town_market".into(),
17225                    hall_label: "Town Market".into(),
17226                    template_id: "short_sword".into(),
17227                    display_name: "Short Sword".into(),
17228                    category: "weapon".into(),
17229                    quantity: 1,
17230                    unit_price_copper: 100,
17231                    line_total_copper: 100,
17232                    npc_price: false,
17233                    npc_dump_unit_copper: None,
17234                    mine: false,
17235                },
17236            ],
17237            tax_bps: 0,
17238            tax_flat_copper: 0,
17239            list_vaults: vec![],
17240        });
17241        assert_eq!(state.market_filtered_listing_indices().len(), 2);
17242        state.market_category_filter = Some("Weapons");
17243        let weapons = state.market_filtered_listing_indices();
17244        assert_eq!(weapons.len(), 1);
17245        assert_eq!(
17246            state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
17247            "Short Sword"
17248        );
17249        state.market_category_filter = None;
17250        state.market_filter = "oak".into();
17251        let oak = state.market_filtered_listing_indices();
17252        assert_eq!(oak.len(), 1);
17253        assert_eq!(
17254            state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
17255            "Oak Log"
17256        );
17257    }
17258
17259    #[test]
17260    fn market_list_source_includes_person_and_vaults() {
17261        let mut state = sample_state();
17262        let item_id = uuid::Uuid::from_u128(1);
17263        state.inventory_stacks = vec![flatland_protocol::ItemStack {
17264            template_id: "oak_log".into(),
17265            quantity: 2,
17266            item_instance_id: Some(item_id),
17267            display_name: Some("Oak Log".into()),
17268            ..Default::default()
17269        }];
17270        state.market_panel = Some(flatland_protocol::MarketPanel {
17271            npc_id: "mira_market".into(),
17272            npc_label: "Mira".into(),
17273            building_id: "town_market".into(),
17274            building_label: "Town Market".into(),
17275            used_volume: 0.0,
17276            max_volume: 100.0,
17277            listings: vec![],
17278            tax_bps: 0,
17279            tax_flat_copper: 0,
17280            list_vaults: vec![flatland_protocol::MarketListVault {
17281                building_id: "town_storage".into(),
17282                building_label: "Town Storage".into(),
17283                contents: vec![flatland_protocol::ItemStack {
17284                    template_id: "lumber".into(),
17285                    quantity: 1,
17286                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
17287                    display_name: Some("Lumber".into()),
17288                    ..Default::default()
17289                }],
17290            }],
17291        });
17292        let sources = state.market_list_source_options();
17293        assert_eq!(sources.len(), 2);
17294        assert!(matches!(sources[0].0, MarketListSourceKind::Person));
17295        assert!(matches!(
17296            sources[1].0,
17297            MarketListSourceKind::TownStorage { .. }
17298        ));
17299        assert!(sources[1].1.contains("Town Storage"));
17300    }
17301
17302    #[test]
17303    fn npc_market_dump_estimate_from_town_storage_vault() {
17304        let mut state = sample_state();
17305        state.market_panel = Some(flatland_protocol::MarketPanel {
17306            npc_id: "mira_market".into(),
17307            npc_label: "Mira".into(),
17308            building_id: "town_market".into(),
17309            building_label: "Town Market".into(),
17310            used_volume: 0.0,
17311            max_volume: 100.0,
17312            listings: vec![],
17313            tax_bps: 0,
17314            tax_flat_copper: 0,
17315            list_vaults: vec![flatland_protocol::MarketListVault {
17316                building_id: "town_storage".into(),
17317                building_label: "Town Storage".into(),
17318                contents: vec![flatland_protocol::ItemStack {
17319                    template_id: "lumber".into(),
17320                    quantity: 3,
17321                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
17322                    display_name: Some("Lumber".into()),
17323                    base_value_copper: Some(20),
17324                    ..Default::default()
17325                }],
17326            }],
17327        });
17328        assert_eq!(
17329            state.npc_market_dump_unit_estimate("lumber"),
17330            Some(9),
17331            "vault stack base_value should enable NPC price estimate"
17332        );
17333    }
17334
17335    #[test]
17336    fn probe_use_world_npc_beats_nearby_loot() {
17337        let mut state = sample_state();
17338        state.npcs.push(flatland_protocol::NpcView {
17339            id: "ada".into(),
17340            label: "Ada".into(),
17341            role: "broker".into(),
17342            x: 129.0,
17343            y: 128.0,
17344            building_id: None,
17345            entity_id: None,
17346            life_state: None,
17347            hp_pct: None,
17348            can_trade: true,
17349            buy_templates: vec!["lumber".into()],
17350            tile_id: None,
17351            behavior_state: None,
17352            presentation_state: None,
17353            sprite_mode: None,
17354            paperdoll_ref: None,
17355            draw_scale: 1.0,
17356            yaw: None,
17357            perception_fov_deg: None,
17358            perception_sight_m: None,
17359            perception_hear_m: None,
17360            quest_verbs: Vec::new(),
17361        });
17362        state.ground_drops.push(flatland_protocol::GroundDropView {
17363            id: "d1".into(),
17364            template_id: "lumber".into(),
17365            quantity: 1,
17366            x: 128.5,
17367            y: 128.0,
17368            z: 0.0,
17369            tile_id: None,
17370            display_name: None,
17371            yaw: 0.0,
17372            pitch: 0.0,
17373            roll: 0.0,
17374            draw_scale: 1.0,
17375            item_instance_id: None,
17376            props: Default::default(),
17377            status_bindings: Vec::new(),
17378        });
17379        let probe = state.probe_use_world();
17380        let primary = probe.primary.expect("primary");
17381        assert_eq!(primary.kind, crate::UseWorldKind::Npc);
17382        assert_eq!(primary.id, "ada");
17383    }
17384
17385    #[test]
17386    fn probe_use_world_harvest_when_in_range() {
17387        let state = sample_state(); // oak at 130,128 — player 128,128 → dist 2 > 1.5
17388        let probe = state.probe_use_world();
17389        assert!(
17390            probe.primary.is_none(),
17391            "oak is 2m away, out of harvest range"
17392        );
17393        assert!(probe
17394            .candidates
17395            .iter()
17396            .any(|c| c.kind == crate::UseWorldKind::Harvest));
17397
17398        let mut state = sample_state();
17399        state.resource_nodes[0].x = 129.0;
17400        let probe = state.probe_use_world();
17401        let primary = probe.primary.expect("primary");
17402        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
17403    }
17404
17405    #[test]
17406    fn probe_use_world_door_uses_building_label() {
17407        let mut state = sample_state();
17408        state.doors[0].x = 129.0;
17409        state.doors[0].y = 128.0;
17410        let probe = state.probe_use_world();
17411        let primary = probe.primary.expect("primary");
17412        assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
17413        assert_eq!(primary.label, "Broker");
17414        assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
17415    }
17416
17417    #[test]
17418    fn empty_entity_tick_preserves_welcome_snapshot() {
17419        let mut state = sample_state();
17420        state.inventory.insert("carrot".into(), 3);
17421        let delta = TickDelta {
17422            tick: 1,
17423            entities: vec![],
17424            resource_nodes: vec![],
17425            ground_drops: vec![],
17426            placed_containers: vec![],
17427            buildings: vec![],
17428            doors: vec![],
17429            interior_map: None,
17430            npcs: vec![],
17431            inventory: vec![],
17432            blueprints: vec![],
17433            building_materials: vec![],
17434            world_clock: flatland_protocol::WorldClock::default(),
17435            combat: None,
17436            quest_log: vec![],
17437            hired_workers: Vec::new(),
17438            interactables: vec![],
17439            ledger: None,
17440            career: None,
17441            combat_fx: Vec::new(),
17442            ground_hazards: Vec::new(),
17443            property_plots: Vec::new(),
17444            terrain_overlays: Vec::new(),
17445        };
17446
17447        state.apply_tick_fields(&delta, 1);
17448
17449        assert_eq!(state.entities.len(), 1);
17450        assert!(state.player.is_some());
17451        assert_eq!(state.inventory.get("carrot"), Some(&3));
17452        assert_eq!(state.resource_nodes.len(), 1);
17453    }
17454
17455    #[test]
17456    fn tick_preserves_world_layers_when_delta_omits_them() {
17457        let mut state = sample_state();
17458        let delta = TickDelta {
17459            tick: 1,
17460            entities: state.entities.clone(),
17461            resource_nodes: vec![],
17462            ground_drops: vec![],
17463            placed_containers: vec![],
17464            buildings: vec![],
17465            doors: vec![],
17466            interior_map: None,
17467            npcs: vec![],
17468            inventory: vec![],
17469            blueprints: vec![],
17470            building_materials: vec![],
17471            world_clock: flatland_protocol::WorldClock::default(),
17472            combat: None,
17473            quest_log: vec![],
17474            hired_workers: Vec::new(),
17475            interactables: vec![],
17476            ledger: None,
17477            career: None,
17478            combat_fx: Vec::new(),
17479            ground_hazards: Vec::new(),
17480            property_plots: Vec::new(),
17481            terrain_overlays: Vec::new(),
17482        };
17483
17484        state.apply_tick_fields(&delta, 1);
17485
17486        assert_eq!(state.resource_nodes.len(), 1);
17487        assert_eq!(state.buildings.len(), 1);
17488        assert_eq!(state.doors.len(), 1);
17489    }
17490
17491    #[test]
17492    fn tick_updates_resource_nodes_when_server_sends_them() {
17493        let mut state = sample_state();
17494        let delta = TickDelta {
17495            tick: 1,
17496            entities: state.entities.clone(),
17497            resource_nodes: vec![ResourceNodeView {
17498                id: "oak-1".into(),
17499                label: "Oak".into(),
17500                x: 130.0,
17501                y: 128.0,
17502                z: 0.0,
17503                item_template: "oak_log".into(),
17504                state: ResourceNodeState::Cooldown,
17505                blocking: true,
17506                blocking_radius_m: 0.8,
17507                harvest_off: false,
17508                tile_id: None,
17509                yaw: 0.0,
17510                pitch: 0.0,
17511                roll: 0.0,
17512                draw_scale: 1.0,
17513                sprite_mode: None,
17514                growth_progress: None,
17515                presentation_state: None,
17516                channel_start_tick: None,
17517                channel_end_tick: None,
17518                harvest_drop_templates: vec![],
17519            }],
17520            buildings: vec![],
17521            doors: vec![],
17522            interior_map: None,
17523            npcs: vec![],
17524            inventory: vec![],
17525            blueprints: vec![],
17526            building_materials: vec![],
17527            world_clock: flatland_protocol::WorldClock::default(),
17528            ground_drops: vec![],
17529            placed_containers: vec![],
17530            combat: None,
17531            quest_log: vec![],
17532            hired_workers: Vec::new(),
17533            interactables: vec![],
17534            ledger: None,
17535            career: None,
17536            combat_fx: Vec::new(),
17537            ground_hazards: Vec::new(),
17538            property_plots: Vec::new(),
17539            terrain_overlays: Vec::new(),
17540        };
17541
17542        state.apply_tick_fields(&delta, 1);
17543
17544        assert!(matches!(
17545            state.resource_nodes[0].state,
17546            ResourceNodeState::Cooldown
17547        ));
17548    }
17549
17550    #[test]
17551    fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
17552        let mut state = GameState {
17553            session_id: 1,
17554            entity_id: 1,
17555            character_id: None,
17556            tick: 0,
17557            chunk_rev: 0,
17558            content_rev: 0,
17559            publish_rev: 0,
17560            entities: vec![EntityState {
17561                id: 1,
17562                label: "You".into(),
17563                transform: Transform {
17564                    position: WorldCoord::surface(4.5, 2.0),
17565                    yaw: 0.0,
17566                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
17567                },
17568                vitals: None,
17569                attributes: None,
17570                skills: None,
17571                inside_building: Some("broker_hut".into()),
17572                tile_id: None,
17573                paperdoll_ref: None,
17574                draw_scale: 1.0,
17575                presentation_state: None,
17576                sprite_mode: None,
17577                progression_xp: None,
17578                combat_cues: vec![],
17579                statuses: vec![],
17580            }],
17581            player: None,
17582            resource_nodes: vec![],
17583            ground_drops: vec![],
17584            placed_containers: vec![],
17585            buildings: vec![BuildingView {
17586                id: "broker_hut".into(),
17587                label: "Broker".into(),
17588                x: 158.0,
17589                y: 124.0,
17590                width_m: 8.0,
17591                depth_m: 6.0,
17592                interior_blueprint: Some("broker_hut".into()),
17593                tags: vec![],
17594                market_boundary_zone_ids: vec![],
17595                market_max_volume: None,
17596                wall_set: None,
17597                roof_set: None,
17598            }],
17599            doors: vec![flatland_protocol::DoorView {
17600                id: "broker_hut_exit".into(),
17601                building_id: "broker_hut".into(),
17602                x: 4.3,
17603                y: 0.9,
17604                open: true,
17605                portal: Some("front".into()),
17606                locked: false,
17607                accessible: true,
17608                lock_id: None,
17609            }],
17610            interior_map: None,
17611            npcs: vec![flatland_protocol::NpcView {
17612                id: "ada_broker".into(),
17613                label: "Ada".into(),
17614                x: 4.5,
17615                y: 2.0,
17616                building_id: Some("broker_hut".into()),
17617                role: "broker".into(),
17618                entity_id: None,
17619                life_state: None,
17620                hp_pct: None,
17621                can_trade: true,
17622                buy_templates: vec!["lumber".into()],
17623                tile_id: None,
17624                behavior_state: None,
17625                presentation_state: None,
17626                sprite_mode: None,
17627                paperdoll_ref: None,
17628                draw_scale: 1.0,
17629                yaw: None,
17630                perception_fov_deg: None,
17631                perception_sight_m: None,
17632                perception_hear_m: None,
17633                quest_verbs: Vec::new(),
17634            }],
17635            blueprints: vec![],
17636            building_materials: vec![],
17637            world_x0: 0.0,
17638            world_y0: 0.0,
17639            world_width_m: 256.0,
17640            world_height_m: 256.0,
17641            terrain_zones: Vec::new(),
17642            z_platforms: Vec::new(),
17643            z_transitions: Vec::new(),
17644            z_bands_outdoor_backup: None,
17645            world_clock: flatland_protocol::WorldClock::default(),
17646            inventory: std::collections::HashMap::new(),
17647            inventory_hints: std::collections::HashMap::new(),
17648            item_catalog: std::collections::HashMap::new(),
17649            logs: VecDeque::new(),
17650            intents_sent: 0,
17651            ticks_received: 0,
17652            connected: true,
17653            disconnect_reason: None,
17654            show_stats: false,
17655            hud_log_hidden: false,
17656            show_equip_menu: false,
17657            equip_menu_index: 0,
17658            show_craft_menu: false,
17659            show_plot_build_menu: false,
17660            plot_build_focus_wall: true,
17661            plot_build_wall_index: 0,
17662            plot_build_roof_index: 0,
17663            craft_menu_index: 0,
17664            craft_batch_quantity: 1,
17665            craft_tab: CraftTab::Ready,
17666            craft_filter: String::new(),
17667            craft_filter_focused: false,
17668            craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
17669            show_shop_menu: false,
17670            shop_catalog: None,
17671            bank_panel: None,
17672            bank_menu_index: 0,
17673            bank_ui_mode: BankUiMode::Menu,
17674            storage_panel: None,
17675            market_panel: None,
17676            market_menu_index: 0,
17677            market_filter: String::new(),
17678            market_filter_focused: false,
17679            market_category_filter: None,
17680            market_buy_confirm: None,
17681            market_ui_mode: MarketUiMode::Browse,
17682            storage_menu_index: 0,
17683            storage_ui_mode: StorageUiMode::Menu,
17684            shop_tab: ShopTab::default(),
17685            shop_menu_index: 0,
17686            shop_quantity: 1,
17687            shop_trade_log: VecDeque::new(),
17688            show_npc_verb_menu: false,
17689            npc_verb_target: None,
17690            npc_verb_index: 0,
17691            npc_verb_notice: None,
17692            player_verbs: crate::social::PlayerVerbState::default(),
17693            social_chat: crate::social::SocialChatState::default(),
17694            trade_ui: crate::social::TradeUiState::default(),
17695            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
17696            show_npc_chat: false,
17697            npc_chat: None,
17698            show_inventory_menu: false,
17699            inventory_menu_index: 0,
17700            inventory_tab: InventoryTab::OnPerson,
17701            inventory_filter: String::new(),
17702            inventory_filter_focused: false,
17703            show_move_picker: false,
17704            show_rename_prompt: false,
17705            rename_plot_id: None,
17706            highlighted_plot_id: None,
17707            show_worker_rename: false,
17708            rename_buffer: String::new(),
17709            move_picker_index: 0,
17710            move_picker: None,
17711            show_grant_picker: false,
17712            grant_picker_index: 0,
17713            grant_picker: None,
17714            show_destroy_picker: false,
17715            destroy_confirm_pending: false,
17716            destroy_picker: None,
17717            combat_target: None,
17718            combat_target_label: None,
17719            ground_target: None,
17720            combat_fx: Vec::new(),
17721            ground_hazards: Vec::new(),
17722            property_zones: Vec::new(),
17723            tax_zones: Vec::new(),
17724            growth_zones: Vec::new(),
17725            biome_zones: Vec::new(),
17726            terrain_kind_nav: Vec::new(),
17727            property_plots: Vec::new(),
17728            property_plot_settings: None,
17729            claim_mode: None,
17730            relocate_mode: None,
17731            sell_plot_confirm: None,
17732            sell_plot_armed_at: None,
17733            show_plant_menu: false,
17734            plant_menu_index: 0,
17735            show_farm_access: false,
17736            farm_access_name_draft: String::new(),
17737            farm_access_discount_bps: 0,
17738            farm_access_index: 0,
17739            plant_quantity: 1,
17740            in_combat: false,
17741            auto_attack: true,
17742            combat_has_los: false,
17743            attack_cd_ticks: 0,
17744            gcd_ticks: 0,
17745            weapon_ability_id: "unarmed".into(),
17746            mainhand_template_id: None,
17747            mainhand_label: None,
17748            mainhand_instance_id: None,
17749            offhand_template_id: None,
17750            offhand_label: None,
17751            offhand_instance_id: None,
17752            mainhand_hand_slots: 1,
17753            defense: None,
17754            worn: BTreeMap::new(),
17755            carry_mass: 0.0,
17756            carry_mass_max: 0.0,
17757            encumbrance: flatland_protocol::EncumbranceState::Light,
17758            move_speed_mps: 0.0,
17759            move_speed_mult: 0.0,
17760            inventory_stacks: Vec::new(),
17761            keychain_stacks: Vec::new(),
17762            whisper_pouch_stacks: Vec::new(),
17763            combat_target_detail: None,
17764            statuses: Vec::new(),
17765            cast_progress: None,
17766            timed_channel: None,
17767            plot_build_offer: None,
17768            ability_cooldowns: Vec::new(),
17769            blocking_active: false,
17770            max_target_slots: 1,
17771            combat_slots: Vec::new(),
17772            rotation_presets: Vec::new(),
17773            known_abilities: Vec::new(),
17774            ability_meta: std::collections::HashMap::new(),
17775            ability_mastery: std::collections::HashMap::new(),
17776            hotbar: vec![None; 9],
17777            max_abilities_per_rotation: 0,
17778            show_loadout_menu: false,
17779            show_keychain_menu: false,
17780            keychain_menu_index: 0,
17781            show_rotation_editor: false,
17782            loadout_menu_index: 0,
17783            loadout_hotbar_slot: 1,
17784            loadout_ability_index: 0,
17785            loadout_focus_presets: false,
17786            rotation_editor: RotationEditorState::default(),
17787            harvest_in_progress: false,
17788            harvest_started_at: None,
17789            pending_craft_ack: None,
17790            craft_channel_blueprint_id: None,
17791            pending_worker_job_ack: None,
17792            attending_worker_instance_id: None,
17793            quest_log: Vec::new(),
17794            interactables: Vec::new(),
17795            ledger: None,
17796            career: None,
17797            character_sheet_tab: CharacterSheetTab::Character,
17798            ledger_period: LedgerPeriod::Day,
17799            show_quest_offer: false,
17800            pending_quest_offers: Vec::new(),
17801                quest_offer_index: 0,
17802            show_quest_menu: false,
17803            quest_menu_index: 0,
17804            quest_withdraw_confirm: false,
17805            hired_workers: Vec::new(),
17806            show_workers_menu: false,
17807            workers_menu_index: 0,
17808            worker_dismiss_confirmation: None,
17809            workers_menu_compact: false,
17810            worker_step_display: BTreeMap::new(),
17811            worker_error_display: BTreeMap::new(),
17812            worker_health_ring_until: BTreeMap::new(),
17813            pending_worker_hire_since: None,
17814            show_worker_give_picker: false,
17815            worker_give_picker_index: 0,
17816            worker_give_picker: None,
17817            show_worker_give_target_picker: false,
17818            worker_give_target_picker_index: 0,
17819            worker_give_target_picker: None,
17820            show_worker_take_picker: false,
17821            worker_take_picker_index: 0,
17822            worker_take_picker: None,
17823            show_worker_teach_picker: false,
17824            worker_teach_picker_index: 0,
17825            worker_teach_picker: None,
17826            worker_route_editor: None,
17827            progression_curve: None,
17828        };
17829        state.player = state.entities.first().cloned();
17830        assert_eq!(
17831            state.nearest_interact_target().as_deref(),
17832            Some("ada_broker")
17833        );
17834    }
17835
17836    #[test]
17837    fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
17838        let mut state = sample_state();
17839        // Player is at (128, 128) per sample_state(). One chest just inside
17840        // CONTAINER_RANGE_M, one clearly beyond it.
17841        state.placed_containers = vec![
17842            flatland_protocol::PlacedContainerView {
17843                id: "near".into(),
17844                template_id: "wooden_chest_small".into(),
17845                display_name: "Wooden Chest".into(),
17846                x: 130.0,
17847                y: 128.0,
17848                z: 0.0,
17849                locked: true,
17850                accessible: true,
17851                owner_character_id: None,
17852                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
17853                lock_id: None,
17854                capacity_volume: None,
17855                item_instance_id: Some(uuid::Uuid::from_u128(1)),
17856                tile_id: None,
17857                worker_lodging_capacity: None,
17858                blocking: false,
17859                blocking_radius_m: 0.0,
17860                building_id: None,
17861            },
17862            flatland_protocol::PlacedContainerView {
17863                id: "far".into(),
17864                template_id: "wooden_chest_small".into(),
17865                display_name: "Distant Chest".into(),
17866                x: 128.0 + CONTAINER_RANGE_M + 5.0,
17867                y: 128.0,
17868                z: 0.0,
17869                locked: false,
17870                accessible: true,
17871                owner_character_id: None,
17872                contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
17873                lock_id: None,
17874                capacity_volume: None,
17875                item_instance_id: Some(uuid::Uuid::from_u128(2)),
17876                tile_id: None,
17877                worker_lodging_capacity: None,
17878                blocking: false,
17879                blocking_radius_m: 0.0,
17880                building_id: None,
17881            },
17882        ];
17883
17884        let nearby = state.nearby_containers();
17885        assert_eq!(
17886            nearby.len(),
17887            1,
17888            "far chest must not appear once out of range"
17889        );
17890        assert_eq!(nearby[0].view.id, "near");
17891        assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
17892        assert!(nearby[0].rows[0].is_chest_shell);
17893
17894        // The same chest, but locked and inaccessible (no key held), must hide
17895        // contents but still show the selectable chest shell row.
17896        state.placed_containers[0].accessible = false;
17897        let nearby = state.nearby_containers();
17898        assert_eq!(nearby.len(), 1);
17899        assert_eq!(nearby[0].rows.len(), 1);
17900        assert!(nearby[0].rows[0].is_chest_shell);
17901    }
17902
17903    #[test]
17904    fn chest_pickup_destinations_offer_person_and_worn_bag() {
17905        let mut state = sample_state();
17906        let back_id = uuid::Uuid::from_u128(42);
17907        state.worn.insert(
17908            BodySlot::Back,
17909            flatland_protocol::ItemStack {
17910                template_id: "travel_backpack".into(),
17911                quantity: 1,
17912                item_instance_id: Some(back_id),
17913                props: Default::default(),
17914                status_bindings: Vec::new(),
17915                contents: Vec::new(),
17916                display_name: Some("Travel Backpack".into()),
17917                category: Some("container".into()),
17918                base_mass: Some(2.5),
17919                base_volume: Some(12.0),
17920                capacity_volume: Some(80.0),
17921                stackable: Some(false),
17922                world_placeable: Some(false),
17923                worker_lodging_capacity: None,
17924                equip_slot: None,
17925                armor_physical: None,
17926                resists: vec![],
17927                hand_slots: None,
17928                listable: None,
17929                ..Default::default()
17930            },
17931        );
17932        let opts = state.chest_pickup_destinations("chest-1");
17933        assert!(matches!(
17934            opts.first().map(|o| &o.kind),
17935            Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
17936        ));
17937        assert!(opts.iter().any(|o| matches!(
17938            &o.kind,
17939            MoveOptionKind::PickupPlaced {
17940                nest_parent_instance_id: None,
17941                ..
17942            }
17943        )));
17944        assert!(opts.iter().any(|o| matches!(
17945            &o.kind,
17946            MoveOptionKind::PickupPlaced {
17947                nest_parent_instance_id: Some(id),
17948                ..
17949            } if *id == back_id
17950        )));
17951        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
17952    }
17953
17954    #[test]
17955    fn placed_container_public_label_hides_owner_custom_name() {
17956        let owner = uuid::Uuid::from_u128(99);
17957        let mut state = sample_state();
17958        state.character_id = Some(uuid::Uuid::from_u128(1));
17959        state.inventory_hints.insert(
17960            "wooden_chest_medium".into(),
17961            InventoryHint {
17962                display_name: "Medium Wooden Chest".into(),
17963                category: "container".into(),
17964                base_mass: None,
17965                base_volume: None,
17966                capacity_volume: None,
17967                stackable: false,
17968                listable: true,
17969                base_value_copper: None,
17970            },
17971        );
17972        let chest = flatland_protocol::PlacedContainerView {
17973            id: "c1".into(),
17974            template_id: "wooden_chest_medium".into(),
17975            display_name: "Barry's Loot #a3f2".into(),
17976            x: 128.0,
17977            y: 128.0,
17978            z: 0.0,
17979            locked: false,
17980            accessible: true,
17981            owner_character_id: Some(owner),
17982            contents: vec![],
17983            lock_id: None,
17984            capacity_volume: None,
17985            item_instance_id: None,
17986            tile_id: None,
17987            worker_lodging_capacity: None,
17988            blocking: false,
17989            blocking_radius_m: 0.0,
17990            building_id: None,
17991        };
17992        assert_eq!(
17993            state.placed_container_public_label(&chest),
17994            "Medium Wooden Chest"
17995        );
17996        state.character_id = Some(owner);
17997        assert_eq!(
17998            state.placed_container_public_label(&chest),
17999            "Barry's Loot #a3f2"
18000        );
18001    }
18002
18003    #[test]
18004    fn location_context_shows_crop_growth_percent_not_depleted() {
18005        let mut state = sample_state();
18006        state.player = state.entities.first().cloned();
18007        state.resource_nodes[0].label = "Carrot (growing)".into();
18008        state.resource_nodes[0].x = 128.2;
18009        state.resource_nodes[0].y = 128.0;
18010        state.resource_nodes[0].state = ResourceNodeState::Cooldown;
18011        state.resource_nodes[0].growth_progress = Some(0.47);
18012        let lines = state.location_context_lines();
18013        let line = lines
18014            .iter()
18015            .find(|l| l.text.contains("Carrot"))
18016            .map(|l| l.text.as_str())
18017            .unwrap_or("");
18018        assert!(
18019            line.contains("(growing, 47%)"),
18020            "expected growth percent, got: {line}"
18021        );
18022        assert!(
18023            !line.contains("depleted"),
18024            "growing crop should not show depleted: {line}"
18025        );
18026    }
18027
18028    #[test]
18029    fn resource_node_near_action_suffix_prefers_growth() {
18030        let node = ResourceNodeView {
18031            id: "crop".into(),
18032            label: "Wheat".into(),
18033            x: 0.0,
18034            y: 0.0,
18035            z: 0.0,
18036            item_template: "wheat".into(),
18037            state: ResourceNodeState::Cooldown,
18038            blocking: false,
18039            blocking_radius_m: 0.0,
18040            harvest_off: false,
18041            tile_id: None,
18042            yaw: 0.0,
18043            pitch: 0.0,
18044            roll: 0.0,
18045            draw_scale: 1.0,
18046            sprite_mode: None,
18047            growth_progress: Some(0.12),
18048            presentation_state: None,
18049            channel_start_tick: None,
18050            channel_end_tick: None,
18051            harvest_drop_templates: vec![],
18052        };
18053        assert_eq!(resource_node_near_action_suffix(&node), " (growing, 12%)");
18054    }
18055
18056    #[test]
18057    fn location_context_lists_nearby_resource_node() {
18058        let mut state = sample_state();
18059        state.player = state.entities.first().cloned();
18060        state.resource_nodes[0].x = 128.2;
18061        state.resource_nodes[0].y = 128.0;
18062        let lines = state.location_context_lines();
18063        assert!(
18064            lines
18065                .iter()
18066                .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
18067            "expected resource node in context: {:?}",
18068            lines
18069        );
18070    }
18071
18072    #[test]
18073    fn quest_board_usable_within_board_radius() {
18074        let mut state = sample_state();
18075        state.player = state.entities.first().cloned();
18076        state.interactables = vec![flatland_protocol::InteractableView {
18077            id: "board-1".into(),
18078            kind: "quest_board".into(),
18079            label: "Town Quest Board".into(),
18080            x: 130.5,
18081            y: 128.0,
18082            z: 0.0,
18083            board_id: Some("starter_town_board".into()),
18084        }];
18085        // ~2.5m away — outside the old 1.5m interact radius, inside the 3.0m board radius.
18086        assert_eq!(
18087            state.nearest_interact_target().as_deref(),
18088            Some("board-1"),
18089            "quest board should be selectable at ~2.5m"
18090        );
18091        let lines = state.location_context_lines();
18092        assert!(
18093            lines
18094                .iter()
18095                .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
18096            "HUD should advertise f when board is in range: {:?}",
18097            lines
18098        );
18099    }
18100
18101    #[test]
18102    fn quest_board_keeps_multiple_offers() {
18103        let mut state = sample_state();
18104        let offer = |id: &str, title: &str| flatland_protocol::QuestOffer {
18105            quest_id: id.into(),
18106            title: title.into(),
18107            description: format!("{title} desc"),
18108            step_count: 2,
18109        };
18110        state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18111        state.push_quest_offer(offer("daily_20695_1", "Town board: Deer threat"));
18112        state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18113        assert_eq!(state.pending_quest_offers.len(), 2);
18114        assert_eq!(
18115            state.selected_quest_offer().unwrap().quest_id,
18116            "ada_goblin_hunt"
18117        );
18118        state.move_quest_offer_selection(1);
18119        assert_eq!(
18120            state.selected_quest_offer().unwrap().quest_id,
18121            "daily_20695_1"
18122        );
18123        state.remove_quest_offer("daily_20695_1");
18124        assert_eq!(state.pending_quest_offers.len(), 1);
18125        assert!(state.show_quest_offer);
18126        state.remove_quest_offer("ada_goblin_hunt");
18127        assert!(!state.show_quest_offer);
18128        assert!(state.pending_quest_offers.is_empty());
18129    }
18130
18131    #[test]
18132    fn inventory_selectable_rows_excludes_equipped_shells_but_keeps_bag_contents() {
18133        let mut state = sample_state();
18134        state.worn.insert(
18135            BodySlot::Back,
18136            flatland_protocol::ItemStack {
18137                template_id: "travel_backpack".into(),
18138                quantity: 1,
18139                item_instance_id: Some(uuid::Uuid::from_u128(3)),
18140                props: Default::default(),
18141                status_bindings: Vec::new(),
18142                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
18143                display_name: None,
18144                category: None,
18145                base_mass: None,
18146                base_volume: None,
18147                capacity_volume: None,
18148                stackable: None,
18149                world_placeable: None,
18150                worker_lodging_capacity: None,
18151                equip_slot: None,
18152                armor_physical: None,
18153                resists: vec![],
18154                hand_slots: None,
18155                listable: None,
18156                ..Default::default()
18157            },
18158        );
18159        state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
18160        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18161            id: "chest-1".into(),
18162            template_id: "wooden_chest_small".into(),
18163            display_name: "Wooden Chest".into(),
18164            x: 129.0,
18165            y: 128.0,
18166            z: 0.0,
18167            locked: false,
18168            accessible: true,
18169            owner_character_id: None,
18170            contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
18171            lock_id: None,
18172            capacity_volume: None,
18173            item_instance_id: Some(uuid::Uuid::from_u128(4)),
18174            tile_id: None,
18175            worker_lodging_capacity: None,
18176            blocking: false,
18177            blocking_radius_m: 0.0,
18178            building_id: None,
18179        }];
18180
18181        state.inventory_tab = InventoryTab::OnPerson;
18182        let rows = state.inventory_selectable_rows();
18183        let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
18184        assert_eq!(
18185            sections,
18186            vec![
18187                InventorySection::Person, // iron_ore carried in backpack
18188                InventorySection::Person, // lumber
18189            ]
18190        );
18191        assert_eq!(rows[0].stack.template_id, "iron_ore");
18192        assert_eq!(rows[0].depth, 0);
18193        assert!(!rows[0].is_equip_shell);
18194        assert_eq!(rows[1].stack.template_id, "lumber");
18195
18196        let lines = state.inventory_browser_lines();
18197        assert!(lines.iter().any(|l| matches!(
18198            l,
18199            InventoryBrowserLine::Section(s) if s.contains("carried bags")
18200        )));
18201        assert!(lines.iter().any(|l| matches!(
18202            l,
18203            InventoryBrowserLine::Item { text, .. } if text.contains("iron_ore")
18204        )));
18205        assert!(!lines.iter().any(|l| matches!(
18206            l,
18207            InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
18208        )));
18209        assert!(!lines.iter().any(|l| matches!(
18210            l,
18211            InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
18212        )));
18213
18214        state.inventory_tab = InventoryTab::Nearby;
18215        let nearby_rows = state.inventory_selectable_rows();
18216        assert_eq!(nearby_rows.len(), 2);
18217        assert!(nearby_rows[0].is_chest_shell);
18218        assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
18219        let nearby_lines = state.inventory_browser_lines();
18220        assert!(nearby_lines.iter().any(|l| matches!(
18221            l,
18222            InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
18223        )));
18224    }
18225
18226    #[test]
18227    fn give_worker_notice_does_not_put_item_back_in_bag() {
18228        let mut state = sample_state();
18229        let id = uuid::Uuid::from_u128(42);
18230        let mut saw = flatland_protocol::ItemStack::simple("handsaw", 1);
18231        saw.item_instance_id = Some(id);
18232        saw.display_name = Some("Handsaw".into());
18233        state.sync_inventory_from_stacks(&[saw]);
18234        assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 1);
18235
18236        state.remove_carried_instance(id, None);
18237        assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 0);
18238        assert!(state.inventory_stacks.is_empty());
18239
18240        state.apply_interaction_notice(&flatland_protocol::InteractionNotice {
18241            target_id: "worker-1".into(),
18242            message: "Gave 1x Handsaw to Laborer".into(),
18243            coins_delta: 0,
18244            inventory_delta: vec![flatland_protocol::ItemStack::simple("handsaw", 1)],
18245        });
18246        assert_eq!(
18247            state.inventory.get("handsaw").copied().unwrap_or(0),
18248            0,
18249            "Gave notice must not restore the handed stack"
18250        );
18251    }
18252
18253    #[test]
18254    fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
18255        let mut state = sample_state();
18256        let back_id = uuid::Uuid::from_u128(5);
18257        state.worn.insert(
18258            BodySlot::Back,
18259            flatland_protocol::ItemStack {
18260                template_id: "travel_backpack".into(),
18261                quantity: 1,
18262                item_instance_id: Some(back_id),
18263                props: Default::default(),
18264                status_bindings: Vec::new(),
18265                contents: Vec::new(),
18266                display_name: None,
18267                category: Some("container".into()),
18268                base_mass: None,
18269                base_volume: None,
18270                capacity_volume: Some(80.0),
18271                stackable: None,
18272                world_placeable: None,
18273                worker_lodging_capacity: None,
18274                equip_slot: None,
18275                armor_physical: None,
18276                resists: vec![],
18277                hand_slots: None,
18278                listable: None,
18279                ..Default::default()
18280            },
18281        );
18282        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18283            id: "chest-1".into(),
18284            template_id: "wooden_chest_small".into(),
18285            display_name: "Wooden Chest".into(),
18286            x: 129.0,
18287            y: 128.0,
18288            z: 0.0,
18289            locked: false,
18290            accessible: true,
18291            owner_character_id: None,
18292            contents: Vec::new(),
18293            lock_id: None,
18294            capacity_volume: None,
18295            item_instance_id: Some(uuid::Uuid::from_u128(6)),
18296            tile_id: None,
18297            worker_lodging_capacity: None,
18298            blocking: false,
18299            blocking_radius_m: 0.0,
18300            building_id: None,
18301        }];
18302
18303        // Item currently sitting loose on the person (Root): backpack + nearby
18304        // chest should both be offered, plus Drop/Cancel, but not "Root" itself.
18305        let opts = state.move_destinations_for(
18306            &flatland_protocol::InventoryLocation::Root,
18307            None,
18308            None,
18309            "lumber",
18310        );
18311        assert!(!opts.iter().any(|o| matches!(
18312            &o.kind,
18313            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
18314        )));
18315        assert!(opts.iter().any(|o| matches!(
18316            &o.kind,
18317            MoveOptionKind::Move { location, parent_instance_id, .. }
18318                if *location == flatland_protocol::InventoryLocation::Worn {
18319                    slot: BodySlot::Back,
18320                } && *parent_instance_id == Some(back_id)
18321        )));
18322        assert!(opts.iter().any(|o| matches!(
18323            &o.kind,
18324            MoveOptionKind::Move { location, .. }
18325                if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
18326        )));
18327        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
18328        assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
18329
18330        // Item currently inside the worn backpack: the backpack itself must be
18331        // excluded from its own destination list (can't move an item into the
18332        // container it's already in).
18333        let from_backpack = flatland_protocol::InventoryLocation::Worn {
18334            slot: BodySlot::Back,
18335        };
18336        let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
18337        assert!(!opts.iter().any(|o| matches!(
18338            &o.kind,
18339            MoveOptionKind::Move { location, parent_instance_id, .. }
18340                if *location == from_backpack && *parent_instance_id == Some(back_id)
18341        )));
18342        assert!(opts.iter().any(|o| matches!(
18343            &o.kind,
18344            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
18345        )));
18346    }
18347
18348    #[test]
18349    fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
18350        let mut state = sample_state();
18351        // Insert out of display order — BTreeMap iteration must still yield the
18352        // canonical Head/Body/Arms/Legs/Feet/Back/Waist order regardless.
18353        state.worn.insert(
18354            BodySlot::Waist,
18355            flatland_protocol::ItemStack {
18356                template_id: "simple_belt".into(),
18357                quantity: 1,
18358                item_instance_id: Some(uuid::Uuid::from_u128(10)),
18359                props: Default::default(),
18360                status_bindings: Vec::new(),
18361                contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
18362                display_name: None,
18363                category: Some("container".into()),
18364                base_mass: None,
18365                base_volume: None,
18366                capacity_volume: None,
18367                stackable: None,
18368                world_placeable: None,
18369                worker_lodging_capacity: None,
18370                equip_slot: None,
18371                armor_physical: None,
18372                resists: vec![],
18373                hand_slots: None,
18374                listable: None,
18375                ..Default::default()
18376            },
18377        );
18378        state.worn.insert(
18379            BodySlot::Head,
18380            flatland_protocol::ItemStack {
18381                template_id: "cloth_cap".into(),
18382                quantity: 1,
18383                item_instance_id: Some(uuid::Uuid::from_u128(11)),
18384                props: Default::default(),
18385                status_bindings: Vec::new(),
18386                contents: Vec::new(),
18387                display_name: None,
18388                category: Some("armor".into()),
18389                base_mass: None,
18390                base_volume: None,
18391                capacity_volume: None,
18392                stackable: None,
18393                world_placeable: None,
18394                worker_lodging_capacity: None,
18395                equip_slot: None,
18396                armor_physical: None,
18397                resists: vec![],
18398                hand_slots: None,
18399                listable: None,
18400                ..Default::default()
18401            },
18402        );
18403        state.worn.insert(
18404            BodySlot::Back,
18405            flatland_protocol::ItemStack {
18406                template_id: "travel_backpack".into(),
18407                quantity: 1,
18408                item_instance_id: Some(uuid::Uuid::from_u128(12)),
18409                props: Default::default(),
18410                status_bindings: Vec::new(),
18411                contents: Vec::new(),
18412                display_name: None,
18413                category: Some("container".into()),
18414                base_mass: None,
18415                base_volume: None,
18416                capacity_volume: None,
18417                stackable: None,
18418                world_placeable: None,
18419                worker_lodging_capacity: None,
18420                equip_slot: None,
18421                armor_physical: None,
18422                resists: vec![],
18423                hand_slots: None,
18424                listable: None,
18425                ..Default::default()
18426            },
18427        );
18428
18429        let rows = state.worn_rows();
18430        // Head, then Back, then Waist (+ nested pouch) — enum declaration order.
18431        assert_eq!(rows.len(), 4);
18432        assert_eq!(rows[0].stack.template_id, "cloth_cap");
18433        assert!(rows[0].is_equip_shell);
18434        assert_eq!(rows[1].stack.template_id, "travel_backpack");
18435        assert!(rows[1].is_equip_shell);
18436        assert_eq!(rows[2].stack.template_id, "simple_belt");
18437        assert!(rows[2].is_equip_shell);
18438        assert_eq!(rows[3].stack.template_id, "leather_pouch");
18439        assert_eq!(rows[3].depth, 1);
18440        assert!(!rows[3].is_equip_shell);
18441    }
18442
18443    #[test]
18444    fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
18445        let mut state = sample_state();
18446        state.worn.insert(
18447            BodySlot::Waist,
18448            flatland_protocol::ItemStack {
18449                template_id: "simple_belt".into(),
18450                quantity: 1,
18451                item_instance_id: Some(uuid::Uuid::from_u128(20)),
18452                props: Default::default(),
18453                status_bindings: Vec::new(),
18454                contents: Vec::new(),
18455                display_name: Some("Simple Belt".into()),
18456                category: Some("container".into()),
18457                base_mass: None,
18458                base_volume: None,
18459                capacity_volume: None,
18460                stackable: None,
18461                world_placeable: None,
18462                worker_lodging_capacity: None,
18463                equip_slot: None,
18464                armor_physical: None,
18465                resists: vec![],
18466                hand_slots: None,
18467                listable: None,
18468                ..Default::default()
18469            },
18470        );
18471        state.worn.insert(
18472            BodySlot::Head,
18473            flatland_protocol::ItemStack {
18474                template_id: "cloth_cap".into(),
18475                quantity: 1,
18476                item_instance_id: Some(uuid::Uuid::from_u128(21)),
18477                props: Default::default(),
18478                status_bindings: Vec::new(),
18479                contents: Vec::new(),
18480                display_name: Some("Cloth Cap".into()),
18481                category: Some("armor".into()),
18482                base_mass: None,
18483                base_volume: None,
18484                capacity_volume: None,
18485                stackable: None,
18486                world_placeable: None,
18487                worker_lodging_capacity: None,
18488                equip_slot: None,
18489                armor_physical: None,
18490                resists: vec![],
18491                hand_slots: None,
18492                listable: None,
18493                ..Default::default()
18494            },
18495        );
18496
18497        let opts = state.move_destinations_for(
18498            &flatland_protocol::InventoryLocation::Root,
18499            None,
18500            None,
18501            "leather_pouch",
18502        );
18503        assert!(
18504            opts.iter().any(|o| matches!(
18505                &o.kind,
18506                MoveOptionKind::Move { location, .. }
18507                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18508            )),
18509            "belt loop must be offered when moving a pouch"
18510        );
18511        assert!(
18512            !opts.iter().any(|o| matches!(
18513                &o.kind,
18514                MoveOptionKind::Move { location, .. }
18515                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
18516            )),
18517            "armor slots can't hold other items and must not appear as move destinations"
18518        );
18519        let belt_opt = opts
18520            .iter()
18521            .find(|o| matches!(
18522                &o.kind,
18523                MoveOptionKind::Move { location, .. }
18524                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18525            ))
18526            .unwrap();
18527        assert!(belt_opt.label.contains("belt loop"));
18528
18529        let opts = state.move_destinations_for(
18530            &flatland_protocol::InventoryLocation::Root,
18531            None,
18532            None,
18533            "lumber",
18534        );
18535        assert!(
18536            !opts.iter().any(|o| o.label.contains("belt loop")),
18537            "loose materials must not target the belt shell — only nested pouches"
18538        );
18539    }
18540
18541    #[test]
18542    fn move_destinations_for_offers_dimensional_pouch_on_belt() {
18543        let mut state = sample_state();
18544        let belt_id = uuid::Uuid::from_u128(30);
18545        let pouch_id = uuid::Uuid::from_u128(31);
18546        state.worn.insert(
18547            BodySlot::Waist,
18548            flatland_protocol::ItemStack {
18549                template_id: "simple_belt".into(),
18550                quantity: 1,
18551                item_instance_id: Some(belt_id),
18552                props: Default::default(),
18553                status_bindings: Vec::new(),
18554                world_placeable: None,
18555                worker_lodging_capacity: None,
18556                equip_slot: None,
18557                armor_physical: None,
18558                resists: vec![],
18559                hand_slots: None,
18560                contents: vec![flatland_protocol::ItemStack {
18561                    template_id: "dimensional_pouch".into(),
18562                    quantity: 1,
18563                    item_instance_id: Some(pouch_id),
18564                    props: Default::default(),
18565                    status_bindings: Vec::new(),
18566                    contents: Vec::new(),
18567                    display_name: Some("Dimensional Pouch".into()),
18568                    category: Some("container".into()),
18569                    base_mass: None,
18570                    base_volume: None,
18571                    capacity_volume: Some(200.0),
18572                    stackable: None,
18573                    world_placeable: None,
18574                    worker_lodging_capacity: None,
18575                    equip_slot: None,
18576                    armor_physical: None,
18577                    resists: vec![],
18578                    hand_slots: None,
18579                    listable: None,
18580                    ..Default::default()
18581                }],
18582                display_name: Some("Simple Belt".into()),
18583                category: Some("container".into()),
18584                base_mass: None,
18585                base_volume: None,
18586                capacity_volume: None,
18587                stackable: None,
18588                listable: None,
18589                ..Default::default()
18590            },
18591        );
18592
18593        let opts = state.move_destinations_for(
18594            &flatland_protocol::InventoryLocation::Root,
18595            None,
18596            None,
18597            "iron_ore",
18598        );
18599        assert!(
18600            opts.iter().any(|o| matches!(
18601                &o.kind,
18602                MoveOptionKind::Move {
18603                    location,
18604                    parent_instance_id,
18605                    ..
18606                } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18607                    && *parent_instance_id == Some(pouch_id)
18608            )),
18609            "dimensional pouch clipped on belt must accept loose items"
18610        );
18611        assert!(
18612            opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
18613            "destination label should name the pouch"
18614        );
18615    }
18616
18617    #[test]
18618    fn container_volume_label_on_placed_chest_shell() {
18619        let mut state = sample_state();
18620        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18621            id: "chest-1".into(),
18622            template_id: "wooden_chest_small".into(),
18623            display_name: "Camp Chest".into(),
18624            x: 129.0,
18625            y: 128.0,
18626            z: 0.0,
18627            locked: false,
18628            accessible: true,
18629            owner_character_id: None,
18630            contents: vec![flatland_protocol::ItemStack {
18631                template_id: "iron_ore".into(),
18632                quantity: 2,
18633                item_instance_id: None,
18634                props: Default::default(),
18635                status_bindings: Vec::new(),
18636                contents: Vec::new(),
18637                display_name: None,
18638                category: None,
18639                base_mass: None,
18640                base_volume: Some(2.0),
18641                capacity_volume: None,
18642                stackable: None,
18643                world_placeable: None,
18644                worker_lodging_capacity: None,
18645                equip_slot: None,
18646                armor_physical: None,
18647                resists: vec![],
18648                hand_slots: None,
18649                listable: None,
18650                ..Default::default()
18651            }],
18652            lock_id: None,
18653            capacity_volume: Some(60.0),
18654            item_instance_id: Some(uuid::Uuid::from_u128(4)),
18655            tile_id: None,
18656            worker_lodging_capacity: None,
18657            blocking: false,
18658            blocking_radius_m: 0.0,
18659            building_id: None,
18660        }];
18661        let nearby = state.nearby_containers();
18662        let label = state.container_volume_label(&nearby[0].rows[0]);
18663        assert!(
18664            label.contains("vol 4/60"),
18665            "expected used/cap in label, got {label}"
18666        );
18667        assert!(
18668            label.contains("56 free"),
18669            "expected free space, got {label}"
18670        );
18671    }
18672
18673    #[test]
18674    fn key_pair_chest_label_from_placed_lock_id() {
18675        let mut state = sample_state();
18676        let owner = uuid::Uuid::from_u128(77);
18677        state.character_id = Some(owner);
18678        let lock = uuid::Uuid::from_u128(99).to_string();
18679        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18680            id: "chest-1".into(),
18681            template_id: "wooden_chest_small".into(),
18682            display_name: "Barry's Loot #a3f2".into(),
18683            x: 129.0,
18684            y: 128.0,
18685            z: 0.0,
18686            locked: true,
18687            accessible: true,
18688            owner_character_id: Some(owner),
18689            contents: Vec::new(),
18690            lock_id: Some(lock.clone()),
18691            capacity_volume: None,
18692            item_instance_id: Some(uuid::Uuid::from_u128(4)),
18693            tile_id: None,
18694            worker_lodging_capacity: None,
18695            blocking: false,
18696            blocking_radius_m: 0.0,
18697            building_id: None,
18698        }];
18699        let key_id = uuid::Uuid::from_u128(5);
18700        let key = flatland_protocol::ItemStack {
18701            template_id: KEY_TEMPLATE.into(),
18702            quantity: 1,
18703            item_instance_id: Some(key_id),
18704            props: BTreeMap::from([
18705                (PROP_OPENS_LOCK_ID.into(), lock),
18706                (
18707                    PROP_OPENS_CONTAINER_NAME.into(),
18708                    "Barry's Loot #a3f2".into(),
18709                ),
18710            ]),
18711            status_bindings: Vec::new(),
18712            contents: Vec::new(),
18713            display_name: Some("Container Key".into()),
18714            category: Some("key".into()),
18715            base_mass: None,
18716            base_volume: None,
18717            capacity_volume: None,
18718            stackable: None,
18719            world_placeable: None,
18720            worker_lodging_capacity: None,
18721            equip_slot: None,
18722            armor_physical: None,
18723            resists: vec![],
18724            hand_slots: None,
18725            listable: None,
18726            ..Default::default()
18727        };
18728        state.inventory_stacks = vec![key.clone()];
18729        assert_eq!(
18730            state.key_pair_chest_label(&key).as_deref(),
18731            Some("Barry's Loot #a3f2")
18732        );
18733        assert!(state.key_drop_blocked(&key));
18734    }
18735
18736    #[test]
18737    fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
18738        let mut state = sample_state();
18739        let lock = uuid::Uuid::from_u128(101).to_string();
18740        let key = flatland_protocol::ItemStack {
18741            template_id: KEY_TEMPLATE.into(),
18742            quantity: 1,
18743            item_instance_id: Some(uuid::Uuid::from_u128(7)),
18744            props: BTreeMap::from([
18745                (PROP_OPENS_LOCK_ID.into(), lock),
18746                (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
18747            ]),
18748            status_bindings: Vec::new(),
18749            contents: Vec::new(),
18750            display_name: None,
18751            category: Some("key".into()),
18752            base_mass: None,
18753            base_volume: None,
18754            capacity_volume: None,
18755            stackable: None,
18756            world_placeable: None,
18757            worker_lodging_capacity: None,
18758            equip_slot: None,
18759            armor_physical: None,
18760            resists: vec![],
18761            hand_slots: None,
18762            listable: None,
18763            ..Default::default()
18764        };
18765        state.placed_containers.clear();
18766        assert_eq!(
18767            state.key_pair_chest_label(&key).as_deref(),
18768            Some("Camp Stash")
18769        );
18770    }
18771
18772    #[test]
18773    fn key_drop_allowed_when_paired_chest_unlocked() {
18774        let mut state = sample_state();
18775        let lock = uuid::Uuid::from_u128(100).to_string();
18776        let key_id = uuid::Uuid::from_u128(6);
18777        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18778            id: "chest-1".into(),
18779            template_id: "wooden_chest_small".into(),
18780            display_name: "Camp Chest".into(),
18781            x: 129.0,
18782            y: 128.0,
18783            z: 0.0,
18784            locked: false,
18785            accessible: true,
18786            owner_character_id: None,
18787            contents: Vec::new(),
18788            lock_id: Some(lock.clone()),
18789            capacity_volume: None,
18790            item_instance_id: None,
18791            tile_id: None,
18792            worker_lodging_capacity: None,
18793            blocking: false,
18794            blocking_radius_m: 0.0,
18795            building_id: None,
18796        }];
18797        let key = flatland_protocol::ItemStack {
18798            template_id: KEY_TEMPLATE.into(),
18799            quantity: 1,
18800            item_instance_id: Some(key_id),
18801            props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
18802            status_bindings: Vec::new(),
18803            contents: Vec::new(),
18804            display_name: None,
18805            category: Some("key".into()),
18806            base_mass: None,
18807            base_volume: None,
18808            capacity_volume: None,
18809            stackable: None,
18810            world_placeable: None,
18811            worker_lodging_capacity: None,
18812            equip_slot: None,
18813            armor_physical: None,
18814            resists: vec![],
18815            hand_slots: None,
18816            listable: None,
18817            ..Default::default()
18818        };
18819        state.inventory_stacks = vec![key.clone()];
18820        assert!(!state.key_drop_blocked(&key));
18821        let opts = state.move_destinations_for(
18822            &flatland_protocol::InventoryLocation::Root,
18823            None,
18824            Some(key_id),
18825            KEY_TEMPLATE,
18826        );
18827        assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
18828    }
18829
18830    #[test]
18831    fn combat_hud_refreshes_progression_xp_when_entity_stale() {
18832        use flatland_protocol::{CombatHud, ProgressionCurve, ProgressionXp};
18833
18834        let mut state = sample_state();
18835        let curve = ProgressionCurve::default();
18836        let bootstrap =
18837            ProgressionXp::bootstrap_new(curve.baseline_display, curve.xp_base, curve.xp_growth);
18838        let mut fresh = bootstrap.clone();
18839        fresh.strength += 0.08;
18840        if let Some(player) = state.player.as_mut() {
18841            player.progression_xp = Some(bootstrap);
18842        }
18843
18844        let combat = CombatHud {
18845            progression_xp: Some(fresh.clone()),
18846            progression_baseline: curve.baseline_display,
18847            progression_xp_base: curve.xp_base,
18848            progression_xp_growth: curve.xp_growth,
18849            attributes: state.player.as_ref().and_then(|p| p.attributes),
18850            skills: state.player.as_ref().and_then(|p| p.skills.clone()),
18851            ..CombatHud::default()
18852        };
18853        state.apply_combat_hud(&combat);
18854
18855        let xp = state
18856            .player
18857            .as_ref()
18858            .and_then(|p| p.progression_xp.as_ref())
18859            .expect("xp");
18860        assert!((xp.strength - fresh.strength).abs() < 0.001);
18861        assert!(state.progression_curve.is_some());
18862    }
18863
18864    #[test]
18865    fn combat_hud_syncs_known_abilities_and_hotbar() {
18866        use flatland_protocol::CombatHud;
18867
18868        let mut state = sample_state();
18869        let combat = CombatHud {
18870            known_abilities: vec!["unarmed".into(), "fireball".into()],
18871            hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
18872            max_abilities_per_rotation: 4,
18873            ability_id: "short_sword_slash".into(),
18874            ..CombatHud::default()
18875        };
18876        state.apply_combat_hud(&combat);
18877
18878        assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
18879        assert_eq!(state.hotbar_ability(1), Some("fireball"));
18880        assert_eq!(state.hotbar_ability(2), None);
18881        assert_eq!(state.hotbar_ability(3), Some("unarmed"));
18882        assert_eq!(state.max_abilities_per_rotation, 4);
18883        let choices = state.loadout_ability_choices();
18884        assert!(choices.iter().any(|a| a == "short_sword_slash"));
18885        assert!(choices.iter().any(|a| a == "fireball"));
18886    }
18887
18888    #[test]
18889    fn loadout_hotbar_choices_include_inventory_consumables() {
18890        let mut state = sample_state();
18891        state.known_abilities = vec!["unarmed".into()];
18892        state.weapon_ability_id = "unarmed".into();
18893        state.inventory_stacks = vec![flatland_protocol::ItemStack {
18894            template_id: "empty_bottle".into(),
18895            quantity: 1,
18896            item_instance_id: Some(uuid::Uuid::from_u128(9)),
18897            display_name: Some("Glass Bottle of Water".into()),
18898            category: Some("container".into()),
18899            props: [
18900                ("serving".into(), "1".into()),
18901                ("liquid_vessel".into(), "1".into()),
18902                ("serving_holds".into(), "liquid".into()),
18903            ]
18904            .into_iter()
18905            .collect(),
18906            ..Default::default()
18907        }];
18908        state.inventory.insert("empty_bottle".into(), 1);
18909        state.inventory_hints.insert(
18910            "empty_bottle".into(),
18911            InventoryHint {
18912                display_name: "Glass Bottle".into(),
18913                category: "container".into(),
18914                ..Default::default()
18915            },
18916        );
18917
18918        let choices = state.loadout_hotbar_choices();
18919        assert!(choices.iter().any(|c| c.binding == "unarmed"));
18920        let water = choices
18921            .iter()
18922            .find(|c| c.binding == "item:empty_bottle")
18923            .expect("serving bottle binding");
18924        assert_eq!(water.meta.as_deref(), Some("use"));
18925        assert!(water.label.contains("Glass Bottle of Water"));
18926        assert_eq!(state.hotbar_slot_label(1), None, "unbound until set");
18927        state.hotbar = vec![None, None, None, None, Some("item:empty_bottle".into())];
18928        assert_eq!(
18929            state.hotbar_slot_label(5).as_deref(),
18930            Some("Glass Bottle×1")
18931        );
18932    }
18933
18934    #[test]
18935    fn loadout_hotbar_choices_exclude_blueprint_scrolls() {
18936        let mut state = sample_state();
18937        state.known_abilities = vec!["unarmed".into()];
18938        state.weapon_ability_id = "unarmed".into();
18939        state.inventory_stacks = vec![
18940            flatland_protocol::ItemStack {
18941                template_id: "carrot".into(),
18942                quantity: 2,
18943                display_name: Some("Wild Carrot".into()),
18944                category: Some("consumable".into()),
18945                ..Default::default()
18946            },
18947            flatland_protocol::ItemStack {
18948                template_id: "blueprint_dimensional_pouch".into(),
18949                quantity: 1,
18950                display_name: Some("Blueprint — Dimensional Pouch".into()),
18951                category: Some("consumable".into()),
18952                props: [("teaches_blueprint".into(), "craft_dimensional_pouch".into())]
18953                    .into_iter()
18954                    .collect(),
18955                ..Default::default()
18956            },
18957        ];
18958        state.inventory.insert("carrot".into(), 2);
18959        state.inventory.insert("blueprint_dimensional_pouch".into(), 1);
18960        state.inventory_hints.insert(
18961            "carrot".into(),
18962            InventoryHint {
18963                display_name: "Wild Carrot".into(),
18964                category: "consumable".into(),
18965                ..Default::default()
18966            },
18967        );
18968        state.inventory_hints.insert(
18969            "blueprint_dimensional_pouch".into(),
18970            InventoryHint {
18971                display_name: "Blueprint — Dimensional Pouch".into(),
18972                category: "consumable".into(),
18973                ..Default::default()
18974            },
18975        );
18976
18977        let choices = state.loadout_hotbar_choices();
18978        assert!(choices.iter().any(|c| c.binding == "item:carrot"));
18979        assert!(
18980            choices
18981                .iter()
18982                .all(|c| c.binding != "item:blueprint_dimensional_pouch"),
18983            "recipe scrolls must not appear on the hotbar picker: {choices:?}"
18984        );
18985    }
18986
18987    #[test]
18988    fn storage_store_options_excludes_hand_equipped() {
18989        let mut state = sample_state();
18990        let sword_id = uuid::Uuid::from_u128(11);
18991        let ore_id = uuid::Uuid::from_u128(22);
18992        state.inventory_stacks = vec![
18993            flatland_protocol::ItemStack {
18994                template_id: "short_sword".into(),
18995                quantity: 1,
18996                item_instance_id: Some(sword_id),
18997                display_name: Some("Short Sword".into()),
18998                category: Some("weapon".into()),
18999                ..Default::default()
19000            },
19001            flatland_protocol::ItemStack {
19002                template_id: "iron_ore".into(),
19003                quantity: 5,
19004                item_instance_id: Some(ore_id),
19005                display_name: Some("Iron Ore".into()),
19006                category: Some("resource".into()),
19007                ..Default::default()
19008            },
19009        ];
19010        state.mainhand_template_id = Some("short_sword".into());
19011        state.mainhand_instance_id = Some(sword_id);
19012
19013        let opts = state.storage_store_options();
19014        assert_eq!(opts.len(), 1);
19015        assert_eq!(opts[0].item_instance_id, ore_id);
19016        assert!(state.hand_equipped_instance_ids().contains(&sword_id));
19017    }
19018
19019    #[test]
19020    fn loose_consumable_move_picker_offers_use_and_storage() {
19021        let mut state = sample_state();
19022        let inst = uuid::Uuid::from_u128(77);
19023        state.inventory_stacks = vec![flatland_protocol::ItemStack {
19024            template_id: "carrot".into(),
19025            quantity: 2,
19026            item_instance_id: Some(inst),
19027            props: Default::default(),
19028            status_bindings: Vec::new(),
19029            contents: Vec::new(),
19030            display_name: Some("Wild Carrot".into()),
19031            category: Some("consumable".into()),
19032            base_mass: None,
19033            base_volume: None,
19034            capacity_volume: None,
19035            stackable: Some(true),
19036            world_placeable: None,
19037            worker_lodging_capacity: None,
19038            equip_slot: None,
19039            armor_physical: None,
19040            resists: vec![],
19041            hand_slots: None,
19042            listable: None,
19043            ..Default::default()
19044        }];
19045        state.inventory_hints.insert(
19046            "carrot".into(),
19047            InventoryHint {
19048                display_name: "Wild Carrot".into(),
19049                category: "consumable".into(),
19050                base_mass: Some(0.15),
19051                base_volume: Some(0.3),
19052                capacity_volume: None,
19053                stackable: true,
19054                listable: true,
19055                base_value_copper: None,
19056            },
19057        );
19058        state.show_inventory_menu = true;
19059        state.inventory_menu_index = 0;
19060
19061        let row = state.inventory_selected_row().expect("carrot row");
19062        let mut options = state.move_destinations_for(
19063            &row.from,
19064            row.from_parent_instance_id,
19065            row.stack.item_instance_id,
19066            &row.stack.template_id,
19067        );
19068        if row.from == flatland_protocol::InventoryLocation::Root
19069            && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
19070        {
19071            options.insert(
19072                0,
19073                MoveOption {
19074                    label: "Use (eat / drink)".into(),
19075                    kind: MoveOptionKind::Use,
19076                },
19077            );
19078        }
19079
19080        assert_eq!(
19081            options.first().map(|o| &o.label),
19082            Some(&"Use (eat / drink)".into())
19083        );
19084        assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
19085        assert!(options
19086            .iter()
19087            .any(|o| matches!(o.kind, MoveOptionKind::Drop)));
19088    }
19089
19090    #[test]
19091    fn inventory_category_group_order_is_stable() {
19092        assert_eq!(inventory_category_group("weapon").0, "Weapons");
19093        assert_eq!(inventory_category_group("armor").0, "Armor");
19094        assert_eq!(inventory_category_group("consumable").0, "Consumables");
19095        assert_eq!(inventory_category_group("liquid").0, "Consumables");
19096        assert_eq!(inventory_category_group("resource").0, "Resources");
19097        assert_eq!(inventory_category_group("container").0, "Containers");
19098        assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
19099        assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
19100    }
19101
19102    #[test]
19103    fn page_list_index_clamps_without_wrap() {
19104        assert_eq!(page_list_index(0, -1, 25), 0);
19105        assert_eq!(page_list_index(0, 1, 25), 10);
19106        assert_eq!(page_list_index(12, 1, 25), 22);
19107        assert_eq!(page_list_index(22, 1, 25), 24);
19108        assert_eq!(page_list_index(5, 1, 0), 0);
19109        assert_eq!(page_list_index(3, -1, 8), 0);
19110    }
19111
19112    #[test]
19113    fn inventory_filter_hides_non_matching_person_items() {
19114        let mut state = sample_state();
19115        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
19116        sword.display_name = Some("Iron Sword".into());
19117        sword.category = Some("weapon".into());
19118        let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
19119        herb.display_name = Some("Wild Herb".into());
19120        herb.category = Some("consumable".into());
19121        state.inventory_stacks = vec![sword, herb];
19122        state.inventory_tab = InventoryTab::OnPerson;
19123        state.inventory_filter = "sword".into();
19124
19125        let rows = state.inventory_selectable_rows();
19126        assert_eq!(rows.len(), 1);
19127        assert_eq!(rows[0].stack.template_id, "iron_sword");
19128
19129        let lines = state.inventory_browser_lines();
19130        assert!(lines.iter().any(|l| matches!(
19131            l,
19132            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
19133        )));
19134        assert!(!lines.iter().any(|l| matches!(
19135            l,
19136            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
19137        )));
19138    }
19139
19140    #[test]
19141    fn list_filter_chars_reject_mac_arrow_glyphs() {
19142        assert!(is_list_filter_char('a'));
19143        assert!(is_list_filter_char(' '));
19144        assert!(is_list_filter_char('-'));
19145        assert!(!is_list_filter_char('\u{F700}'));
19146        assert!(!is_list_filter_char('\u{F701}'));
19147        assert!(!is_list_filter_char('\n'));
19148    }
19149
19150    #[test]
19151    fn ready_tab_keeps_in_progress_craft_after_inputs_spent() {
19152        let mut state = sample_state();
19153        state.craft_tab = CraftTab::Ready;
19154        state.blueprints = vec![BlueprintView {
19155            id: "plank".into(),
19156            label: "Plank".into(),
19157            craft_tier: 1,
19158            craft_ticks: 30,
19159            output: "wood_plank".into(),
19160            output_qty: 1,
19161            output_display_name: "Wood Plank".into(),
19162            station: None,
19163            category: None,
19164            inputs: vec![flatland_protocol::BlueprintIngredientView {
19165                template_id: "oak_log".into(),
19166                quantity: 1,
19167                consumed: true,
19168                display_name: "Oak Log".into(),
19169            }],
19170            required_tools: vec![],
19171            skill: None,
19172            failure_chance: 0.0,
19173            worker_train_copper: 0,
19174        }];
19175        // Materials already consumed at craft begin — would drop off Ready without the pin.
19176        state.inventory.clear();
19177        state.craft_channel_blueprint_id = Some("plank".into());
19178        state.timed_channel = Some(flatland_protocol::TimedChannelHud {
19179            label: "Crafting Plank".into(),
19180            channel: flatland_protocol::TimedChannelKind::Craft,
19181            ticks_remaining: 20,
19182            ticks_total: 30,
19183            ..Default::default()
19184        });
19185
19186        let idxs = state.craft_filtered_indices();
19187        assert_eq!(idxs, vec![0]);
19188        assert!(state.craft_blueprint_in_channel("plank"));
19189
19190        // Channel finished → clear pin → Ready empty.
19191        state.timed_channel = None;
19192        state.craft_channel_blueprint_id = None;
19193        assert!(state.craft_filtered_indices().is_empty());
19194    }
19195
19196    #[test]
19197    fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
19198        let mut state = sample_state();
19199        let id_a = uuid::Uuid::from_u128(0xa1);
19200        let id_b = uuid::Uuid::from_u128(0xb2);
19201        let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
19202        sword_a.display_name = Some("Iron Sword".into());
19203        sword_a.category = Some("weapon".into());
19204        sword_a.item_instance_id = Some(id_a);
19205        let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
19206        sword_b.display_name = Some("Iron Sword".into());
19207        sword_b.category = Some("weapon".into());
19208        sword_b.item_instance_id = Some(id_b);
19209        state.inventory_stacks = vec![sword_a, sword_b];
19210        state.inventory_tab = InventoryTab::OnPerson;
19211
19212        let lines = state.inventory_browser_lines();
19213        let items: Vec<_> = lines
19214            .iter()
19215            .filter_map(|l| match l {
19216                InventoryBrowserLine::Item {
19217                    title,
19218                    instance_tooltip,
19219                    ..
19220                } => Some((title.clone(), instance_tooltip.clone())),
19221                _ => None,
19222            })
19223            .collect();
19224        assert_eq!(items.len(), 2);
19225        for (title, tip) in &items {
19226            assert!(
19227                !title.contains('#'),
19228                "title should not show instance suffix: {title}"
19229            );
19230            assert!(
19231                tip.is_some(),
19232                "two identical rows should expose instance on hover"
19233            );
19234        }
19235
19236        state.inventory_stacks.pop();
19237        let lines = state.inventory_browser_lines();
19238        let one = lines.iter().find_map(|l| match l {
19239            InventoryBrowserLine::Item {
19240                title,
19241                instance_tooltip,
19242                ..
19243            } => Some((title.clone(), instance_tooltip.clone())),
19244            _ => None,
19245        });
19246        let (title, tip) = one.expect("one sword row");
19247        assert!(!title.contains('#'));
19248        assert!(tip.is_none(), "single row should not need instance tooltip");
19249    }
19250
19251    #[test]
19252    fn inventory_person_rows_group_by_category() {
19253        let mut state = sample_state();
19254        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
19255        sword.category = Some("weapon".into());
19256        sword.display_name = Some("Iron Sword".into());
19257        let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
19258        ore.category = Some("resource".into());
19259        ore.display_name = Some("Iron Ore".into());
19260        let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
19261        potion.category = Some("consumable".into());
19262        potion.display_name = Some("Health Potion".into());
19263        state.inventory_stacks = vec![ore, potion, sword];
19264        state.inventory_tab = InventoryTab::OnPerson;
19265
19266        let lines = state.inventory_browser_lines();
19267        let labels: Vec<&str> = lines
19268            .iter()
19269            .filter_map(|l| match l {
19270                InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
19271                _ => None,
19272            })
19273            .collect();
19274        assert!(
19275            labels.iter().any(|s| s.contains("Weapons")),
19276            "expected Weapons group: {labels:?}"
19277        );
19278        assert!(labels.iter().any(|s| s.contains("Consumables")));
19279        assert!(labels.iter().any(|s| s.contains("Resources")));
19280
19281        let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
19282        let consumable_pos = labels
19283            .iter()
19284            .position(|s| s.contains("Consumables"))
19285            .unwrap();
19286        let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
19287        assert!(weapon_pos < consumable_pos);
19288        assert!(consumable_pos < resource_pos);
19289    }
19290
19291    #[test]
19292    fn inventory_tab_cycle_resets_selection() {
19293        let mut state = sample_state();
19294        state.inventory_tab = InventoryTab::OnPerson;
19295        state.inventory_menu_index = 3;
19296        state.inventory_tab = state.inventory_tab.cycle(true);
19297        assert_eq!(state.inventory_tab, InventoryTab::Nearby);
19298        // Client method resets index; enum cycle alone does not — verify cycle labels.
19299        assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
19300        assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
19301        assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
19302        assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
19303    }
19304
19305    #[test]
19306    fn parse_bank_copper_amount_blank_and_zero_mean_all() {
19307        assert_eq!(parse_bank_copper_amount(""), Some(0));
19308        assert_eq!(parse_bank_copper_amount("  "), Some(0));
19309        assert_eq!(parse_bank_copper_amount("0"), Some(0));
19310        assert_eq!(parse_bank_copper_amount("250"), Some(250));
19311        assert_eq!(parse_bank_copper_amount("nope"), None);
19312    }
19313
19314    #[test]
19315    fn parse_storage_quantity_blank_and_zero_mean_all() {
19316        assert_eq!(parse_storage_quantity(""), Some(None));
19317        assert_eq!(parse_storage_quantity("  "), Some(None));
19318        assert_eq!(parse_storage_quantity("0"), Some(None));
19319        assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
19320        assert_eq!(parse_storage_quantity("nope"), None);
19321    }
19322
19323    #[test]
19324    fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
19325        assert!(worker_error_is_hud_noise("path stuck — repathing"));
19326        assert!(worker_error_is_hud_noise(
19327            "path stuck — nudged clear, repathing"
19328        ));
19329        assert!(worker_error_is_hud_noise(
19330            "returned to lodging after path failures"
19331        ));
19332        // Real problem: player may need to place lodging / fix assignment.
19333        assert!(!worker_error_is_hud_noise(
19334            "path stuck — no lodging to reset to"
19335        ));
19336        assert!(!worker_error_is_hud_noise(
19337            "cannot reach Eli — idling"
19338        ));
19339    }
19340
19341    #[test]
19342    fn leaving_building_restores_outdoor_z_bands() {
19343        use flatland_protocol::{InteriorMapView, ZPlatformView};
19344
19345        let mut state = sample_state();
19346        state.z_platforms.clear();
19347        state.z_transitions.clear();
19348        state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
19349        state.interior_map = Some(InteriorMapView {
19350            building_id: "broker_hut".into(),
19351            blueprint_id: "broker_hut".into(),
19352            background_color: "#000".into(),
19353            default_floor_color: None,
19354            floor_height_m: 3.0,
19355            z_platforms: vec![ZPlatformView {
19356                id: "floor_0".into(),
19357                z: 0.0,
19358                x0: 0.0,
19359                y0: 0.0,
19360                x1: 8.0,
19361                y1: 8.0,
19362            }],
19363            z_transitions: vec![],
19364            rooms: vec![],
19365            room_doors: vec![],
19366        });
19367        state.sync_interior_map_context();
19368        assert_eq!(
19369            state.z_platforms.len(),
19370            1,
19371            "indoors installs interior platforms"
19372        );
19373        assert!(state.z_bands_outdoor_backup.is_some());
19374
19375        state.player.as_mut().unwrap().inside_building = None;
19376        state.sync_interior_map_context();
19377        assert!(
19378            state.z_platforms.is_empty(),
19379            "leaving must restore outdoor bands (empty), not leave interior platforms"
19380        );
19381        assert!(state.z_bands_outdoor_backup.is_none());
19382        assert!(state.interior_map.is_none());
19383    }
19384
19385    #[test]
19386    fn resource_node_route_label_prefers_friendly_label_with_suffix() {
19387        let node = ResourceNodeView {
19388            id: "crop-carrot-1_copy10".into(),
19389            label: "crop-carrot-1_copy10".into(),
19390            x: 0.0,
19391            y: 0.0,
19392            z: 0.0,
19393            item_template: "carrot".into(),
19394            state: ResourceNodeState::Available,
19395            blocking: false,
19396            blocking_radius_m: 0.5,
19397            harvest_off: false,
19398            tile_id: None,
19399            yaw: 0.0,
19400            pitch: 0.0,
19401            roll: 0.0,
19402            draw_scale: 1.0,
19403            sprite_mode: None,
19404            growth_progress: None,
19405            presentation_state: None,
19406            channel_start_tick: None,
19407            channel_end_tick: None,
19408            harvest_drop_templates: vec![],
19409        };
19410        let label = super::resource_node_route_label(&node);
19411        assert!(label.starts_with("Carrot ("), "got {label}");
19412        assert!(label.ends_with(')'), "got {label}");
19413
19414        let mut named = node;
19415        named.label = "Sweet Pad".into();
19416        named.id = "crop-carrot-a3f2b1c0".into();
19417        assert_eq!(super::resource_node_route_label(&named), "Sweet Pad (b1c0)");
19418    }
19419
19420    #[test]
19421    fn plot_public_label_uses_owner_zone_and_label() {
19422        let plot = flatland_protocol::PropertyPlotView {
19423            plot_id: uuid::Uuid::nil(),
19424            property_zone_id: "zone_a".into(),
19425            zone_label: Some("Starter Town East 1".into()),
19426            deed_instance_id: uuid::Uuid::nil(),
19427            x0: 0.0,
19428            y0: 0.0,
19429            x1: 4.0,
19430            y1: 4.0,
19431            upkeep_copper_per_day: 1,
19432            arrears_days: 0,
19433            is_mine: true,
19434            may_farm: true,
19435            purchase_basis_copper: 0,
19436            farm_public: false,
19437            public_tax_discount_bps: 0,
19438            farm_allow: vec![],
19439            owner_character_id: None,
19440            owner_label: Some("Madsin".into()),
19441            building_id: None,
19442            plot_code: "xyz1234a".into(),
19443            label: "Food Pad".into(),
19444        };
19445        assert_eq!(
19446            super::plot_public_label(&plot),
19447            "Madsin — Starter Town East 1 — Food Pad"
19448        );
19449    }
19450
19451    #[test]
19452    fn plot_public_label_uses_size_when_label_and_code_blank() {
19453        let plot = flatland_protocol::PropertyPlotView {
19454            plot_id: uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap(),
19455            property_zone_id: String::new(),
19456            zone_label: None,
19457            deed_instance_id: uuid::Uuid::nil(),
19458            x0: 10.0,
19459            y0: 20.0,
19460            x1: 18.0,
19461            y1: 28.0,
19462            upkeep_copper_per_day: 1,
19463            arrears_days: 0,
19464            is_mine: true,
19465            may_farm: true,
19466            purchase_basis_copper: 0,
19467            farm_public: false,
19468            public_tax_discount_bps: 0,
19469            farm_allow: vec![],
19470            owner_character_id: None,
19471            owner_label: None,
19472            building_id: None,
19473            plot_code: String::new(),
19474            label: String::new(),
19475        };
19476        assert_eq!(super::plot_public_label(&plot), "Homestead — Plot (8×8 m)");
19477        assert!(!super::plot_public_label(&plot).contains("19fe35f"));
19478    }
19479
19480    #[test]
19481    fn plot_stop_label_prefers_view_over_hex() {
19482        let plot_id = uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap();
19483        let plot = flatland_protocol::PropertyPlotView {
19484            plot_id,
19485            property_zone_id: "zone_a".into(),
19486            zone_label: Some("Starter Town East".into()),
19487            deed_instance_id: uuid::Uuid::nil(),
19488            x0: 0.0,
19489            y0: 0.0,
19490            x1: 4.0,
19491            y1: 4.0,
19492            upkeep_copper_per_day: 1,
19493            arrears_days: 0,
19494            is_mine: true,
19495            may_farm: true,
19496            purchase_basis_copper: 0,
19497            farm_public: false,
19498            public_tax_discount_bps: 0,
19499            farm_allow: vec![],
19500            owner_character_id: None,
19501            owner_label: Some("Madsin".into()),
19502            building_id: None,
19503            plot_code: "xyz1234a".into(),
19504            label: "Food Pad".into(),
19505        };
19506        assert_eq!(
19507            super::plot_stop_label(&[plot.clone()], plot_id),
19508            "Madsin — Starter Town East — Food Pad"
19509        );
19510        let missing = uuid::Uuid::parse_str("1a001590-0000-4000-8000-000000000002").unwrap();
19511        assert_eq!(super::plot_stop_label(&[plot], missing), "plot 1a001590");
19512    }
19513}