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        let templates = self.re_template_candidates();
12254        crate::worker_route_editor::trade_npc_candidates(
12255            &self.state.npcs,
12256            px,
12257            py,
12258            &templates,
12259        )
12260    }
12261
12262    fn re_template_candidates(&self) -> Vec<String> {
12263        let mut extra = Vec::new();
12264        if let Some(ed) = self.state.worker_route_editor.as_ref() {
12265            for stop in &ed.stops {
12266                match stop {
12267                    crate::worker_route_editor::WorkerRouteStop::DepositAt {
12268                        filter: Some(filter),
12269                        ..
12270                    } => extra.extend(filter.iter().cloned()),
12271                    crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. }
12272                    | crate::worker_route_editor::WorkerRouteStop::ListOnMarket {
12273                        template,
12274                        ..
12275                    } => {
12276                        extra.push(template.clone());
12277                    }
12278                    crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
12279                        if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint)
12280                        {
12281                            extra.push(bp.output.clone());
12282                            for input in &bp.inputs {
12283                                extra.push(input.template_id.clone());
12284                            }
12285                        }
12286                    }
12287                    crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
12288                        for it in items {
12289                            extra.push(it.template.clone());
12290                        }
12291                    }
12292                    _ => {}
12293                }
12294            }
12295            // Worker-known recipe outputs even if the player hasn't learned them yet.
12296            if let Some(worker) = self
12297                .state
12298                .hired_workers
12299                .iter()
12300                .find(|w| w.instance_id == ed.worker_instance_id)
12301            {
12302                for recipe in &worker.known_blueprint_ids {
12303                    if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
12304                        extra.push(bp.output.clone());
12305                    }
12306                }
12307                for stack in &worker.inventory {
12308                    if !stack.template_id.is_empty() && stack.quantity > 0 {
12309                        extra.push(stack.template_id.clone());
12310                    }
12311                }
12312            }
12313        }
12314        crate::worker_route_editor::route_item_template_candidates(
12315            &self.state.placed_containers,
12316            self.state.character_id,
12317            &self.state.inventory,
12318            &self.state.blueprints,
12319            &self.state.resource_nodes,
12320            &extra,
12321            Some(&self.state.item_catalog),
12322        )
12323    }
12324
12325    fn re_blueprint_ids(&self) -> Vec<String> {
12326        let worker_known: Option<&[String]> = self
12327            .state
12328            .worker_route_editor
12329            .as_ref()
12330            .and_then(|ed| {
12331                self.state
12332                    .hired_workers
12333                    .iter()
12334                    .find(|w| w.instance_id == ed.worker_instance_id)
12335            })
12336            .map(|w| w.known_blueprint_ids.as_slice());
12337        crate::worker_route_editor::worker_craft_blueprint_ids(&self.state.blueprints, worker_known)
12338    }
12339
12340    fn re_bed_candidates(&self) -> Vec<(String, String)> {
12341        crate::worker_route_editor::owned_lodging_container_ids(
12342            &self.state.placed_containers,
12343            self.state.character_id,
12344        )
12345    }
12346
12347    fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
12348        self.state
12349            .placed_containers
12350            .iter()
12351            .find(|c| c.id == container_id)
12352            .map(|c| c.contents.clone())
12353            .unwrap_or_default()
12354    }
12355
12356    // ---- sheet navigation (`plans/33`) --------------------------------
12357
12358    fn re_sheet_supports_filter(&self) -> bool {
12359        use crate::worker_route_editor::RouteEditorSheet as S;
12360        self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12361            matches!(
12362                ed.sheet,
12363                S::HarvestPicker { .. }
12364                    | S::SellItem { .. }
12365                    | S::MarketListItem { .. }
12366                    | S::DepositFilter { .. }
12367                    | S::WithdrawItems { .. }
12368                    | S::WithdrawContainers { .. }
12369                    | S::DepositContainers { .. }
12370                    | S::SellNpcs { .. }
12371                    | S::CraftBlueprint { .. }
12372                    | S::BedPicker { .. }
12373            )
12374        })
12375    }
12376
12377    /// Whether a sheet row is shown under the current `/` filter.
12378    pub fn re_sheet_row_visible(&self, row: usize) -> bool {
12379        use crate::worker_route_editor::{
12380            harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
12381            ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
12382        };
12383        let Some(ed) = self.state.worker_route_editor.as_ref() else {
12384            return false;
12385        };
12386        let filter = &ed.sheet_filter;
12387        match &ed.sheet {
12388            S::HarvestPicker { nodes, .. } => harvest_picker_row_matches(nodes, row, filter),
12389            S::SellItem { templates, .. } | S::MarketListItem { templates, .. } => {
12390                if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
12391                    return true;
12392                }
12393                let slot = row.saturating_sub(2);
12394                templates.get(slot).is_some_and(|t| {
12395                    let label = self.state.template_display_name(t);
12396                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12397                })
12398            }
12399            S::DepositFilter { rows, .. } => {
12400                if row >= rows.len() {
12401                    return true;
12402                }
12403                rows.get(row).is_some_and(|(t, _)| {
12404                    let label = self.state.template_display_name(t);
12405                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12406                })
12407            }
12408            S::WithdrawItems { lines, .. } => {
12409                if row >= lines.len() {
12410                    return true;
12411                }
12412                lines.get(row).is_some_and(|l| {
12413                    let label = self.state.template_display_name(&l.template);
12414                    list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
12415                })
12416            }
12417            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12418                self.re_container_candidates().get(row).is_some_and(|c| {
12419                    list_filter_row_matches(
12420                        filter,
12421                        Some(c.dist),
12422                        &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
12423                    )
12424                })
12425            }
12426            S::SellNpcs { .. } => {
12427                if row == 0 {
12428                    return true;
12429                }
12430                self.re_npc_candidates().get(row - 1).is_some_and(|n| {
12431                    list_filter_row_matches(
12432                        filter,
12433                        Some(n.dist),
12434                        &[n.label.as_str(), n.id.as_str()],
12435                    )
12436                })
12437            }
12438            S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
12439                let label = self
12440                    .state
12441                    .blueprints
12442                    .iter()
12443                    .find(|b| &b.id == id)
12444                    .map(|b| {
12445                        if b.label.is_empty() {
12446                            id.as_str()
12447                        } else {
12448                            b.label.as_str()
12449                        }
12450                    })
12451                    .unwrap_or(id.as_str());
12452                list_filter_row_matches(filter, None, &[id.as_str(), label])
12453            }),
12454            S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
12455                list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
12456            }),
12457            _ => true,
12458        }
12459    }
12460
12461    fn re_sheet_clamp_index(&mut self) {
12462        let count = self.re_sheet_row_count();
12463        if count == 0 {
12464            return;
12465        }
12466        let cur = self.re_sheet_index();
12467        if self.re_sheet_row_visible(cur) {
12468            return;
12469        }
12470        for offset in 1..count {
12471            if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
12472                self.re_sheet_set_index(cur + offset);
12473                return;
12474            }
12475            if cur >= offset && self.re_sheet_row_visible(cur - offset) {
12476                self.re_sheet_set_index(cur - offset);
12477                return;
12478            }
12479        }
12480    }
12481
12482    fn re_sheet_set_index(&mut self, index: usize) {
12483        use crate::worker_route_editor::RouteEditorSheet as S;
12484        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12485            return;
12486        };
12487        match &mut ed.sheet {
12488            S::AddMenu { index: slot }
12489            | S::WaypointMenu { index: slot }
12490            | S::HarvestPicker { index: slot, .. }
12491            | S::WithdrawContainers { index: slot }
12492            | S::DepositContainers { index: slot }
12493            | S::SellNpcs { index: slot }
12494            | S::CraftBlueprint { index: slot }
12495            | S::BedPicker { index: slot }
12496            | S::FarmPlotPicker { index: slot, .. }
12497            | S::FarmPlantSeed { index: slot, .. }
12498            | S::WithdrawItems { index: slot, .. }
12499            | S::DepositFilter { index: slot, .. }
12500            | S::SellItem { index: slot, .. } | S::MarketListItem { index: slot, .. } => *slot = index,
12501            _ => {}
12502        }
12503    }
12504
12505    pub fn re_focus_sheet_filter(&mut self) {
12506        if !self.re_sheet_supports_filter() {
12507            return;
12508        }
12509        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12510            ed.sheet_filter_focused = true;
12511        }
12512    }
12513
12514    pub fn re_blur_sheet_filter_keep_text(&mut self) {
12515        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12516            return;
12517        };
12518        if !ed.sheet_filter_focused {
12519            return;
12520        }
12521        ed.sheet_filter_focused = false;
12522        self.re_sheet_clamp_index();
12523    }
12524
12525    pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
12526        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12527            return false;
12528        };
12529        if ed.sheet_filter_focused {
12530            ed.sheet_filter_focused = false;
12531            self.re_sheet_clamp_index();
12532            return true;
12533        }
12534        if !ed.sheet_filter.is_empty() {
12535            ed.sheet_filter.clear();
12536            self.re_sheet_clamp_index();
12537            return true;
12538        }
12539        false
12540    }
12541
12542    pub fn re_append_sheet_filter_char(&mut self, ch: char) {
12543        if ch.is_control() {
12544            return;
12545        }
12546        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12547            return;
12548        };
12549        if !ed.sheet_filter_focused {
12550            return;
12551        }
12552        ed.sheet_filter.push(ch);
12553        self.re_sheet_set_index(0);
12554        self.re_sheet_clamp_index();
12555    }
12556
12557    pub fn re_sheet_filter_backspace(&mut self) {
12558        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12559            return;
12560        };
12561        if !ed.sheet_filter_focused {
12562            return;
12563        }
12564        ed.sheet_filter.pop();
12565        self.re_sheet_set_index(0);
12566        self.re_sheet_clamp_index();
12567    }
12568
12569    /// Row count of the current sheet (for cursor wrapping).
12570    pub fn re_sheet_row_count(&self) -> usize {
12571        use crate::worker_route_editor::{
12572            harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
12573        };
12574        let Some(ed) = self.state.worker_route_editor.as_ref() else {
12575            return 0;
12576        };
12577        match &ed.sheet {
12578            S::Stops => ed.stops.len(),
12579            S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
12580            S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
12581            S::WaypointMapPick => 0,
12582            S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
12583            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12584                self.re_container_candidates().len()
12585            }
12586            S::WithdrawItems { lines, .. } => lines.len() + 1, // + Done row
12587            S::DepositFilter { rows, .. } => rows.len() + 1,   // + Done row
12588            S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, // + auto row
12589            S::SellItem { templates, .. } | S::MarketListItem { templates, .. } => {
12590                sell_item_picker_row_count(templates.len())
12591            }
12592            S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
12593            S::WaitEntry { .. } => 1,
12594            S::BedPicker { .. } => self.re_bed_candidates().len(),
12595            S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
12596            S::FarmPlantSeed { seeds, .. } => seeds.len(),
12597        }
12598    }
12599
12600    /// Current sheet cursor index (0 for sheets without one).
12601    pub fn re_sheet_index(&self) -> usize {
12602        use crate::worker_route_editor::RouteEditorSheet as S;
12603        let Some(ed) = self.state.worker_route_editor.as_ref() else {
12604            return 0;
12605        };
12606        match &ed.sheet {
12607            S::AddMenu { index }
12608            | S::WaypointMenu { index }
12609            | S::HarvestPicker { index, .. }
12610            | S::WithdrawContainers { index }
12611            | S::DepositContainers { index }
12612            | S::SellNpcs { index }
12613            | S::CraftBlueprint { index }
12614            | S::BedPicker { index }
12615            | S::FarmPlotPicker { index, .. }
12616            | S::FarmPlantSeed { index, .. }
12617            | S::WithdrawItems { index, .. }
12618            | S::DepositFilter { index, .. }
12619            | S::SellItem { index, .. } | S::MarketListItem { index, .. } => *index,
12620            _ => 0,
12621        }
12622    }
12623
12624    /// Move the current sheet's cursor, wrapping within its rows.
12625    pub fn re_sheet_move(&mut self, delta: i32) {
12626        let count = self.re_sheet_row_count();
12627        if count == 0 {
12628            return;
12629        }
12630        let cur = self.re_sheet_index();
12631        let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
12632        self.re_sheet_set_index(next);
12633    }
12634
12635    pub fn re_sheet_page(&mut self, pages: i32) {
12636        let count = self.re_sheet_row_count();
12637        if count == 0 {
12638            return;
12639        }
12640        let cur = self.re_sheet_index();
12641        let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
12642        self.re_sheet_set_index(next);
12643    }
12644
12645    /// `[`/`]` on a sheet: adjust quantity (withdraw lines, wait ticks).
12646    pub fn re_sheet_adjust(&mut self, delta: i32) {
12647        use crate::worker_route_editor::RouteEditorSheet as S;
12648        let index = self.re_sheet_index();
12649        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12650            return;
12651        };
12652        match &mut ed.sheet {
12653            S::WithdrawItems { lines, .. } => {
12654                if let Some(line) = lines.get_mut(index) {
12655                    line.adjust_qty(delta);
12656                }
12657            }
12658            S::WaitEntry { ticks } => {
12659                *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
12660            }
12661            _ => {}
12662        }
12663    }
12664
12665    pub fn re_sheet_back(&mut self) {
12666        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12667            return;
12668        };
12669        use crate::worker_route_editor::RouteEditorSheet as S;
12670        let was_editing = ed.editing_index.is_some();
12671        let from_top_picker = matches!(
12672            ed.sheet,
12673            S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
12674        );
12675        ed.sheet_back();
12676        if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
12677            // Chest retarget commits on pick; Esc here just ends the edit sheet.
12678            self.state
12679                .push_log("Route: left edit sheet — press s to save current stops".to_string());
12680        }
12681    }
12682
12683    /// True when the editor is at its root sheet (Esc should close it).
12684    pub fn re_at_root_sheet(&self) -> bool {
12685        self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12686            matches!(
12687                ed.sheet,
12688                crate::worker_route_editor::RouteEditorSheet::Stops
12689            )
12690        })
12691    }
12692
12693    pub fn re_open_add_menu(&mut self) {
12694        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12695            ed.open_add_menu();
12696        }
12697    }
12698
12699    pub fn re_open_bed_picker(&mut self) {
12700        let beds = self.re_bed_candidates();
12701        if beds.is_empty() {
12702            self.state
12703                .push_log("Route: place a camp bed first".to_string());
12704            return;
12705        }
12706        let current = self
12707            .state
12708            .worker_route_editor
12709            .as_ref()
12710            .and_then(|ed| ed.lodging_container_id.clone());
12711        let index = current
12712            .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
12713            .unwrap_or(0);
12714        self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
12715    }
12716
12717    fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
12718        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12719            ed.open_sheet(sheet);
12720        }
12721    }
12722
12723    /// Confirm a sheet's stop and log the outcome.
12724    fn re_confirm_stop(&mut self, stop: crate::worker_route_editor::WorkerRouteStop, what: String) {
12725        let appended = self
12726            .state
12727            .worker_route_editor
12728            .as_mut()
12729            .is_some_and(|ed| ed.confirm_stop(stop));
12730        if appended {
12731            self.state.push_log(format!("Route: + {what}"));
12732        } else {
12733            self.state
12734                .push_log(format!("Route: {what} already in route — selected it"));
12735        }
12736    }
12737
12738    fn re_open_withdraw_items(&mut self, container_id: String) {
12739        use crate::worker_route_editor::{
12740            RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
12741        };
12742        let contents = self.re_container_contents(&container_id);
12743        // When editing, keep the stop's item picks even if the player retargets
12744        // to a different (possibly empty) chest — otherwise Done has nothing to
12745        // confirm and the edit appears to "revert".
12746        let existing = self
12747            .state
12748            .worker_route_editor
12749            .as_ref()
12750            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12751            .and_then(|stop| match stop {
12752                WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
12753                _ => None,
12754            })
12755            .unwrap_or_default();
12756        let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
12757        // Commit the new chest immediately while editing so Esc-before-Done still
12758        // keeps the retarget (items update only when Done is pressed).
12759        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12760            let _ = ed.retarget_withdraw_container(container_id.clone());
12761        }
12762        self.re_open_sheet(S::WithdrawItems {
12763            container_id,
12764            lines,
12765            index: 0,
12766        });
12767    }
12768
12769    fn re_withdraw_items_activate(&mut self, index: usize) {
12770        use crate::worker_route_editor::{
12771            RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
12772        };
12773        enum Outcome {
12774            Cycled,
12775            Confirmed(String),
12776            Empty,
12777        }
12778        let outcome = {
12779            let Some(ed) = self.state.worker_route_editor.as_mut() else {
12780                return;
12781            };
12782            let S::WithdrawItems {
12783                container_id,
12784                lines,
12785                index: sheet_index,
12786            } = &mut ed.sheet
12787            else {
12788                return;
12789            };
12790            *sheet_index = index;
12791            if index < lines.len() {
12792                lines[index].cycle();
12793                Outcome::Cycled
12794            } else {
12795                let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
12796                if items.is_empty() {
12797                    Outcome::Empty
12798                } else {
12799                    let stop = WorkerRouteStop::WithdrawFrom {
12800                        container_id: container_id.clone(),
12801                        items,
12802                    };
12803                    let summary = stop.summary();
12804                    ed.confirm_stop(stop);
12805                    Outcome::Confirmed(summary)
12806                }
12807            }
12808        };
12809        match outcome {
12810            Outcome::Cycled => {}
12811            Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
12812            Outcome::Empty => self.state.push_log(
12813                "Route: pick at least one item (Space/Enter toggles All/qty)".to_string(),
12814            ),
12815        }
12816    }
12817
12818    fn re_open_deposit_filter(&mut self, container_id: String) {
12819        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12820        // Preserve filter when retargeting the deposit chest while editing.
12821        let existing_filter = self
12822            .state
12823            .worker_route_editor
12824            .as_ref()
12825            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12826            .and_then(|stop| match stop {
12827                WorkerRouteStop::DepositAt { filter, .. } => {
12828                    Some(filter.clone().unwrap_or_default())
12829                }
12830                _ => None,
12831            });
12832        let mut candidates = self.re_template_candidates();
12833        if let Some(ref chosen) = existing_filter {
12834            for t in chosen {
12835                if !candidates.iter().any(|c| c == t) {
12836                    candidates.push(t.clone());
12837                }
12838            }
12839            candidates.sort();
12840            candidates.dedup();
12841        }
12842        let rows: Vec<(String, bool)> = match existing_filter {
12843            Some(chosen) => candidates
12844                .iter()
12845                .map(|t| (t.clone(), chosen.contains(t)))
12846                .collect(),
12847            None => candidates.into_iter().map(|t| (t, false)).collect(),
12848        };
12849        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12850            let _ = ed.retarget_deposit_container(container_id.clone());
12851        }
12852        self.re_open_sheet(S::DepositFilter {
12853            container_id,
12854            rows,
12855            index: 0,
12856        });
12857    }
12858
12859    fn re_deposit_filter_activate(&mut self, index: usize) {
12860        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12861        let mut confirmed: Option<String> = None;
12862        {
12863            let Some(ed) = self.state.worker_route_editor.as_mut() else {
12864                return;
12865            };
12866            let S::DepositFilter {
12867                container_id,
12868                rows,
12869                index: sheet_index,
12870            } = &mut ed.sheet
12871            else {
12872                return;
12873            };
12874            *sheet_index = index;
12875            if index < rows.len() {
12876                rows[index].1 = !rows[index].1;
12877            } else {
12878                // "Done" row.
12879                let chosen: Vec<String> = rows
12880                    .iter()
12881                    .filter(|(_, on)| *on)
12882                    .map(|(t, _)| t.clone())
12883                    .collect();
12884                let filter = if chosen.is_empty() {
12885                    None
12886                } else {
12887                    Some(chosen)
12888                };
12889                let stop = WorkerRouteStop::DepositAt {
12890                    container_id: container_id.clone(),
12891                    filter,
12892                };
12893                confirmed = Some(stop.summary());
12894                ed.confirm_stop(stop);
12895            }
12896        }
12897        if let Some(what) = confirmed {
12898            self.state.push_log(format!("Route: + {what}"));
12899        }
12900    }
12901
12902    fn re_open_sell_item(&mut self, npc_id: Option<String>) {
12903        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12904        // Prefill when editing an existing sell stop.
12905        let (pre_npc, pre_template, pre_all) = self
12906            .state
12907            .worker_route_editor
12908            .as_ref()
12909            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12910            .and_then(|stop| match stop {
12911                WorkerRouteStop::TradeWith {
12912                    npc_id,
12913                    template,
12914                    sell_all,
12915                } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
12916                _ => None,
12917            })
12918            .unwrap_or((None, None, true));
12919        let npc_id = npc_id.or(pre_npc);
12920        let mut templates = crate::worker_route_editor::sellable_route_item_template_candidates(
12921            &self.re_template_candidates(),
12922            &self.state.npcs,
12923            npc_id.as_deref(),
12924        );
12925        // Keep an existing invalid selection visible long enough to remove it
12926        // when editing a route after NPC buy lists change.
12927        if let Some(template) = pre_template.as_ref() {
12928            if !templates.iter().any(|candidate| candidate == template) {
12929                templates.push(template.clone());
12930                templates.sort();
12931            }
12932        }
12933        if templates.is_empty() {
12934            let msg = crate::worker_route_editor::sell_merchant_empty_reason(
12935                npc_id.as_deref(),
12936                &self.state.npcs,
12937                &self.re_template_candidates(),
12938            );
12939            self.state.push_log(msg);
12940            return;
12941        }
12942        let mut picked = std::collections::BTreeSet::new();
12943        if let Some(t) = pre_template {
12944            picked.insert(t);
12945        }
12946        self.re_open_sheet(S::SellItem {
12947            npc_id,
12948            templates,
12949            index: if picked.is_empty() {
12950                crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
12951            } else {
12952                2
12953            },
12954            sell_all: pre_all,
12955            picked,
12956        });
12957    }
12958
12959    fn re_sell_item_activate(&mut self, index: usize) {
12960        use crate::worker_route_editor::{
12961            RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
12962        };
12963        let mut batch_log: Option<String> = None;
12964        {
12965            let Some(ed) = self.state.worker_route_editor.as_mut() else {
12966                return;
12967            };
12968            let S::SellItem {
12969                npc_id,
12970                templates,
12971                index: sheet_index,
12972                sell_all,
12973                picked,
12974            } = &mut ed.sheet
12975            else {
12976                return;
12977            };
12978            *sheet_index = index;
12979            if index == ROUTE_PICKER_DONE_ROW {
12980                if picked.is_empty() {
12981                    batch_log =
12982                        Some("Route: pick at least one item (Space toggles, Done confirms)".into());
12983                } else {
12984                    let picks: Vec<String> = picked.iter().cloned().collect();
12985                    let npc = npc_id.clone();
12986                    let all = *sell_all;
12987                    let added = ed.confirm_trade_picks(npc, &picks, all);
12988                    batch_log = Some(format!("Route: + {added} sell stop(s)"));
12989                }
12990            } else if index == SELL_ITEM_TOGGLE_ROW {
12991                *sell_all = !*sell_all;
12992            } else if let Some(template) = templates.get(index.saturating_sub(2)) {
12993                let sellable = crate::worker_route_editor::sellable_route_item_template_candidates(
12994                    std::slice::from_ref(template),
12995                    &self.state.npcs,
12996                    npc_id.as_deref(),
12997                )
12998                .iter()
12999                .any(|candidate| candidate == template);
13000                if !sellable && !picked.contains(template) {
13001                    return;
13002                }
13003                if picked.contains(template) {
13004                    picked.remove(template);
13005                } else {
13006                    picked.insert(template.clone());
13007                }
13008            }
13009        }
13010        if let Some(msg) = batch_log {
13011            self.state.push_log(msg);
13012        }
13013    }
13014
13015    fn re_open_market_list_item(&mut self) {
13016        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13017        let (pre_hall, pre_template, pre_all) = self
13018            .state
13019            .worker_route_editor
13020            .as_ref()
13021            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
13022            .and_then(|stop| match stop {
13023                WorkerRouteStop::ListOnMarket {
13024                    hall_id,
13025                    template,
13026                    list_all,
13027                } => Some((hall_id.clone(), Some(template.clone()), *list_all)),
13028                _ => None,
13029            })
13030            .unwrap_or((None, None, true));
13031        let mut templates = self.re_template_candidates();
13032        // Prefer items with a known NPC-value hint when available; keep the rest
13033        // so the stop can still be authored (sim rejects no-value / unlistable).
13034        templates.sort_by_key(|t| {
13035            std::cmp::Reverse(self.state.item_base_value_copper_hint(t).unwrap_or(0))
13036        });
13037        if let Some(template) = pre_template.as_ref() {
13038            if !templates.iter().any(|c| c == template) {
13039                templates.push(template.clone());
13040            }
13041        }
13042        if templates.is_empty() {
13043            self.state.push_log("Route: no item templates available for market list".to_string());
13044            return;
13045        }
13046        let mut picked = std::collections::BTreeSet::new();
13047        if let Some(t) = pre_template {
13048            picked.insert(t);
13049        }
13050        self.re_open_sheet(S::MarketListItem {
13051            hall_id: pre_hall,
13052            templates,
13053            index: if picked.is_empty() {
13054                crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
13055            } else {
13056                2
13057            },
13058            list_all: pre_all,
13059            picked,
13060        });
13061    }
13062
13063    fn re_market_list_item_activate(&mut self, index: usize) {
13064        use crate::worker_route_editor::{
13065            RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
13066        };
13067        let mut batch_log: Option<String> = None;
13068        {
13069            let Some(ed) = self.state.worker_route_editor.as_mut() else {
13070                return;
13071            };
13072            let S::MarketListItem {
13073                hall_id,
13074                templates,
13075                index: sheet_index,
13076                list_all,
13077                picked,
13078            } = &mut ed.sheet
13079            else {
13080                return;
13081            };
13082            *sheet_index = index;
13083            if index == ROUTE_PICKER_DONE_ROW {
13084                if picked.is_empty() {
13085                    batch_log = Some(
13086                        "Route: pick at least one item (Space toggles, Done confirms)".into(),
13087                    );
13088                } else {
13089                    let picks: Vec<String> = picked.iter().cloned().collect();
13090                    let hall = hall_id.clone();
13091                    let all = *list_all;
13092                    let added = ed.confirm_market_list_picks(hall, &picks, all);
13093                    batch_log = Some(format!("Route: + {added} market-list stop(s)"));
13094                }
13095            } else if index == SELL_ITEM_TOGGLE_ROW {
13096                *list_all = !*list_all;
13097            } else if let Some(template) = templates.get(index.saturating_sub(2)) {
13098                if picked.contains(template) {
13099                    picked.remove(template);
13100                } else {
13101                    picked.insert(template.clone());
13102                }
13103            }
13104        }
13105        if let Some(msg) = batch_log {
13106            self.state.push_log(msg);
13107        }
13108    }
13109
13110    /// Enter on the stop list: open the selected stop's sheet, prefilled.
13111    pub fn re_edit_selected_stop(&mut self) {
13112        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13113        let Some(stop) = self
13114            .state
13115            .worker_route_editor
13116            .as_ref()
13117            .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
13118        else {
13119            self.state
13120                .push_log("Route: no stop selected — press a to add one".to_string());
13121            return;
13122        };
13123        if let Some(ed) = self.state.worker_route_editor.as_mut() {
13124            ed.begin_edit_selected();
13125        }
13126        match stop {
13127            WorkerRouteStop::Waypoint { .. } => {
13128                self.re_open_sheet(S::WaypointMenu { index: 0 });
13129            }
13130            WorkerRouteStop::HarvestNode { node_id } => {
13131                let nodes = self.state.route_editor_node_candidates();
13132                if nodes.is_empty() {
13133                    self.re_cancel_edit();
13134                    self.state
13135                        .push_log("Route: no harvestable nodes visible to retarget".to_string());
13136                } else {
13137                    let mut picked = std::collections::BTreeSet::new();
13138                    picked.insert(node_id.clone());
13139                    let index = nodes
13140                        .iter()
13141                        .position(|n| n.id == node_id)
13142                        .map(|i| i + 1)
13143                        .unwrap_or(1);
13144                    self.re_open_harvest_picker(index, picked);
13145                }
13146            }
13147            WorkerRouteStop::WithdrawFrom { container_id, .. } => {
13148                // Open the container picker so the player can retarget storage
13149                // (previously jumped straight to items on the same chest).
13150                let containers = self.re_container_candidates();
13151                if containers.is_empty() {
13152                    self.re_cancel_edit();
13153                    self.state
13154                        .push_log("Route: place a storage chest first".to_string());
13155                } else {
13156                    let index = containers
13157                        .iter()
13158                        .position(|c| c.id == container_id)
13159                        .unwrap_or(0);
13160                    self.re_open_sheet(S::WithdrawContainers { index });
13161                }
13162            }
13163            WorkerRouteStop::DepositAt { container_id, .. } => {
13164                let containers = self.re_container_candidates();
13165                if containers.is_empty() {
13166                    self.re_cancel_edit();
13167                    self.state
13168                        .push_log("Route: place a storage chest first".to_string());
13169                } else {
13170                    let index = containers
13171                        .iter()
13172                        .position(|c| c.id == container_id)
13173                        .unwrap_or(0);
13174                    self.re_open_sheet(S::DepositContainers { index });
13175                }
13176            }
13177            WorkerRouteStop::TradeWith { npc_id, .. } => {
13178                let npcs = self.re_npc_candidates();
13179                // Row 0 is "auto / nearest"; rows 1.. map to npcs.
13180                let index = npc_id
13181                    .as_ref()
13182                    .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
13183                    .unwrap_or(0);
13184                self.re_open_sheet(S::SellNpcs { index });
13185            }
13186            WorkerRouteStop::ListOnMarket { .. } => {
13187                self.re_open_market_list_item();
13188            }
13189            WorkerRouteStop::CraftAt { blueprint, .. } => {
13190                let bps = self.re_blueprint_ids();
13191                let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
13192                if bps.is_empty() {
13193                    self.re_cancel_edit();
13194                    self.state
13195                        .push_log("Route: no known blueprints to retarget".to_string());
13196                } else {
13197                    self.re_open_sheet(S::CraftBlueprint { index });
13198                }
13199            }
13200            WorkerRouteStop::CultivatePlot { .. } => {
13201                self.re_open_farm_plot_picker(
13202                    crate::worker_route_editor::FarmPlotAction::Cultivate,
13203                );
13204            }
13205            WorkerRouteStop::PlantPlot { .. } => {
13206                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
13207            }
13208            WorkerRouteStop::HarvestPlot { .. } => {
13209                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
13210            }
13211            WorkerRouteStop::RestIfNeeded => {
13212                self.re_cancel_edit();
13213                self.state
13214                    .push_log("Route: rest has no settings (change the bed with l)".to_string());
13215            }
13216            WorkerRouteStop::Wait { wait_ticks } => {
13217                self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
13218            }
13219        }
13220    }
13221
13222    fn re_cancel_edit(&mut self) {
13223        if let Some(ed) = self.state.worker_route_editor.as_mut() {
13224            ed.editing_index = None;
13225        }
13226    }
13227
13228    // ---- mouse clicks inside the overlay ------------------------------
13229
13230    pub fn worker_route_editor_ui_click(
13231        &mut self,
13232        click: crate::worker_route_editor::RouteEditorClick,
13233    ) {
13234        use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
13235        match click {
13236            RouteEditorClick::SelectStop(i) => {
13237                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13238                    ed.sheet = S::Stops;
13239                    ed.select_stop(i);
13240                }
13241            }
13242            RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
13243            RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
13244            RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
13245        }
13246    }
13247
13248    /// Activate (Enter / click) a row of the current sheet.
13249    pub fn re_sheet_row_activate(&mut self, row: usize) {
13250        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13251        let Some(sheet) = self
13252            .state
13253            .worker_route_editor
13254            .as_ref()
13255            .map(|ed| ed.sheet.clone())
13256        else {
13257            return;
13258        };
13259        match sheet {
13260            S::Stops => {
13261                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13262                    ed.select_stop(row);
13263                }
13264            }
13265            S::AddMenu { .. } => match row {
13266                0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
13267                1 => {
13268                    if self.re_node_candidates().is_empty() {
13269                        self.state.push_log(
13270                            "Route: no harvestable nodes visible in this region".to_string(),
13271                        );
13272                    } else {
13273                        self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
13274                    }
13275                }
13276                2 | 3 => {
13277                    if self.re_container_candidates().is_empty() {
13278                        self.state
13279                            .push_log("Route: place a storage chest first".to_string());
13280                    } else if row == 2 {
13281                        self.re_open_sheet(S::WithdrawContainers { index: 0 });
13282                    } else {
13283                        self.re_open_sheet(S::DepositContainers { index: 0 });
13284                    }
13285                }
13286                4 => {
13287                    if self.re_template_candidates().is_empty() {
13288                        self.state.push_log(
13289                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
13290                                .to_string(),
13291                        );
13292                    } else {
13293                        self.re_open_sheet(S::SellNpcs { index: 0 });
13294                    }
13295                }
13296                5 => {
13297                    if self.re_template_candidates().is_empty() {
13298                        self.state.push_log(
13299                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
13300                                .to_string(),
13301                        );
13302                    } else {
13303                        self.re_open_market_list_item();
13304                    }
13305                }
13306                6 => {
13307                    if self.re_blueprint_ids().is_empty() {
13308                        self.state.push_log(
13309                            "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
13310                                .to_string(),
13311                        );
13312                    } else {
13313                        self.re_open_sheet(S::CraftBlueprint { index: 0 });
13314                    }
13315                }
13316                7 => self.re_confirm_stop(
13317                    WorkerRouteStop::RestIfNeeded,
13318                    "rest at lodging (if needed)".into(),
13319                ),
13320                8 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
13321                9 => self.re_open_farm_plot_picker(
13322                    crate::worker_route_editor::FarmPlotAction::Cultivate,
13323                ),
13324                10 => {
13325                    self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant)
13326                }
13327                11 => self
13328                    .re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
13329                _ => {}
13330            },
13331            S::WaypointMenu { .. } => match row {
13332                0 => {
13333                    let (x, y, z) = self.state.player_position_with_z();
13334                    let stop = WorkerRouteStop::Waypoint { x, y, z };
13335                    self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13336                }
13337                1 => {
13338                    self.re_open_sheet(S::WaypointMapPick);
13339                    self.state.push_log(
13340                        "Route: click the map to place the waypoint (Esc to finish)".to_string(),
13341                    );
13342                }
13343                _ => {}
13344            },
13345            S::HarvestPicker { .. } => {
13346                let mut log: Option<String> = None;
13347                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13348                    let S::HarvestPicker {
13349                        index: sheet_index,
13350                        picked,
13351                        nodes,
13352                    } = &mut ed.sheet
13353                    else {
13354                        return;
13355                    };
13356                    *sheet_index = row;
13357                    if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
13358                        if picked.is_empty() {
13359                            log = Some(
13360                                "Route: pick at least one node (Space toggles, Done confirms)"
13361                                    .into(),
13362                            );
13363                        } else {
13364                            let ids: Vec<String> = picked.iter().cloned().collect();
13365                            let added = ed.confirm_harvest_picks(&ids);
13366                            log = Some(format!("Route: + {added} harvest stop(s)"));
13367                        }
13368                    } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
13369                        if picked.contains(&n.id) {
13370                            picked.remove(&n.id);
13371                        } else {
13372                            picked.insert(n.id.clone());
13373                        }
13374                    }
13375                }
13376                if let Some(msg) = log {
13377                    self.state.push_log(msg);
13378                }
13379            }
13380            S::WithdrawContainers { .. } => {
13381                let containers = self.re_container_candidates();
13382                if let Some(c) = containers.get(row) {
13383                    let id = c.id.clone();
13384                    self.re_open_withdraw_items(id);
13385                }
13386            }
13387            S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
13388            S::DepositContainers { .. } => {
13389                let containers = self.re_container_candidates();
13390                if let Some(c) = containers.get(row) {
13391                    let id = c.id.clone();
13392                    self.re_open_deposit_filter(id);
13393                }
13394            }
13395            S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
13396            S::SellNpcs { .. } => {
13397                let templates = self.re_template_candidates();
13398                let npcs = self.re_npc_candidates();
13399                if row == 0 {
13400                    if !crate::worker_route_editor::any_trade_npc_buys_route_item(
13401                        &self.state.npcs,
13402                        &templates,
13403                    ) {
13404                        self.state.push_log(
13405                            crate::worker_route_editor::sell_merchant_empty_reason(
13406                                None,
13407                                &self.state.npcs,
13408                                &templates,
13409                            ),
13410                        );
13411                        return;
13412                    }
13413                    self.re_open_sell_item(None);
13414                    return;
13415                }
13416                let Some(n) = npcs.get(row - 1) else {
13417                    return;
13418                };
13419                if !n.buys_route_item {
13420                    self.state.push_log(
13421                        crate::worker_route_editor::sell_merchant_empty_reason(
13422                            Some(n.id.as_str()),
13423                            &self.state.npcs,
13424                            &templates,
13425                        ),
13426                    );
13427                    return;
13428                }
13429                self.re_open_sell_item(Some(n.id.clone()));
13430            }
13431            S::SellItem { .. } => self.re_sell_item_activate(row),
13432            S::MarketListItem { .. } => self.re_market_list_item_activate(row),
13433            S::CraftBlueprint { .. } => {
13434                let bps = self.re_blueprint_ids();
13435                if let Some(bp) = bps.get(row) {
13436                    let stop = WorkerRouteStop::CraftAt {
13437                        device: "hand".into(),
13438                        blueprint: bp.clone(),
13439                        qty: None,
13440                    };
13441                    self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
13442                }
13443            }
13444            S::WaitEntry { ticks } => {
13445                let stop = WorkerRouteStop::Wait { wait_ticks: ticks };
13446                self.re_confirm_stop(stop, format!("wait {ticks}t"));
13447            }
13448            S::BedPicker { .. } => {
13449                let beds = self.re_bed_candidates();
13450                if let Some((id, name)) = beds.get(row) {
13451                    let (id, name) = (id.clone(), name.clone());
13452                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13453                        ed.lodging_container_id = Some(id.clone());
13454                        ed.sheet = S::Stops;
13455                    }
13456                    self.state
13457                        .push_log(format!("Route: rest bed set to {name}"));
13458                }
13459            }
13460            S::FarmPlotPicker { action, .. } => {
13461                let plots = self.re_farm_plot_candidates();
13462                let Some(plot) = plots.get(row).cloned() else {
13463                    return;
13464                };
13465                match action {
13466                    crate::worker_route_editor::FarmPlotAction::Cultivate => {
13467                        let label = plot_route_label(&plot);
13468                        self.re_confirm_stop(
13469                            WorkerRouteStop::CultivatePlot {
13470                                plot_id: plot.plot_id,
13471                            },
13472                            format!("cultivate {label}"),
13473                        );
13474                    }
13475                    crate::worker_route_editor::FarmPlotAction::Harvest => {
13476                        let label = plot_route_label(&plot);
13477                        self.re_confirm_stop(
13478                            WorkerRouteStop::HarvestPlot {
13479                                plot_id: plot.plot_id,
13480                            },
13481                            format!("harvest {label}"),
13482                        );
13483                    }
13484                    crate::worker_route_editor::FarmPlotAction::Plant => {
13485                        let seeds = self.re_farm_seed_candidates();
13486                        if seeds.is_empty() {
13487                            self.state.push_log(
13488                                "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
13489                            );
13490                            return;
13491                        }
13492                        self.re_open_sheet(S::FarmPlantSeed {
13493                            plot_id: plot.plot_id,
13494                            seeds,
13495                            index: 0,
13496                        });
13497                    }
13498                }
13499            }
13500            S::FarmPlantSeed { plot_id, seeds, .. } => {
13501                if let Some(seed) = seeds.get(row).cloned() {
13502                    self.re_confirm_stop(
13503                        WorkerRouteStop::PlantPlot {
13504                            plot_id,
13505                            seed_template: seed.clone(),
13506                        },
13507                        format!("plant {seed}"),
13508                    );
13509                }
13510            }
13511            S::WaypointMapPick => {}
13512        }
13513    }
13514
13515    fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
13516        use crate::worker_route_editor::RouteEditorSheet as S;
13517        if self.re_farm_plot_candidates().is_empty() {
13518            self.state
13519                .push_log("Route: no farmable plots visible — claim land or get farm access first");
13520            return;
13521        }
13522        self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
13523    }
13524
13525    fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
13526        self.state
13527            .property_plots
13528            .iter()
13529            .filter(|p| p.is_mine || p.may_farm)
13530            .cloned()
13531            .collect()
13532    }
13533
13534    /// Seed templates for a plant-plot route stop — does **not** require seeds on the
13535    /// player. Unions inventory, owned storage, withdraw lines already in the draft,
13536    /// and catalog `seed_for` templates so routes can withdraw-then-plant.
13537    fn re_farm_seed_candidates(&self) -> Vec<String> {
13538        let mut set = std::collections::BTreeSet::new();
13539        let looks_like_seed = |id: &str, catalog: &std::collections::HashMap<String, ItemCatalogEntryView>| {
13540            catalog.get(id).is_some_and(|e| e.is_farm_seed()) || id.ends_with("_seed")
13541        };
13542        for (id, _, _) in self.state.farm_seed_entries() {
13543            set.insert(id);
13544        }
13545        for c in &self.state.placed_containers {
13546            let mine = match (self.state.character_id, c.owner_character_id) {
13547                (Some(a), Some(b)) => a == b,
13548                _ => false,
13549            };
13550            if !mine {
13551                continue;
13552            }
13553            for s in &c.contents {
13554                if s.quantity > 0
13555                    && (s.props.contains_key("seed_for")
13556                        || looks_like_seed(&s.template_id, &self.state.item_catalog))
13557                {
13558                    set.insert(s.template_id.clone());
13559                }
13560            }
13561        }
13562        if let Some(ed) = self.state.worker_route_editor.as_ref() {
13563            for stop in &ed.stops {
13564                if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } =
13565                    stop
13566                {
13567                    for it in items {
13568                        if looks_like_seed(&it.template, &self.state.item_catalog) {
13569                            set.insert(it.template.clone());
13570                        }
13571                    }
13572                }
13573                if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
13574                    seed_template,
13575                    ..
13576                } = stop
13577                {
13578                    if !seed_template.is_empty() {
13579                        set.insert(seed_template.clone());
13580                    }
13581                }
13582            }
13583        }
13584        for (id, entry) in &self.state.item_catalog {
13585            if entry.is_farm_seed() {
13586                set.insert(id.clone());
13587            }
13588        }
13589        set.into_iter().collect()
13590    }
13591
13592    // ---- map clicks (sheet-scoped accelerators) ------------------------
13593
13594    /// Map click while the route editor is open. When a picker sheet is open
13595    /// the click feeds *that* sheet (chest clicks choose the withdraw/deposit
13596    /// container, NPC clicks the merchant, node clicks the harvest target);
13597    /// otherwise the click quick-adds the nearest target (context-aware).
13598    pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
13599        use crate::worker_route_editor as wre;
13600        use wre::RouteEditorSheet as S;
13601        if self.state.worker_route_editor.is_none() {
13602            return;
13603        }
13604        let sheet = self
13605            .state
13606            .worker_route_editor
13607            .as_ref()
13608            .map(|ed| ed.sheet.clone())
13609            .unwrap_or(S::Stops);
13610        match sheet {
13611            S::WaypointMapPick => {
13612                let (_, _, z) = self.state.player_position_with_z();
13613                let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
13614                self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13615                // Stay in map-pick mode when adding (not editing) for fast pathing.
13616                let editing = self
13617                    .state
13618                    .worker_route_editor
13619                    .as_ref()
13620                    .is_some_and(|ed| ed.editing_index.is_some());
13621                if !editing {
13622                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13623                        ed.sheet = S::WaypointMapPick;
13624                    }
13625                }
13626            }
13627            S::HarvestPicker { .. } => {
13628                if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
13629                    let mut log: Option<String> = None;
13630                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13631                        let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
13632                            return;
13633                        };
13634                        let selected = if picked.contains(&node.id) {
13635                            picked.remove(&node.id);
13636                            false
13637                        } else {
13638                            picked.insert(node.id.clone());
13639                            true
13640                        };
13641                        log = Some(format!(
13642                            "Route: {} {}",
13643                            if selected { "selected" } else { "deselected" },
13644                            resource_node_route_label(node)
13645                        ));
13646                    }
13647                    if let Some(msg) = log {
13648                        self.state.push_log(msg);
13649                    }
13650                }
13651            }
13652            S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
13653                // Chest click picks (or switches) the withdraw source.
13654                let inside = self.state.effective_inside_building();
13655                if let Some(cid) = wre::pick_storage_container_at(
13656                    &self.state.placed_containers,
13657                    self.state.character_id,
13658                    x,
13659                    y,
13660                    inside.as_deref(),
13661                ) {
13662                    self.re_open_withdraw_items(cid);
13663                }
13664            }
13665            S::DepositContainers { .. } | S::DepositFilter { .. } => {
13666                let inside = self.state.effective_inside_building();
13667                if let Some(cid) = wre::pick_storage_container_at(
13668                    &self.state.placed_containers,
13669                    self.state.character_id,
13670                    x,
13671                    y,
13672                    inside.as_deref(),
13673                ) {
13674                    self.re_open_deposit_filter(cid);
13675                }
13676            }
13677            S::SellNpcs { .. } => {
13678                if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13679                    self.re_open_sell_item(Some(npc_id));
13680                }
13681            }
13682            S::SellItem { .. } => {
13683                if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13684                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13685                        if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
13686                            *slot = Some(npc_id.clone());
13687                        }
13688                    }
13689                    self.state
13690                        .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
13691                }
13692            }
13693            // Stop list / other sheets: context-aware quick add.
13694            _ => self.worker_route_editor_quick_add_click(x, y),
13695        }
13696    }
13697
13698    /// Quick-add map click with no picker open: nearest of bed/chest/merchant/
13699    /// node wins; duplicates select the existing stop; a click on a merchant
13700    /// while a sell stop is selected pins it.
13701    fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
13702        use crate::worker_route_editor as wre;
13703        let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
13704            let dx = ax - bx;
13705            let dy = ay - by;
13706            (dx * dx + dy * dy).sqrt()
13707        };
13708
13709        // 1. Retarget the selected stop when the click names its target:
13710        //    sell stop → pin the merchant; withdraw stop → set the source chest.
13711        let selected_stop_kind = self
13712            .state
13713            .worker_route_editor
13714            .as_ref()
13715            .and_then(|ed| ed.stops.get(ed.selected_stop_index))
13716            .map(|s| match s {
13717                wre::WorkerRouteStop::TradeWith { .. } => 1,
13718                wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
13719                _ => 0,
13720            })
13721            .unwrap_or(0);
13722        if selected_stop_kind == 1 {
13723            if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13724                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13725                    ed.set_selected_trade_npc(npc_id.clone());
13726                }
13727                self.state
13728                    .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
13729                return;
13730            }
13731        }
13732        if selected_stop_kind == 2 {
13733            let inside = self.state.effective_inside_building();
13734            if let Some(cid) = wre::pick_storage_container_at(
13735                &self.state.placed_containers,
13736                self.state.character_id,
13737                x,
13738                y,
13739                inside.as_deref(),
13740            ) {
13741                let name = self
13742                    .state
13743                    .placed_containers
13744                    .iter()
13745                    .find(|c| c.id == cid)
13746                    .map(|c| c.display_name.clone())
13747                    .unwrap_or_else(|| "container".into());
13748                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13749                    ed.set_selected_withdraw_container(cid.clone());
13750                }
13751                self.state
13752                    .push_log(format!("Route: withdraw source → {name}"));
13753                return;
13754            }
13755        }
13756
13757        // 2. Nearest candidate of any kind wins. Category order is the
13758        //    tie-break for exact overlaps (bed over chest over merchant over node).
13759        enum Target {
13760            Bed(String),
13761            Container(String),
13762            Npc(String, String),
13763            Node(String, String),
13764        }
13765        let mut best: Option<(f32, u8, Target)> = None;
13766        let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
13767            let better = match best {
13768                None => true,
13769                Some((bd, brank, _)) => {
13770                    d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank)
13771                }
13772            };
13773            if better {
13774                *best = Some((d, rank, t));
13775            }
13776        };
13777        let inside = self.state.effective_inside_building();
13778        if let Some(bed_id) = wre::pick_lodging_container_at(
13779            &self.state.placed_containers,
13780            self.state.character_id,
13781            x,
13782            y,
13783            inside.as_deref(),
13784        ) {
13785            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
13786                // A bed that's already the rest bed doubles as a storage chest —
13787                // a second click on it means "deposit here", not "set bed again".
13788                let already_bed =
13789                    self.state.worker_route_editor.as_ref().is_some_and(|ed| {
13790                        ed.lodging_container_id.as_deref() == Some(bed_id.as_str())
13791                    });
13792                if already_bed {
13793                    consider(
13794                        dist(x, y, c.x, c.y),
13795                        1,
13796                        Target::Container(bed_id),
13797                        &mut best,
13798                    );
13799                } else {
13800                    consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
13801                }
13802            }
13803        }
13804        if let Some(cid) = wre::pick_storage_container_at(
13805            &self.state.placed_containers,
13806            self.state.character_id,
13807            x,
13808            y,
13809            inside.as_deref(),
13810        ) {
13811            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
13812                consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
13813            }
13814        }
13815        if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13816            if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
13817                consider(
13818                    dist(x, y, n.x, n.y),
13819                    2,
13820                    Target::Npc(npc_id, label),
13821                    &mut best,
13822                );
13823            }
13824        }
13825        if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
13826            let d = dist(x, y, node.x, node.y);
13827            let label = resource_node_route_label(node);
13828            consider(d, 3, Target::Node(node.id.clone(), label), &mut best);
13829        }
13830
13831        match best.map(|(_, _, t)| t) {
13832            Some(Target::Bed(bed_id)) => {
13833                let name = self
13834                    .state
13835                    .placed_containers
13836                    .iter()
13837                    .find(|c| c.id == bed_id)
13838                    .map(|c| c.display_name.clone())
13839                    .unwrap_or_else(|| "camp bed".into());
13840                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13841                    ed.lodging_container_id = Some(bed_id.clone());
13842                }
13843                self.state
13844                    .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
13845            }
13846            Some(Target::Container(cid)) => {
13847                let name = self
13848                    .state
13849                    .placed_containers
13850                    .iter()
13851                    .find(|c| c.id == cid)
13852                    .map(|c| c.display_name.clone())
13853                    .unwrap_or_else(|| "container".into());
13854                let added = self
13855                    .state
13856                    .worker_route_editor
13857                    .as_mut()
13858                    .is_some_and(|ed| ed.append_deposit_at(&cid));
13859                if added {
13860                    self.state
13861                        .push_log(format!("Route: + deposit at {name} ({cid})"));
13862                } else {
13863                    self.state.push_log(format!(
13864                        "Route: {name} already in route — selected it (d to remove)"
13865                    ));
13866                }
13867            }
13868            Some(Target::Npc(npc_id, label)) => {
13869                // Quick-add sell: first storage template (the full sell flow
13870                // lives in the a → Sell sheet).
13871                let template = self.re_template_candidates().into_iter().next();
13872                let Some(template) = template else {
13873                    self.state.push_log(
13874                        "Route: no items in your storage to sell — stock a chest first".to_string(),
13875                    );
13876                    return;
13877                };
13878                let added = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
13879                    ed.append_trade_with(template.clone(), Some(npc_id.clone()), true)
13880                });
13881                if added {
13882                    self.state
13883                        .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
13884                } else {
13885                    self.state.push_log(format!(
13886                        "Route: {label} already sells {template} — selected it (d to remove)"
13887                    ));
13888                }
13889            }
13890            Some(Target::Node(id, label)) => {
13891                let added = self
13892                    .state
13893                    .worker_route_editor
13894                    .as_mut()
13895                    .is_some_and(|ed| ed.append_harvest_node(&id));
13896                if added {
13897                    self.state
13898                        .push_log(format!("Route: + harvest node {label}"));
13899                } else {
13900                    self.state.push_log(format!(
13901                        "Route: {label} already in route — selected it (d to remove)"
13902                    ));
13903                }
13904            }
13905            None => {}
13906        }
13907    }
13908
13909    pub fn worker_route_editor_select(&mut self, delta: i32) {
13910        let Some(ed) = self.state.worker_route_editor.as_mut() else {
13911            return;
13912        };
13913        if ed.stops.is_empty() {
13914            return;
13915        }
13916        let n = ed.stops.len() as i32;
13917        let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
13918        ed.selected_stop_index = next;
13919    }
13920
13921    pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
13922        let Some(ed) = self.state.worker_route_editor.as_mut() else {
13923            return;
13924        };
13925        if delta < 0 {
13926            ed.move_selected_up();
13927        } else if delta > 0 {
13928            ed.move_selected_down();
13929        }
13930    }
13931
13932    pub fn worker_route_editor_delete_selected(&mut self) {
13933        let removed = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
13934            let before = ed.stop_count();
13935            ed.remove_selected_stop();
13936            ed.stop_count() < before
13937        });
13938        if removed {
13939            self.state.push_log("Route: removed selected stop");
13940        }
13941    }
13942
13943    /// Clear all stops. Saving afterwards parks the worker in idle mode
13944    /// instead of leaving it in a broken job loop.
13945    pub fn worker_route_editor_clear_stops(&mut self) {
13946        let Some(ed) = self.state.worker_route_editor.as_mut() else {
13947            return;
13948        };
13949        if ed.stops.is_empty() {
13950            self.state
13951                .push_log("Route: already empty — s saves an idle worker".to_string());
13952            return;
13953        }
13954        ed.stops.clear();
13955        ed.selected_stop_index = 0;
13956        self.state.push_log(
13957            "Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string(),
13958        );
13959    }
13960
13961    pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
13962        if self.state.pending_worker_job_ack.is_some() {
13963            anyhow::bail!("route save still pending — wait for server ack");
13964        }
13965        let Some(ed) = self.state.worker_route_editor.clone() else {
13966            anyhow::bail!("route editor not open");
13967        };
13968        // Empty route = stand down: park the worker in idle mode rather than
13969        // erroring out or leaving it marching a ghost loop.
13970        let (job_yaml, idle) = if ed.stops.is_empty() {
13971            (ed.build_idle_job_yaml(), true)
13972        } else {
13973            (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
13974        };
13975        let worker_id = ed.worker_instance_id.clone();
13976        let route_view = if idle { None } else { Some(ed.to_route_view()) };
13977        let mode = if idle {
13978            flatland_protocol::WorkerModeView::Idle
13979        } else {
13980            flatland_protocol::WorkerModeView::JobLoop
13981        };
13982        let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
13983            .state
13984            .hired_workers
13985            .iter()
13986            .find(|w| w.instance_id == worker_id)
13987            .map(|w| {
13988                (
13989                    w.route.clone(),
13990                    w.mode,
13991                    w.step_label.clone(),
13992                    w.last_error.clone(),
13993                )
13994            })
13995            .unwrap_or((
13996                None,
13997                flatland_protocol::WorkerModeView::Idle,
13998                String::new(),
13999                None,
14000            ));
14001        self.seq += 1;
14002        let seq = self.seq;
14003        self.session
14004            .submit_intent(Intent::SetWorkerJob {
14005                entity_id: self.state.entity_id,
14006                worker_instance_id: worker_id.clone(),
14007                job_yaml,
14008                seq,
14009            })
14010            .await?;
14011        self.state.intents_sent += 1;
14012        if let Some(w) = self
14013            .state
14014            .hired_workers
14015            .iter_mut()
14016            .find(|w| w.instance_id == worker_id)
14017        {
14018            w.route = route_view;
14019            w.mode = mode;
14020            w.last_error = None;
14021            if idle {
14022                w.step_label.clear();
14023                w.route_stop_index = None;
14024            }
14025        }
14026        self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
14027            seq,
14028            worker_instance_id: worker_id,
14029            worker_label: ed.worker_label.clone(),
14030            idle,
14031            stop_count: ed.stops.len(),
14032            prev_route,
14033            prev_mode,
14034            prev_step_label,
14035            prev_last_error,
14036        });
14037        self.state.push_log(format!(
14038            "Route: saving for {}… (waiting for server)",
14039            ed.worker_label
14040        ));
14041        // Keep editor open until IntentAck; reject Interaction reverts optimistic state.
14042        Ok(())
14043    }
14044    pub fn quest_menu_move(&mut self, delta: i32) {
14045        let n = self.state.active_quest_entries().len();
14046        if n == 0 {
14047            return;
14048        }
14049        let idx = self.state.quest_menu_index as i32;
14050        self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
14051    }
14052
14053    pub fn quest_menu_page(&mut self, pages: i32) {
14054        let n = self.state.active_quest_entries().len();
14055        self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
14056    }
14057
14058    pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
14059        let Some(offer) = self.state.selected_quest_offer().cloned() else {
14060            anyhow::bail!("no quest offer");
14061        };
14062        self.seq += 1;
14063        let seq = self.seq;
14064        self.session
14065            .submit_intent(Intent::AcceptQuest {
14066                entity_id: self.state.entity_id,
14067                quest_id: offer.quest_id,
14068                seq,
14069            })
14070            .await?;
14071        self.state.intents_sent += 1;
14072        Ok(())
14073    }
14074
14075    pub fn quest_offer_move(&mut self, delta: i32) {
14076        self.state.move_quest_offer_selection(delta);
14077    }
14078
14079    pub fn quest_offer_decline(&mut self) {
14080        self.state.clear_quest_offers();
14081        if !self.state.show_npc_chat
14082            && !self.state.show_shop_menu
14083            && self.state.npc_verb_target.is_some()
14084        {
14085            self.state.show_npc_verb_menu = true;
14086        }
14087    }
14088
14089    pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
14090        if !self.state.show_quest_menu {
14091            return Ok(());
14092        }
14093        let active: Vec<_> = self
14094            .state
14095            .active_quest_entries()
14096            .into_iter()
14097            .cloned()
14098            .collect();
14099        let Some(entry) = active.get(self.state.quest_menu_index) else {
14100            return Ok(());
14101        };
14102        if self.state.quest_withdraw_confirm {
14103            if !entry.can_withdraw {
14104                anyhow::bail!("quest cannot be withdrawn");
14105            }
14106            self.seq += 1;
14107            let seq = self.seq;
14108            self.session
14109                .submit_intent(Intent::WithdrawQuest {
14110                    entity_id: self.state.entity_id,
14111                    quest_id: entry.quest_id.clone(),
14112                    seq,
14113                })
14114                .await?;
14115            self.state.intents_sent += 1;
14116            self.state.quest_withdraw_confirm = false;
14117            return Ok(());
14118        }
14119        self.seq += 1;
14120        let seq = self.seq;
14121        self.session
14122            .submit_intent(Intent::TrackQuest {
14123                entity_id: self.state.entity_id,
14124                quest_id: entry.quest_id.clone(),
14125                seq,
14126            })
14127            .await?;
14128        self.state.intents_sent += 1;
14129        Ok(())
14130    }
14131
14132    pub fn quest_request_withdraw(&mut self) {
14133        if self.state.show_quest_menu {
14134            self.state.quest_withdraw_confirm = true;
14135        }
14136    }
14137
14138    pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
14139        if !self.state.is_alive() {
14140            anyhow::bail!("you are dead");
14141        }
14142        let Some(catalog) = self.state.shop_catalog.clone() else {
14143            anyhow::bail!("no shop open");
14144        };
14145        self.seq += 1;
14146        let seq = self.seq;
14147        match self.state.shop_tab {
14148            ShopTab::Buy => {
14149                let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
14150                    anyhow::bail!("nothing selected");
14151                };
14152                if offer.already_owned {
14153                    anyhow::bail!("already owned");
14154                }
14155                self.session
14156                    .submit_intent(Intent::ShopBuy {
14157                        entity_id: self.state.entity_id,
14158                        npc_id: catalog.npc_id.clone(),
14159                        offer_id: offer.offer_id.clone(),
14160                        quantity: self.state.shop_quantity,
14161                        seq,
14162                    })
14163                    .await?;
14164            }
14165            ShopTab::Sell => {
14166                let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
14167                    anyhow::bail!("nothing to sell");
14168                };
14169                if line.quantity == 0 {
14170                    anyhow::bail!("you have no {}", line.label);
14171                }
14172                let quantity = self.state.shop_quantity.min(line.quantity).max(1);
14173                self.session
14174                    .submit_intent(Intent::ShopSell {
14175                        entity_id: self.state.entity_id,
14176                        npc_id: catalog.npc_id.clone(),
14177                        template_id: line.template_id.clone(),
14178                        quantity,
14179                        seq,
14180                    })
14181                    .await?;
14182            }
14183        }
14184        self.state.intents_sent += 1;
14185        Ok(())
14186    }
14187
14188    pub fn craft_menu_move(&mut self, delta: i32) {
14189        let n = self.state.craft_filtered_indices().len();
14190        if n == 0 {
14191            return;
14192        }
14193        let idx = self.state.craft_menu_index as i32;
14194        let next = (idx + delta).rem_euclid(n as i32);
14195        self.state.craft_menu_index = next as usize;
14196        self.state.clamp_craft_batch_quantity();
14197    }
14198
14199    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
14200        self.state.craft_batch_adjust_quantity(delta);
14201    }
14202
14203    pub fn craft_batch_set_max(&mut self) {
14204        self.state.craft_batch_set_max();
14205    }
14206
14207    pub fn craft_batch_set_min(&mut self) {
14208        self.state.craft_batch_set_min();
14209    }
14210
14211    pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
14212        let Some(blueprint) = self.state.craft_selected_blueprint().cloned() else {
14213            anyhow::bail!("no blueprints in this tab");
14214        };
14215        if !self.state.can_craft_blueprint(&blueprint) {
14216            let hint = self
14217                .state
14218                .craft_missing_hint(&blueprint)
14219                .unwrap_or_else(|| "missing materials".into());
14220            anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
14221        }
14222        let count = self.state.craft_batch_quantity;
14223        let max = self.state.max_craft_batches(&blueprint);
14224        if max == 0 {
14225            anyhow::bail!("cannot craft {}", blueprint.label);
14226        }
14227        let batches = count.min(max);
14228        self.craft(&blueprint.id, Some(batches)).await?;
14229        // Keep the craft menu open so progress / blockers stay visible.
14230        Ok(())
14231    }
14232
14233    pub async fn move_by(
14234        &mut self,
14235        forward: f32,
14236        strafe: f32,
14237        vertical: f32,
14238        sprint: bool,
14239        sneak: bool,
14240    ) -> anyhow::Result<()> {
14241        if !self.state.is_alive() {
14242            anyhow::bail!("you are dead");
14243        }
14244        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
14245            self.last_move_forward = forward;
14246            self.last_move_strafe = strafe;
14247        }
14248        self.seq += 1;
14249        self.session
14250            .submit_intent(Intent::Move {
14251                entity_id: self.state.entity_id,
14252                forward,
14253                strafe,
14254                vertical,
14255                sprint: sprint && !sneak,
14256                sneak,
14257                seq: self.seq,
14258            })
14259            .await?;
14260        self.state.intents_sent += 1;
14261        Ok(())
14262    }
14263
14264    pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
14265        if !self.state.connected {
14266            crate::harvest_trace!("harvest_nearest rejected: not connected");
14267            anyhow::bail!("not connected");
14268        }
14269        if !self.state.is_alive() {
14270            crate::harvest_trace!("harvest_nearest rejected: player dead");
14271            anyhow::bail!("you are dead");
14272        }
14273        if self.state.harvest_in_progress {
14274            if self.state.harvest_state_stale() {
14275                self.state.clear_harvest_state();
14276            } else {
14277                anyhow::bail!("already harvesting");
14278            }
14279        }
14280        let (px, py) = self
14281            .state
14282            .player
14283            .as_ref()
14284            .map(|p| (p.transform.position.x, p.transform.position.y))
14285            .unwrap_or((0.0, 0.0));
14286
14287        let available = self
14288            .state
14289            .resource_nodes
14290            .iter()
14291            .filter(|n| !n.harvest_off)
14292            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14293            .count();
14294        let node_id = self
14295            .state
14296            .resource_nodes
14297            .iter()
14298            .filter(|n| !n.harvest_off)
14299            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14300            .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
14301            .min_by(|a, b| {
14302                let da = distance(px, py, a.x, a.y);
14303                let db = distance(px, py, b.x, b.y);
14304                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
14305            })
14306            .map(|n| n.id.clone());
14307
14308        let Some(node_id) = node_id else {
14309            let has_loot = self
14310                .state
14311                .ground_drops
14312                .iter()
14313                .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14314            if has_loot {
14315                return self.pickup_nearest().await;
14316            }
14317            anyhow::bail!(
14318                "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
14319            );
14320        };
14321
14322        self.seq += 1;
14323        let seq = self.seq;
14324        crate::harvest_trace!(
14325            entity_id = self.state.entity_id,
14326            node_id = %node_id,
14327            seq,
14328            px,
14329            py,
14330            available_nodes = available,
14331            "submitting harvest intent"
14332        );
14333        self.session
14334            .submit_intent(Intent::Harvest {
14335                entity_id: self.state.entity_id,
14336                node_id,
14337                seq,
14338            })
14339            .await?;
14340        self.state.intents_sent += 1;
14341        self.state.harvest_in_progress = true;
14342        self.state.harvest_started_at = Some(Instant::now());
14343        self.state.push_log("Harvesting…");
14344        crate::harvest_trace!(
14345            entity_id = self.state.entity_id,
14346            seq,
14347            "harvest intent queued to session"
14348        );
14349        Ok(())
14350    }
14351
14352    pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
14353        if !self.state.is_alive() {
14354            anyhow::bail!("you are dead");
14355        }
14356        let blueprint_id = self
14357            .state
14358            .blueprints
14359            .iter()
14360            .find(|bp| self.state.can_craft_blueprint(bp))
14361            .map(|bp| bp.id.clone())
14362            .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
14363        self.craft(&blueprint_id, None).await
14364    }
14365
14366    pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
14367        if !self.state.is_alive() {
14368            anyhow::bail!("you are dead");
14369        }
14370        self.seq += 1;
14371        self.session
14372            .submit_intent(Intent::Craft {
14373                entity_id: self.state.entity_id,
14374                blueprint_id: blueprint_id.to_string(),
14375                count,
14376                seq: self.seq,
14377            })
14378            .await?;
14379        self.state.intents_sent += 1;
14380        let (label, batches) = self
14381            .state
14382            .blueprints
14383            .iter()
14384            .find(|b| b.id == blueprint_id)
14385            .map(|b| {
14386                let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
14387                (b.label.as_str(), n)
14388            })
14389            .unwrap_or((blueprint_id, count.unwrap_or(1)));
14390        self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
14391        self.state.craft_channel_blueprint_id = Some(blueprint_id.to_string());
14392        Ok(())
14393    }
14394
14395    pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
14396        if !self.state.is_alive() {
14397            anyhow::bail!("you are dead");
14398        }
14399        let target_id = match self.state.nearest_interact_target() {
14400            Some(id) => id,
14401            None => {
14402                anyhow::bail!("nothing to interact with nearby");
14403            }
14404        };
14405        if self.state.npcs.iter().any(|n| n.id == target_id) {
14406            self.state.show_npc_verb_menu = true;
14407            self.state.npc_verb_target = Some(target_id);
14408            self.state.npc_verb_index = 0;
14409            self.state.npc_verb_notice = None;
14410            return Ok(());
14411        }
14412        if self
14413            .state
14414            .hired_workers
14415            .iter()
14416            .any(|w| w.instance_id == target_id)
14417        {
14418            return self.open_workers_menu_for(&target_id).await;
14419        }
14420        if let Ok(peer_id) = target_id.parse::<EntityId>() {
14421            if self
14422                .state
14423                .hired_workers
14424                .iter()
14425                .any(|w| w.entity_id == peer_id)
14426            {
14427                if let Some(w) = self
14428                    .state
14429                    .hired_workers
14430                    .iter()
14431                    .find(|w| w.entity_id == peer_id)
14432                {
14433                    let id = w.instance_id.clone();
14434                    return self.open_workers_menu_for(&id).await;
14435                }
14436            }
14437            if let Some(entity) = self
14438                .state
14439                .entities
14440                .iter()
14441                .find(|e| e.id == peer_id && e.id != self.state.entity_id)
14442            {
14443                self.state.player_verbs.open_for(peer_id, &entity.label);
14444                return Ok(());
14445            }
14446        }
14447        self.seq += 1;
14448        self.session
14449            .submit_intent(Intent::Interact {
14450                entity_id: self.state.entity_id,
14451                target_id: target_id.clone(),
14452                seq: self.seq,
14453            })
14454            .await?;
14455        self.state.intents_sent += 1;
14456        Ok(())
14457    }
14458
14459    /// Context-sensitive world use: loot/chest → plot farm → interact → claim → harvest.
14460    pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
14461        if !self.state.is_alive() {
14462            anyhow::bail!("you are dead");
14463        }
14464        let (px, py) = self.state.player_position();
14465        let has_loot = self
14466            .state
14467            .ground_drops
14468            .iter()
14469            .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14470        if has_loot {
14471            return self.pickup_nearest().await;
14472        }
14473        if self
14474            .state
14475            .placed_containers
14476            .iter()
14477            .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
14478        {
14479            return self.pickup_nearest_container().await;
14480        }
14481
14482        // Resource nodes beat hired workers / NPCs that stand in interact range.
14483        if self.state.harvestable_node_in_range() {
14484            return self.harvest_nearest().await;
14485        }
14486
14487        if let Some(plot) = self.state.my_plot_under_player().cloned() {
14488            // Double-tap f within 1.2s sells; otherwise farm the plot.
14489            const SELL_WINDOW: Duration = Duration::from_millis(1200);
14490            let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
14491                && self
14492                    .state
14493                    .sell_plot_armed_at
14494                    .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
14495            if sell_armed {
14496                return self.confirm_sell_plot_to_crown(plot.plot_id).await;
14497            }
14498            self.state.sell_plot_confirm = None;
14499            self.state.sell_plot_armed_at = None;
14500
14501            // Prefer farm actions on your own land unless an NPC/player/door/board
14502            // is in interact range (those still win).
14503            let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
14504                self.state.npcs.iter().any(|n| n.id == id)
14505                    || self.state.hired_workers.iter().any(|w| w.instance_id == id)
14506                    || self.state.doors.iter().any(|d| d.id == id)
14507                    || self.state.interactables.iter().any(|i| {
14508                        i.id == id
14509                            && matches!(i.kind.as_str(), "quest_board" | "well" | "exit" | "enter")
14510                    })
14511                    || id.parse::<EntityId>().is_ok_and(|eid| {
14512                        self.state
14513                            .entities
14514                            .iter()
14515                            .any(|e| e.id == eid && e.id != self.state.entity_id)
14516                    })
14517            });
14518            if !blocking_interact {
14519                // On your plot, `f` is harvest only — use `c` / `p` for till and plant.
14520                match self.harvest_nearest().await {
14521                    Ok(()) => return Ok(()),
14522                    Err(err) => {
14523                        let msg = err.to_string();
14524                        if !(msg.contains("no harvestable")
14525                            || msg.contains("press p")
14526                            || msg.contains("press f")
14527                            || msg.contains("nothing"))
14528                        {
14529                            return Err(err);
14530                        }
14531                    }
14532                }
14533                return Ok(());
14534            }
14535        }
14536        if self.state.nearest_interact_target().is_some() {
14537            return self.interact_nearest().await;
14538        }
14539        // Prefer a clear "move closer" when a board is visible but out of reach,
14540        // instead of silently falling through to harvest.
14541        if let Some((label, dist)) = self.state.nearest_quest_board() {
14542            if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
14543                anyhow::bail!(
14544                    "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
14545                );
14546            }
14547        }
14548
14549        match self.harvest_nearest().await {
14550            Ok(()) => Ok(()),
14551            Err(err) => {
14552                let msg = err.to_string();
14553                if msg.contains("no harvestable")
14554                    || msg.contains("press p")
14555                    || msg.contains("press f")
14556                {
14557                    anyhow::bail!(
14558                        "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
14559                    );
14560                }
14561                Err(err)
14562            }
14563        }
14564    }
14565
14566    /// Enter claim-mode footprint editor on unclaimed crown land (`k`).
14567    pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
14568        if !self.state.is_alive() {
14569            anyhow::bail!("you are dead");
14570        }
14571        if self.state.claim_mode.is_some() {
14572            anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
14573        }
14574        let zone = self
14575            .state
14576            .free_property_zone_under_player()
14577            .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
14578        let zone_id = zone.id.clone();
14579        let label = zone
14580            .label
14581            .as_deref()
14582            .filter(|s| !s.trim().is_empty())
14583            .unwrap_or(zone.id.as_str())
14584            .to_string();
14585        self.enter_claim_mode(&zone_id);
14586        self.state.push_log(format!(
14587            "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
14588        ));
14589        Ok(())
14590    }
14591
14592    /// Enter claim mode over a property zone (default 4×4 or min size).
14593    pub fn enter_claim_mode(&mut self, zone_id: &str) {
14594        let Some(zone) = self
14595            .state
14596            .property_zones
14597            .iter()
14598            .find(|z| z.id == zone_id)
14599            .cloned()
14600        else {
14601            self.state.push_log("unknown property zone");
14602            return;
14603        };
14604        self.state.sell_plot_confirm = None;
14605        self.state.sell_plot_armed_at = None;
14606        let min_area = self
14607            .state
14608            .property_plot_settings
14609            .as_ref()
14610            .map(|s| s.min_plot_area_m2)
14611            .unwrap_or(4.0)
14612            .max(1.0);
14613        let min_side = min_area.sqrt().ceil().max(1.0) as u32;
14614        let side = 4u32.max(min_side);
14615        let (px, py) = self.state.player_position();
14616        let anchor_x = px.floor();
14617        let anchor_y = py.floor();
14618        self.state.claim_mode = Some(ClaimModeState {
14619            zone_id: zone.id.clone(),
14620            width_m: side,
14621            height_m: side,
14622            anchor_x,
14623            anchor_y,
14624        });
14625        let label = zone
14626            .label
14627            .as_deref()
14628            .filter(|s| !s.trim().is_empty())
14629            .unwrap_or(zone.id.as_str());
14630        self.state.push_log(format!(
14631            "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
14632        ));
14633    }
14634
14635    pub fn cancel_claim_mode(&mut self) {
14636        if self.state.claim_mode.take().is_some() {
14637            self.state.push_log("Claim cancelled");
14638        }
14639    }
14640
14641    /// Enter relocate mode for a placed container (1×1 ghost).
14642    pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
14643        if !self.state.is_alive() {
14644            anyhow::bail!("you are dead");
14645        }
14646        if self.state.relocate_mode.is_some() {
14647            anyhow::bail!("already relocating — Enter confirm, Esc cancel");
14648        }
14649        if self.state.claim_mode.is_some() {
14650            anyhow::bail!("finish or cancel claim mode first");
14651        }
14652        let chest = self
14653            .state
14654            .placed_containers
14655            .iter()
14656            .find(|c| c.id == container_id)
14657            .cloned()
14658            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
14659        let (px, py) = self.state.player_position();
14660        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
14661            anyhow::bail!("too far from {}", chest.display_name);
14662        }
14663        if chest.locked && !chest.accessible {
14664            anyhow::bail!(
14665                "need the matching key for {} before moving it",
14666                chest.display_name
14667            );
14668        }
14669        let label = if chest.display_name.trim().is_empty() {
14670            chest.template_id.clone()
14671        } else {
14672            chest.display_name.clone()
14673        };
14674        self.state.relocate_mode = Some(RelocateModeState {
14675            container_id: chest.id.clone(),
14676            label: label.clone(),
14677            cursor_x: chest.x.floor() + 0.5,
14678            cursor_y: chest.y.floor() + 0.5,
14679        });
14680        self.state.push_log(format!(
14681            "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
14682        ));
14683        Ok(())
14684    }
14685
14686    /// World `Shift+m`: relocate nearest owned/accessible placed container.
14687    pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
14688        let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
14689            anyhow::bail!("no chest nearby to relocate");
14690        };
14691        if chest.locked && !chest.accessible {
14692            anyhow::bail!(
14693                "need the matching key for {} before moving it",
14694                chest.display_name
14695            );
14696        }
14697        // Prefer own chests: if owner is set and we're not the owner, skip unless accessible unlocked?
14698        // Server enforces ownership; client starts on nearest accessible chest in range.
14699        self.begin_relocate_container(&chest.id)
14700    }
14701
14702    pub fn cancel_relocate_mode(&mut self) {
14703        if self.state.relocate_mode.take().is_some() {
14704            self.state.push_log("Relocate cancelled");
14705        }
14706    }
14707
14708    pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
14709        let Some(mode) = self.state.relocate_mode.as_mut() else {
14710            return;
14711        };
14712        let max_x = self.state.world_width_m.max(1.0);
14713        let max_y = self.state.world_height_m.max(1.0);
14714        let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
14715        let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
14716        mode.cursor_x = nx.floor() + 0.5;
14717        mode.cursor_y = ny.floor() + 0.5;
14718    }
14719
14720    pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
14721        let Some(mode) = self.state.relocate_mode.as_mut() else {
14722            return;
14723        };
14724        let max_x = self.state.world_width_m.max(1.0);
14725        let max_y = self.state.world_height_m.max(1.0);
14726        mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
14727        mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
14728    }
14729
14730    pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
14731        if !self.state.is_alive() {
14732            anyhow::bail!("you are dead");
14733        }
14734        let Some(mode) = self.state.relocate_mode.clone() else {
14735            anyhow::bail!("not relocating");
14736        };
14737        let (px, py) = self.state.player_position();
14738        let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
14739        if dist > 8.0 {
14740            anyhow::bail!("destination too far (max 8 m)");
14741        }
14742        self.seq += 1;
14743        self.session
14744            .submit_intent(Intent::MovePlacedContainer {
14745                entity_id: self.state.entity_id,
14746                container_id: mode.container_id.clone(),
14747                x: mode.cursor_x,
14748                y: mode.cursor_y,
14749                seq: self.seq,
14750            })
14751            .await?;
14752        self.state.intents_sent += 1;
14753        self.state.relocate_mode = None;
14754        self.state.push_log(format!("Moving {}…", mode.label));
14755        Ok(())
14756    }
14757
14758    pub fn claim_set_preset(&mut self, w: u32, h: u32) {
14759        let Some(mode) = self.state.claim_mode.as_mut() else {
14760            return;
14761        };
14762        mode.width_m = w.max(1);
14763        mode.height_m = h.max(1);
14764    }
14765
14766    pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
14767        let Some(mode) = self.state.claim_mode.as_mut() else {
14768            return;
14769        };
14770        let w = (mode.width_m as i32 + dw).max(1) as u32;
14771        let h = (mode.height_m as i32 + dh).max(1) as u32;
14772        mode.width_m = w;
14773        mode.height_m = h;
14774    }
14775
14776    /// Nudge the claim footprint SW corner one meter cell (WASD / arrows).
14777    pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
14778        let Some(mode) = self.state.claim_mode.as_mut() else {
14779            return;
14780        };
14781        let max_x = self.state.world_width_m.max(1.0);
14782        let max_y = self.state.world_height_m.max(1.0);
14783        let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
14784        let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
14785        mode.anchor_x = nx.floor();
14786        mode.anchor_y = ny.floor();
14787    }
14788
14789    pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
14790        if !self.state.is_alive() {
14791            anyhow::bail!("you are dead");
14792        }
14793        let Some(mode) = self.state.claim_mode.clone() else {
14794            anyhow::bail!("not in claim mode");
14795        };
14796        let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
14797            self.state.claim_quote()
14798        else {
14799            anyhow::bail!("cannot quote claim");
14800        };
14801        if !valid {
14802            anyhow::bail!(reason);
14803        }
14804        if !can_afford {
14805            anyhow::bail!(
14806                "not enough copper (need {})",
14807                crate::currency::format_copper(purchase)
14808            );
14809        }
14810        let (x0, y0, x1, y1) = self
14811            .state
14812            .claim_footprint_rect()
14813            .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
14814        let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
14815        self.seq += 1;
14816        self.session
14817            .submit_intent(Intent::BuyPlot {
14818                entity_id: self.state.entity_id,
14819                zone_id: mode.zone_id,
14820                x0,
14821                y0,
14822                x1,
14823                y1,
14824                seq: self.seq,
14825            })
14826            .await?;
14827        self.state.intents_sent += 1;
14828        self.state.claim_mode = None;
14829        self.state.push_log(format!(
14830            "Buying plot for {}",
14831            crate::currency::format_copper(purchase)
14832        ));
14833        Ok(())
14834    }
14835
14836    pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
14837        if !self.state.is_alive() {
14838            anyhow::bail!("you are dead");
14839        }
14840        let zone_id = self
14841            .state
14842            .claim_mode
14843            .as_ref()
14844            .map(|m| m.zone_id.clone())
14845            .or_else(|| {
14846                self.state
14847                    .free_property_zone_under_player()
14848                    .map(|z| z.id.clone())
14849            })
14850            .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
14851        self.seq += 1;
14852        self.session
14853            .submit_intent(Intent::BuyPlotAllFree {
14854                entity_id: self.state.entity_id,
14855                zone_id,
14856                seq: self.seq,
14857            })
14858            .await?;
14859        self.state.intents_sent += 1;
14860        self.state.claim_mode = None;
14861        self.state.push_log("Claiming largest free plot…");
14862        Ok(())
14863    }
14864
14865    pub async fn confirm_sell_plot_to_crown(&mut self, plot_id: uuid::Uuid) -> anyhow::Result<()> {
14866        if !self.state.is_alive() {
14867            anyhow::bail!("you are dead");
14868        }
14869        self.seq += 1;
14870        self.session
14871            .submit_intent(Intent::SellPlotToCrown {
14872                entity_id: self.state.entity_id,
14873                plot_id,
14874                seq: self.seq,
14875            })
14876            .await?;
14877        self.state.intents_sent += 1;
14878        self.state.sell_plot_confirm = None;
14879        self.state.sell_plot_armed_at = None;
14880        self.state.push_log("Selling plot to the crown…");
14881        Ok(())
14882    }
14883
14884    pub async fn set_plot_farm_public(
14885        &mut self,
14886        plot_id: uuid::Uuid,
14887        public: bool,
14888        public_tax_discount_bps: u32,
14889    ) -> anyhow::Result<()> {
14890        self.seq += 1;
14891        self.session
14892            .submit_intent(Intent::SetPlotFarmPublic {
14893                entity_id: self.state.entity_id,
14894                plot_id,
14895                public,
14896                public_tax_discount_bps,
14897                seq: self.seq,
14898            })
14899            .await?;
14900        self.state.intents_sent += 1;
14901        Ok(())
14902    }
14903
14904    pub async fn plot_farm_allow_upsert(
14905        &mut self,
14906        plot_id: uuid::Uuid,
14907        character_id: Option<uuid::Uuid>,
14908        character_name: String,
14909        tax_discount_bps: u32,
14910    ) -> anyhow::Result<()> {
14911        self.seq += 1;
14912        self.session
14913            .submit_intent(Intent::PlotFarmAllowUpsert {
14914                entity_id: self.state.entity_id,
14915                plot_id,
14916                character_id,
14917                character_name,
14918                tax_discount_bps,
14919                seq: self.seq,
14920            })
14921            .await?;
14922        self.state.intents_sent += 1;
14923        Ok(())
14924    }
14925
14926    pub async fn plot_farm_allow_remove(
14927        &mut self,
14928        plot_id: uuid::Uuid,
14929        character_id: uuid::Uuid,
14930    ) -> anyhow::Result<()> {
14931        self.seq += 1;
14932        self.session
14933            .submit_intent(Intent::PlotFarmAllowRemove {
14934                entity_id: self.state.entity_id,
14935                plot_id,
14936                character_id,
14937                seq: self.seq,
14938            })
14939            .await?;
14940        self.state.intents_sent += 1;
14941        Ok(())
14942    }
14943
14944    pub fn open_farm_access_panel(&mut self) {
14945        let Some(plot) = self.state.my_plot_under_player() else {
14946            self.state
14947                .push_log("Stand on your deed plot to manage farm access");
14948            return;
14949        };
14950        self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
14951        self.state.farm_access_index = 0;
14952        self.state.show_farm_access = true;
14953    }
14954
14955    pub fn close_farm_access_panel(&mut self) {
14956        self.state.show_farm_access = false;
14957        self.state.farm_access_name_draft.clear();
14958        self.state.farm_access_index = 0;
14959    }
14960
14961    pub fn farm_access_move(&mut self, delta: i32) {
14962        let n = self.farm_access_row_count().max(1);
14963        let idx = self.state.farm_access_index as i32 + delta;
14964        self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
14965    }
14966
14967    pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
14968        let Some(plot) = self.state.my_plot_under_player() else {
14969            return vec![FarmAccessRow::PublicToggle];
14970        };
14971        let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
14972        for g in &plot.farm_allow {
14973            rows.push(FarmAccessRow::AllowRemove {
14974                character_id: g.character_id,
14975                label: if g.character_label.trim().is_empty() {
14976                    g.character_id.to_string()[..8].to_string()
14977                } else {
14978                    g.character_label.clone()
14979                },
14980                tax_discount_bps: g.tax_discount_bps,
14981            });
14982        }
14983        for e in &self.state.entities {
14984            if e.id == self.state.entity_id || e.label.trim().is_empty() {
14985                continue;
14986            }
14987            if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
14988                continue;
14989            }
14990            if self
14991                .state
14992                .npcs
14993                .iter()
14994                .any(|n| n.id == e.label || n.label == e.label)
14995            {
14996                continue;
14997            }
14998            if plot
14999                .farm_allow
15000                .iter()
15001                .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
15002            {
15003                continue;
15004            }
15005            rows.push(FarmAccessRow::NearbyAdd {
15006                name: e.label.clone(),
15007            });
15008        }
15009        rows
15010    }
15011
15012    pub fn farm_access_row_count(&self) -> usize {
15013        self.farm_access_rows().len().max(1)
15014    }
15015
15016    pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
15017        let Some(plot) = self.state.my_plot_under_player().cloned() else {
15018            self.close_farm_access_panel();
15019            return Ok(());
15020        };
15021        let rows = self.farm_access_rows();
15022        let Some(row) = rows.get(self.state.farm_access_index) else {
15023            return Ok(());
15024        };
15025        match row {
15026            FarmAccessRow::PublicToggle => {
15027                self.set_plot_farm_public(
15028                    plot.plot_id,
15029                    !plot.farm_public,
15030                    plot.public_tax_discount_bps,
15031                )
15032                .await
15033            }
15034            FarmAccessRow::PublicDiscount => Ok(()),
15035            FarmAccessRow::AllowRemove { character_id, .. } => {
15036                self.plot_farm_allow_remove(plot.plot_id, *character_id)
15037                    .await
15038            }
15039            FarmAccessRow::NearbyAdd { name } => {
15040                let disc = self
15041                    .state
15042                    .farm_access_discount_bps
15043                    .max(plot.public_tax_discount_bps);
15044                self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
15045                    .await
15046            }
15047        }
15048    }
15049
15050    pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
15051        let Some(plot) = self.state.my_plot_under_player().cloned() else {
15052            return Ok(());
15053        };
15054        let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
15055        self.state.farm_access_discount_bps = next;
15056        self.state.farm_access_index = 1;
15057        self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
15058            .await
15059    }
15060
15061    /// Till the cell under you on a farmable plot (`c`).
15062    pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
15063        if self.state.farmable_plot_under_player().is_none() {
15064            anyhow::bail!("stand on a farmable plot to cultivate");
15065        }
15066        let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
15067            let (px, py) = self.state.player_position();
15068            if self
15069                .state
15070                .terrain_at(px, py)
15071                .is_some_and(|k| k == TerrainKindView::Tilled)
15072            {
15073                anyhow::bail!("already tilled — stand on bare soil and press c");
15074            }
15075            anyhow::bail!("cannot till this cell — move onto soil on your plot");
15076        };
15077        self.cultivate_at(tx, ty).await
15078    }
15079
15080    /// Plant seeds on empty tilled soil under you (`p`).
15081    pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
15082        if self.state.farmable_plot_under_player().is_none() {
15083            anyhow::bail!("stand on a farmable plot to plant");
15084        }
15085        if !self.state.underfoot_free_tilled_plant_slot() {
15086            anyhow::bail!("stand on empty tilled soil and press p");
15087        }
15088        let seeds = self.state.farm_seed_entries();
15089        if seeds.is_empty() {
15090            anyhow::bail!("no seeds in inventory — buy seeds from Eli");
15091        }
15092        if seeds.len() == 1 {
15093            return self.plant_seeds(seeds[0].0.clone(), 1).await;
15094        }
15095        self.open_plant_menu();
15096        Ok(())
15097    }
15098
15099    /// Open the wall/roof build picker on the owned plot underfoot (`B`).
15100    pub fn open_plot_build_menu(&mut self) -> anyhow::Result<()> {
15101        let Some(plot) = self.state.my_plot_under_player() else {
15102            anyhow::bail!("stand on your plot to build");
15103        };
15104        if plot.building_id.is_some() {
15105            anyhow::bail!("this plot already has a building");
15106        }
15107        let building_now = self
15108            .state
15109            .timed_channel
15110            .as_ref()
15111            .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
15112        if !building_now && self.state.building_materials.is_empty() {
15113            anyhow::bail!("no building materials loaded — wait a moment and try again");
15114        }
15115        self.state.show_plot_build_menu = true;
15116        self.state.show_craft_menu = false;
15117        self.state.show_shop_menu = false;
15118        self.state.shop_catalog = None;
15119        self.state.show_stats = false;
15120        self.state.show_inventory_menu = false;
15121        self.state.plot_build_focus_wall = true;
15122        let walls = self.state.plot_build_wall_options().len();
15123        let roofs = self.state.plot_build_roof_options().len();
15124        if walls > 0 {
15125            self.state.plot_build_wall_index = self.state.plot_build_wall_index.min(walls - 1);
15126        } else {
15127            self.state.plot_build_wall_index = 0;
15128        }
15129        if roofs > 0 {
15130            self.state.plot_build_roof_index = self.state.plot_build_roof_index.min(roofs - 1);
15131        } else {
15132            self.state.plot_build_roof_index = 0;
15133        }
15134        Ok(())
15135    }
15136
15137    pub fn close_plot_build_menu(&mut self) {
15138        self.state.show_plot_build_menu = false;
15139    }
15140
15141    pub fn plot_build_menu_move(&mut self, delta: i32) {
15142        let walls = self.state.plot_build_wall_options();
15143        let roofs = self.state.plot_build_roof_options();
15144        if self.state.plot_build_focus_wall {
15145            if walls.is_empty() {
15146                return;
15147            }
15148            let n = walls.len() as i32;
15149            let cur = self.state.plot_build_wall_index as i32;
15150            self.state.plot_build_wall_index = ((cur + delta).rem_euclid(n)) as usize;
15151        } else {
15152            if roofs.is_empty() {
15153                return;
15154            }
15155            let n = roofs.len() as i32;
15156            let cur = self.state.plot_build_roof_index as i32;
15157            self.state.plot_build_roof_index = ((cur + delta).rem_euclid(n)) as usize;
15158        }
15159    }
15160
15161    pub fn plot_build_menu_toggle_focus(&mut self) {
15162        self.state.plot_build_focus_wall = !self.state.plot_build_focus_wall;
15163    }
15164
15165    /// Confirm selection from the B menu.
15166    pub async fn plot_build_menu_confirm(&mut self) -> anyhow::Result<()> {
15167        let wall = self
15168            .state
15169            .plot_build_selected_wall()
15170            .ok_or_else(|| anyhow::anyhow!("pick a wall material"))?
15171            .id
15172            .clone();
15173        let roof = self
15174            .state
15175            .plot_build_selected_roof()
15176            .ok_or_else(|| anyhow::anyhow!("pick a roof material"))?
15177            .id
15178            .clone();
15179        // Keep the B menu open so it switches to the in-progress panel.
15180        self.start_plot_build(&wall, &roof).await
15181    }
15182
15183    /// Cancel an in-progress plot build (materials returned).
15184    pub async fn plot_build_menu_cancel_build(&mut self) -> anyhow::Result<()> {
15185        self.seq += 1;
15186        self.session
15187            .submit_intent(Intent::CancelPlotBuild {
15188                entity_id: self.state.entity_id,
15189                seq: self.seq,
15190            })
15191            .await?;
15192        self.state.intents_sent += 1;
15193        Ok(())
15194    }
15195
15196    /// Start timed shell craft on the plot's solid tilled rectangle.
15197    pub async fn start_plot_build(
15198        &mut self,
15199        wall_material_id: &str,
15200        roof_material_id: &str,
15201    ) -> anyhow::Result<()> {
15202        let Some(plot) = self.state.my_plot_under_player() else {
15203            anyhow::bail!("stand on your plot to build");
15204        };
15205        if plot.building_id.is_some() {
15206            anyhow::bail!("this plot already has a building");
15207        }
15208        let plot_id = plot.plot_id;
15209        self.seq += 1;
15210        self.session
15211            .submit_intent(Intent::StartPlotBuild {
15212                entity_id: self.state.entity_id,
15213                plot_id,
15214                wall_material_id: wall_material_id.to_string(),
15215                roof_material_id: roof_material_id.to_string(),
15216                seq: self.seq,
15217            })
15218            .await?;
15219        self.state.intents_sent += 1;
15220        Ok(())
15221    }
15222
15223    /// Lock/unlock nearest exterior door when you hold its key (`l` / `L`).
15224    pub async fn toggle_nearby_door_lock(&mut self) -> anyhow::Result<()> {
15225        let (px, py) = self.state.player_position();
15226        let mut best: Option<(f32, String, bool)> = None;
15227        for d in &self.state.doors {
15228            if d.lock_id.is_none() {
15229                continue;
15230            }
15231            let dist = (d.x - px).hypot(d.y - py);
15232            if dist > DOOR_INTERACTION_RADIUS_M {
15233                continue;
15234            }
15235            if best.as_ref().is_none_or(|(bd, _, _)| dist < *bd) {
15236                best = Some((dist, d.id.clone(), d.locked));
15237            }
15238        }
15239        let Some((_, door_id, locked_now)) = best else {
15240            anyhow::bail!("no lockable door nearby");
15241        };
15242        let locked = !locked_now;
15243        self.seq += 1;
15244        self.session
15245            .submit_intent(Intent::SetDoorLocked {
15246                entity_id: self.state.entity_id,
15247                door_id,
15248                locked,
15249                seq: self.seq,
15250            })
15251            .await?;
15252        self.state.intents_sent += 1;
15253        Ok(())
15254    }
15255
15256    /// Enter through a nearby open player-building door (`Enter`).
15257    pub async fn enter_nearby_open_door(&mut self) -> anyhow::Result<()> {
15258        if !self.state.is_alive() {
15259            anyhow::bail!("you are dead");
15260        }
15261        if self.state.effective_inside_building().is_some() {
15262            anyhow::bail!("already inside");
15263        }
15264        let (px, py) = self.state.player_position();
15265        let mut best: Option<(f32, String)> = None;
15266        for d in &self.state.doors {
15267            if !d.open || d.locked {
15268                continue;
15269            }
15270            let player_house = self
15271                .state
15272                .buildings
15273                .iter()
15274                .find(|b| b.id == d.building_id)
15275                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15276            if !player_house {
15277                continue;
15278            }
15279            let dist = (d.x - px).hypot(d.y - py);
15280            if dist > DOOR_INTERACTION_RADIUS_M {
15281                continue;
15282            }
15283            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15284                best = Some((dist, d.id.clone()));
15285            }
15286        }
15287        let Some((_, door_id)) = best else {
15288            anyhow::bail!("no open house door nearby — open with f first");
15289        };
15290        self.seq += 1;
15291        self.session
15292            .submit_intent(Intent::EnterBuildingDoor {
15293                entity_id: self.state.entity_id,
15294                door_id,
15295                seq: self.seq,
15296            })
15297            .await?;
15298        self.state.intents_sent += 1;
15299        Ok(())
15300    }
15301
15302    /// Exit through a nearby exterior portal from inside a player house (`Enter`).
15303    /// Works even when the door is closed or locked.
15304    pub async fn exit_nearby_building_door(&mut self) -> anyhow::Result<()> {
15305        if !self.state.is_alive() {
15306            anyhow::bail!("you are dead");
15307        }
15308        let Some(bid) = self.state.effective_inside_building() else {
15309            anyhow::bail!("not inside a building");
15310        };
15311        let (px, py) = self.state.player_position();
15312        let mut best: Option<(f32, String)> = None;
15313        for d in &self.state.doors {
15314            if d.building_id != bid || d.portal.is_none() {
15315                continue;
15316            }
15317            let player_house = self
15318                .state
15319                .buildings
15320                .iter()
15321                .find(|b| b.id == d.building_id)
15322                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15323            if !player_house {
15324                continue;
15325            }
15326            let dist = (d.x - px).hypot(d.y - py);
15327            if dist > 1.5 {
15328                continue;
15329            }
15330            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15331                best = Some((dist, d.id.clone()));
15332            }
15333        }
15334        let Some((_, door_id)) = best else {
15335            anyhow::bail!("stand by the door to exit");
15336        };
15337        self.seq += 1;
15338        self.session
15339            .submit_intent(Intent::ExitBuildingDoor {
15340                entity_id: self.state.entity_id,
15341                door_id,
15342                seq: self.seq,
15343            })
15344            .await?;
15345        self.state.intents_sent += 1;
15346        Ok(())
15347    }
15348
15349    /// Confirm interior layout for your player building (copper charged on server).
15350    pub async fn confirm_interior_edit(
15351        &mut self,
15352        building_id: String,
15353        rooms: Vec<flatland_protocol::InteriorRoomEdit>,
15354        room_doors: Vec<flatland_protocol::InteriorRoomDoorEdit>,
15355    ) -> anyhow::Result<()> {
15356        self.seq += 1;
15357        self.session
15358            .submit_intent(Intent::ConfirmInteriorEdit {
15359                entity_id: self.state.entity_id,
15360                building_id,
15361                rooms,
15362                room_doors,
15363                seq: self.seq,
15364            })
15365            .await?;
15366        self.state.intents_sent += 1;
15367        Ok(())
15368    }
15369
15370    pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
15371        if !self.state.is_alive() {
15372            anyhow::bail!("you are dead");
15373        }
15374        self.seq += 1;
15375        self.session
15376            .submit_intent(Intent::Cultivate {
15377                entity_id: self.state.entity_id,
15378                x,
15379                y,
15380                seq: self.seq,
15381            })
15382            .await?;
15383        self.state.intents_sent += 1;
15384        Ok(())
15385    }
15386
15387    pub async fn plant_seeds(
15388        &mut self,
15389        seed_template_id: String,
15390        quantity: u32,
15391    ) -> anyhow::Result<()> {
15392        if !self.state.is_alive() {
15393            anyhow::bail!("you are dead");
15394        }
15395        self.seq += 1;
15396        self.session
15397            .submit_intent(Intent::PlantSeeds {
15398                entity_id: self.state.entity_id,
15399                seed_template_id: seed_template_id.clone(),
15400                quantity,
15401                seq: self.seq,
15402            })
15403            .await?;
15404        self.state.intents_sent += 1;
15405        self.state
15406            .push_log(format!("Planting {quantity}× {seed_template_id}…"));
15407        Ok(())
15408    }
15409
15410    pub fn open_plant_menu(&mut self) {
15411        if self.state.farm_seed_entries().is_empty() {
15412            self.state.push_log("No seeds in inventory to plant");
15413            return;
15414        }
15415        self.state.show_plant_menu = true;
15416        self.state.plant_menu_index = 0;
15417        self.state.plant_quantity = 1;
15418        self.state.clamp_plant_menu();
15419    }
15420
15421    pub fn close_plant_menu(&mut self) {
15422        self.state.show_plant_menu = false;
15423    }
15424
15425    pub fn plant_menu_move(&mut self, delta: i32) {
15426        let n = self.state.farm_seed_entries().len();
15427        if n == 0 {
15428            return;
15429        }
15430        let idx = self.state.plant_menu_index as i32 + delta;
15431        self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
15432        self.state.clamp_plant_menu();
15433    }
15434
15435    pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
15436        let next = self.state.plant_quantity as i32 + delta;
15437        self.state.plant_quantity = next.max(1) as u32;
15438        self.state.clamp_plant_menu();
15439    }
15440
15441    pub fn plant_menu_set_quantity_max(&mut self) {
15442        if let Some((_, max, _)) = self.state.plant_menu_selection() {
15443            self.state.plant_quantity = max;
15444        }
15445        self.state.clamp_plant_menu();
15446    }
15447
15448    pub fn plant_menu_set_quantity_min(&mut self) {
15449        self.state.plant_quantity = 1;
15450        self.state.clamp_plant_menu();
15451    }
15452
15453    pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
15454        let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
15455            self.close_plant_menu();
15456            anyhow::bail!("no seeds to plant");
15457        };
15458        self.close_plant_menu();
15459        self.plant_seeds(seed, qty).await?;
15460        self.state.push_log(format!("Planted {qty}× {label}"));
15461        Ok(())
15462    }
15463
15464    /// Activate the ability or consumable bound to hotbar slot `1`–`9`.
15465    /// Heals prefer T2/self; other abilities prefer T1. Consumables need no target.
15466    pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
15467        if !self.state.is_alive() {
15468            anyhow::bail!("you are dead");
15469        }
15470        let binding = self
15471            .state
15472            .hotbar_ability(slot)
15473            .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
15474            .to_string();
15475        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
15476            let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
15477            if qty == 0 {
15478                anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
15479            }
15480            return self.use_item(template_id).await;
15481        }
15482        let ability_id = binding;
15483        if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
15484            return self
15485                .cast_ability(&ability_id, Some(self.state.entity_id))
15486                .await;
15487        }
15488        let is_heal = ability_id == "heal_touch"
15489            || self
15490                .state
15491                .ability_meta
15492                .get(&ability_id)
15493                .map(|meta| meta.is_heal)
15494                .unwrap_or(false);
15495        let target = if is_heal {
15496            Some(
15497                self.state
15498                    .target_for_slot(2)
15499                    .unwrap_or(self.state.entity_id),
15500            )
15501        } else {
15502            self.state
15503                .target_for_slot(1)
15504                .or_else(|| self.state.target_for_slot(2))
15505        };
15506        let Some(target_id) = target else {
15507            anyhow::bail!("no target — Tab to select, then press the hotbar key");
15508        };
15509        self.cast_ability(&ability_id, Some(target_id)).await
15510    }
15511
15512    /// Bind or clear a hotbar slot (`1`–`9`) via [`Intent::SetHotbarSlot`].
15513    /// `ability_id` may be a learned ability or `item:<template_id>` for consumables.
15514    pub async fn set_hotbar_slot(
15515        &mut self,
15516        slot: u8,
15517        ability_id: Option<&str>,
15518    ) -> anyhow::Result<()> {
15519        if !self.state.is_alive() {
15520            anyhow::bail!("you are dead");
15521        }
15522        if !(1..=9).contains(&slot) {
15523            anyhow::bail!("hotbar slot must be 1–9");
15524        }
15525        let ability_id = ability_id
15526            .map(str::trim)
15527            .filter(|id| !id.is_empty())
15528            .map(str::to_string);
15529        self.seq += 1;
15530        self.session
15531            .submit_intent(Intent::SetHotbarSlot {
15532                entity_id: self.state.entity_id,
15533                slot,
15534                ability_id: ability_id.clone(),
15535                seq: self.seq,
15536            })
15537            .await?;
15538        self.state.intents_sent += 1;
15539        let idx = (slot - 1) as usize;
15540        if self.state.hotbar.len() < 9 {
15541            self.state.hotbar.resize(9, None);
15542        }
15543        if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
15544            *slot_mut = ability_id.clone();
15545        }
15546        match ability_id {
15547            Some(id) => {
15548                let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
15549                    format!("use {tid}")
15550                } else {
15551                    id
15552                };
15553                self.state.push_log(format!("Hotbar {slot} → {label}"))
15554            }
15555            None => self.state.push_log(format!("Hotbar {slot} cleared")),
15556        }
15557        Ok(())
15558    }
15559
15560    pub fn npc_verb_options(&self) -> Vec<NpcVerbChoice> {
15561        self.state.npc_verb_options()
15562    }
15563
15564    pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
15565        let Some(npc_id) = self.state.npc_verb_target.clone() else {
15566            return Ok(());
15567        };
15568        let options = self.npc_verb_options();
15569        let choice = options
15570            .get(self.state.npc_verb_index)
15571            .cloned()
15572            .unwrap_or_else(GameState::talk_choice);
15573        match choice.action {
15574            NpcVerbAction::QuestGive { quest_id } => {
15575                self.submit_npc_quest_turn_in(&npc_id, Some(&quest_id))
15576                    .await?;
15577                self.state.show_npc_verb_menu = false;
15578            }
15579            NpcVerbAction::Talk => {
15580                self.open_npc_talk(&npc_id, None).await?;
15581            }
15582            NpcVerbAction::QuestTalk { quest_id } => {
15583                self.open_npc_talk(&npc_id, Some(&quest_id)).await?;
15584            }
15585            NpcVerbAction::Trade | NpcVerbAction::Bank | NpcVerbAction::Storage | NpcVerbAction::Market => {
15586                self.seq += 1;
15587                self.session
15588                    .submit_intent(Intent::Interact {
15589                        entity_id: self.state.entity_id,
15590                        target_id: npc_id,
15591                        seq: self.seq,
15592                    })
15593                    .await?;
15594                self.state.intents_sent += 1;
15595            }
15596        }
15597        Ok(())
15598    }
15599
15600    async fn open_npc_talk(&mut self, npc_id: &str, quest_id: Option<&str>) -> anyhow::Result<()> {
15601        self.seq += 1;
15602        self.session
15603            .submit_intent(Intent::NpcTalkOpen {
15604                entity_id: self.state.entity_id,
15605                npc_id: npc_id.to_string(),
15606                quest_id: quest_id.map(str::to_string),
15607                seq: self.seq,
15608            })
15609            .await?;
15610        self.state.intents_sent += 1;
15611        Ok(())
15612    }
15613
15614    async fn submit_npc_quest_turn_in(
15615        &mut self,
15616        npc_id: &str,
15617        quest_id: Option<&str>,
15618    ) -> anyhow::Result<()> {
15619        let catalog_ref = self.state.npc_quest_catalog_ref(npc_id);
15620        let pending: Vec<(String, u32, String)> = self
15621            .state
15622            .quest_log
15623            .iter()
15624            .filter(|q| {
15625                q.status == flatland_protocol::QuestStatusView::Active
15626                    && quest_id.is_none_or(|id| q.quest_id == id)
15627            })
15628            .flat_map(|q| q.objectives.iter())
15629            .filter(|o| {
15630                !o.done
15631                    && o.kind == "give_item"
15632                    && o.npc_ref.as_deref() == Some(catalog_ref.as_str())
15633            })
15634            .filter_map(|o| {
15635                let template = o.item_template.clone()?;
15636                let remaining = o.required.saturating_sub(o.current);
15637                if remaining == 0 {
15638                    return None;
15639                }
15640                Some((template, remaining, o.label.clone()))
15641            })
15642            .collect();
15643        if pending.is_empty() {
15644            self.state.push_log("Nothing to turn in here.");
15645            return Ok(());
15646        }
15647        let mut sent = 0u32;
15648        for (template, remaining, label) in pending {
15649            let held = self.state.count_inventory_template(&template);
15650            let qty = remaining.min(held);
15651            if qty == 0 {
15652                self.state.push_log(format!("Need {label}"));
15653                continue;
15654            }
15655            self.seq += 1;
15656            self.session
15657                .submit_intent(Intent::QuestGiveItem {
15658                    entity_id: self.state.entity_id,
15659                    npc_id: npc_id.to_string(),
15660                    template_id: template,
15661                    quantity: qty,
15662                    seq: self.seq,
15663                })
15664                .await?;
15665            self.state.intents_sent += 1;
15666            sent += 1;
15667        }
15668        if sent > 0 {
15669            self.state.push_log("Turning in quest items.");
15670        }
15671        Ok(())
15672    }
15673
15674    pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
15675        let Some(chat) = self.state.npc_chat.clone() else {
15676            return Ok(());
15677        };
15678        let message = chat.input.trim().to_string();
15679        if message.is_empty() || chat.pending {
15680            return Ok(());
15681        }
15682        if let Some(c) = self.state.npc_chat.as_mut() {
15683            c.lines.push(format!("You: {message}"));
15684            c.input.clear();
15685            c.pending = true;
15686        }
15687        self.seq += 1;
15688        self.session
15689            .submit_intent(Intent::NpcTalkSay {
15690                entity_id: self.state.entity_id,
15691                npc_id: chat.npc_id,
15692                message,
15693                seq: self.seq,
15694            })
15695            .await?;
15696        self.state.intents_sent += 1;
15697        Ok(())
15698    }
15699
15700    pub async fn npc_talk_topic(&mut self, index: usize) -> anyhow::Result<()> {
15701        let topic = self
15702            .state
15703            .npc_chat
15704            .as_ref()
15705            .and_then(|c| c.suggested_topics.get(index))
15706            .cloned();
15707        let Some(topic) = topic else {
15708            return Ok(());
15709        };
15710        if let Some(c) = self.state.npc_chat.as_mut() {
15711            if c.pending {
15712                return Ok(());
15713            }
15714            c.input = topic;
15715        }
15716        self.npc_talk_send().await
15717    }
15718
15719    pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
15720        let return_to_verbs = self.state.npc_verb_target.is_some();
15721        let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
15722            self.state.show_npc_chat = false;
15723            if return_to_verbs {
15724                self.state.show_npc_verb_menu = true;
15725            }
15726            return Ok(());
15727        };
15728        self.seq += 1;
15729        self.session
15730            .submit_intent(Intent::NpcTalkClose {
15731                entity_id: self.state.entity_id,
15732                npc_id,
15733                seq: self.seq,
15734            })
15735            .await?;
15736        self.state.intents_sent += 1;
15737        self.state.show_npc_chat = false;
15738        self.state.npc_chat = None;
15739        if return_to_verbs {
15740            self.state.show_npc_verb_menu = true;
15741            self.state.npc_verb_notice = None;
15742        }
15743        Ok(())
15744    }
15745
15746    /// Esc/back inside Talk, Trade, quest-offer, or the verb menu — pop one layer, not the whole session.
15747    pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
15748        if self.state.show_quest_offer
15749            && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
15750        {
15751            self.quest_offer_decline();
15752            return Ok(());
15753        }
15754        if self.state.show_npc_chat {
15755            return self.npc_talk_close().await;
15756        }
15757        if self.state.show_shop_menu {
15758            return self.back_from_shop_menu().await;
15759        }
15760        if self.state.bank_panel.is_some() {
15761            if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
15762                self.bank_transfer_back();
15763                return Ok(());
15764            }
15765            return self.close_bank_panel().await;
15766        }
15767        if self.state.storage_panel.is_some() {
15768            if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
15769                self.storage_ui_back();
15770                return Ok(());
15771            }
15772            return self.close_storage_panel().await;
15773        }
15774        if self.state.market_panel.is_some() {
15775            if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
15776                self.market_ui_back();
15777                return Ok(());
15778            }
15779            if self.state.market_buy_confirm.is_some() {
15780                self.state.market_buy_confirm = None;
15781                return Ok(());
15782            }
15783            return self.close_market_panel().await;
15784        }
15785        if self.state.show_npc_verb_menu {
15786            self.state.show_npc_verb_menu = false;
15787            self.state.npc_verb_target = None;
15788        }
15789        Ok(())
15790    }
15791
15792    pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
15793        self.seq += 1;
15794        self.session
15795            .submit_intent(Intent::TestDamage {
15796                entity_id: self.state.entity_id,
15797                amount,
15798                seq: self.seq,
15799            })
15800            .await?;
15801        self.state.intents_sent += 1;
15802        Ok(())
15803    }
15804
15805    pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
15806        self.cycle_combat_target_slot(1, reverse).await
15807    }
15808
15809    pub async fn cycle_combat_target_slot(
15810        &mut self,
15811        slot_index: u8,
15812        reverse: bool,
15813    ) -> anyhow::Result<()> {
15814        if !self.state.is_alive() {
15815            anyhow::bail!("you are dead");
15816        }
15817        let candidates = self.state.candidates_for_slot(slot_index);
15818        if candidates.is_empty() {
15819            anyhow::bail!("no targets nearby");
15820        }
15821        let current = self.state.target_for_slot(slot_index);
15822        let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
15823        let next_idx = match idx {
15824            None => 0,
15825            Some(i) if reverse => {
15826                if i == 0 {
15827                    candidates.len() - 1
15828                } else {
15829                    i - 1
15830                }
15831            }
15832            Some(i) => (i + 1) % candidates.len(),
15833        };
15834        if idx == Some(next_idx) && candidates.len() == 1 {
15835            self.clear_combat_target_slot(slot_index).await?;
15836            return Ok(());
15837        }
15838        let (target_id, label) = candidates[next_idx].clone();
15839        self.set_combat_target_slot(slot_index, target_id, &label)
15840            .await
15841    }
15842
15843    pub async fn set_combat_target_slot(
15844        &mut self,
15845        slot_index: u8,
15846        target_id: EntityId,
15847        label: &str,
15848    ) -> anyhow::Result<()> {
15849        if !self.state.is_alive() {
15850            anyhow::bail!("you are dead");
15851        }
15852        self.seq += 1;
15853        self.session
15854            .submit_intent(Intent::SetTargetSlot {
15855                entity_id: self.state.entity_id,
15856                slot_index,
15857                target_id,
15858                seq: self.seq,
15859            })
15860            .await?;
15861        self.state.intents_sent += 1;
15862        if slot_index == 1 {
15863            self.state.combat_target = Some(target_id);
15864            self.state.combat_target_label = Some(label.to_string());
15865        }
15866        self.state
15867            .push_log(format!("Slot {slot_index} target: {label}"));
15868        Ok(())
15869    }
15870
15871    pub async fn set_combat_target(
15872        &mut self,
15873        target_id: EntityId,
15874        label: &str,
15875    ) -> anyhow::Result<()> {
15876        self.set_combat_target_slot(1, target_id, label).await
15877    }
15878
15879    pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
15880        if slot_index == 1 && self.state.combat_target.is_none() {
15881            return Ok(());
15882        }
15883        self.seq += 1;
15884        self.session
15885            .submit_intent(Intent::ClearTargetSlot {
15886                entity_id: self.state.entity_id,
15887                slot_index,
15888                seq: self.seq,
15889            })
15890            .await?;
15891        if slot_index == 1 {
15892            self.state.combat_target = None;
15893            self.state.combat_target_label = None;
15894        }
15895        self.state.intents_sent += 1;
15896        self.state
15897            .push_log(format!("Slot {slot_index} target cleared"));
15898        Ok(())
15899    }
15900
15901    pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
15902        self.clear_combat_target_slot(1).await
15903    }
15904
15905    pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
15906        if !self.state.is_alive() {
15907            anyhow::bail!("you are dead");
15908        }
15909        self.seq += 1;
15910        self.session
15911            .submit_intent(Intent::AdvanceRotation {
15912                entity_id: self.state.entity_id,
15913                slot_index,
15914                seq: self.seq,
15915            })
15916            .await?;
15917        self.state.intents_sent += 1;
15918        Ok(())
15919    }
15920
15921    pub async fn assign_slot_preset(
15922        &mut self,
15923        slot_index: u8,
15924        preset_id: &str,
15925    ) -> anyhow::Result<()> {
15926        if !self.state.is_alive() {
15927            anyhow::bail!("you are dead");
15928        }
15929        self.seq += 1;
15930        self.session
15931            .submit_intent(Intent::AssignSlotPreset {
15932                entity_id: self.state.entity_id,
15933                slot_index,
15934                preset_id: preset_id.to_string(),
15935                seq: self.seq,
15936            })
15937            .await?;
15938        self.state.intents_sent += 1;
15939        if let Some(slot) = self
15940            .state
15941            .combat_slots
15942            .iter_mut()
15943            .find(|s| s.slot_index == slot_index)
15944        {
15945            slot.preset_id = Some(preset_id.to_string());
15946            if let Some(preset) = self
15947                .state
15948                .rotation_presets
15949                .iter()
15950                .find(|p| p.id == preset_id)
15951            {
15952                slot.preset_label = Some(preset.label.clone());
15953                slot.rotation = preset.abilities.clone();
15954                slot.rotation_index = 0;
15955            }
15956        }
15957        self.state
15958            .push_log(format!("T{slot_index} loadout → {preset_id}"));
15959        Ok(())
15960    }
15961
15962    pub async fn cast_ability(
15963        &mut self,
15964        ability_id: &str,
15965        target_id: Option<EntityId>,
15966    ) -> anyhow::Result<()> {
15967        if !self.state.is_alive() {
15968            anyhow::bail!("you are dead");
15969        }
15970        let allows_ground = self.state.ability_allows_ground(ability_id);
15971        let requires_ground = self.state.ability_requires_ground(ability_id);
15972        if requires_ground && self.state.ground_target.is_none() {
15973            anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
15974        }
15975        let (resolved_target_id, target_point) = if allows_ground {
15976            if let Some((x, y, z)) = self.state.ground_target {
15977                (
15978                    target_id.unwrap_or(self.state.entity_id),
15979                    Some(flatland_protocol::AimPoint { x, y, z }),
15980                )
15981            } else {
15982                (
15983                    target_id
15984                        .or_else(|| self.state.target_for_slot(2))
15985                        .or_else(|| self.state.target_for_slot(1))
15986                        .unwrap_or(self.state.entity_id),
15987                    None,
15988                )
15989            }
15990        } else {
15991            (
15992                target_id
15993                    .or_else(|| self.state.target_for_slot(2))
15994                    .or_else(|| self.state.target_for_slot(1))
15995                    .unwrap_or(self.state.entity_id),
15996                None,
15997            )
15998        };
15999        self.seq += 1;
16000        self.session
16001            .submit_intent(Intent::Cast {
16002                entity_id: self.state.entity_id,
16003                ability_id: ability_id.to_string(),
16004                target_id: resolved_target_id,
16005                target_point,
16006                seq: self.seq,
16007            })
16008            .await?;
16009        self.state.intents_sent += 1;
16010        match target_point {
16011            Some(point) => self.state.push_log(format!(
16012                "Cast {ability_id} → ({:.1}, {:.1})",
16013                point.x, point.y
16014            )),
16015            None => self
16016                .state
16017                .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
16018        }
16019        Ok(())
16020    }
16021
16022    pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
16023        self.seq += 1;
16024        self.session
16025            .submit_intent(Intent::UpsertRotationPreset {
16026                entity_id: self.state.entity_id,
16027                preset: preset.clone(),
16028                seq: self.seq,
16029            })
16030            .await?;
16031        self.state.intents_sent += 1;
16032        if let Some(existing) = self
16033            .state
16034            .rotation_presets
16035            .iter_mut()
16036            .find(|p| p.id == preset.id)
16037        {
16038            *existing = preset.clone();
16039        } else {
16040            self.state.rotation_presets.push(preset.clone());
16041        }
16042        for slot in &mut self.state.combat_slots {
16043            if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
16044                slot.preset_label = Some(preset.label.clone());
16045                slot.rotation = preset.abilities.clone();
16046            }
16047        }
16048        self.state
16049            .push_log(format!("Saved rotation: {}", preset.label));
16050        Ok(())
16051    }
16052
16053    pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
16054        self.seq += 1;
16055        self.session
16056            .submit_intent(Intent::DeleteRotationPreset {
16057                entity_id: self.state.entity_id,
16058                preset_id: preset_id.to_string(),
16059                seq: self.seq,
16060            })
16061            .await?;
16062        self.state.intents_sent += 1;
16063        self.state.rotation_presets.retain(|p| p.id != preset_id);
16064        for slot in &mut self.state.combat_slots {
16065            if slot.preset_id.as_deref() == Some(preset_id) {
16066                slot.preset_id = None;
16067                slot.preset_label = None;
16068                slot.rotation.clear();
16069                slot.rotation_index = 0;
16070            }
16071        }
16072        self.state
16073            .push_log(format!("Deleted rotation: {preset_id}"));
16074        Ok(())
16075    }
16076
16077    pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
16078        if !self.state.is_alive() {
16079            anyhow::bail!("you are dead");
16080        }
16081        let enabled = !self
16082            .state
16083            .combat_slots
16084            .iter()
16085            .find(|s| s.slot_index == slot_index)
16086            .map(|s| s.auto_enabled)
16087            .unwrap_or(false);
16088        self.seq += 1;
16089        self.session
16090            .submit_intent(Intent::SetAutoAttack {
16091                entity_id: self.state.entity_id,
16092                slot_index,
16093                enabled,
16094                seq: self.seq,
16095            })
16096            .await?;
16097        if slot_index == 1 {
16098            self.state.auto_attack = enabled;
16099        }
16100        self.state.intents_sent += 1;
16101        self.state.push_log(format!(
16102            "T{slot_index} auto {}",
16103            if enabled { "ON" } else { "OFF" }
16104        ));
16105        Ok(())
16106    }
16107
16108    pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
16109        if !self.state.connected {
16110            anyhow::bail!("not connected");
16111        }
16112        if !self.state.is_alive() {
16113            anyhow::bail!("you are dead");
16114        }
16115        let (px, py) = self.state.player_position();
16116        if self
16117            .state
16118            .ground_drops
16119            .iter()
16120            .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
16121        {
16122            anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
16123        }
16124        self.seq += 1;
16125        self.session
16126            .submit_intent(Intent::Pickup {
16127                entity_id: self.state.entity_id,
16128                drop_id: None,
16129                seq: self.seq,
16130            })
16131            .await?;
16132        self.state.intents_sent += 1;
16133        self.state.push_audio(crate::social::AudioCue::LootPickup);
16134        Ok(())
16135    }
16136
16137    pub async fn dodge(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16138        if !self.state.is_alive() {
16139            anyhow::bail!("you are dead");
16140        }
16141        // Only currently held WASD counts — sticky last_move would skip smart dodge after walking.
16142        self.seq += 1;
16143        self.session
16144            .submit_intent(Intent::Dodge {
16145                entity_id: self.state.entity_id,
16146                forward,
16147                strafe,
16148                seq: self.seq,
16149            })
16150            .await?;
16151        self.state.intents_sent += 1;
16152        self.state.push_log("Dodge!");
16153        self.state.push_audio(crate::social::AudioCue::CombatDodge);
16154        Ok(())
16155    }
16156
16157    pub async fn lunge(&mut self) -> anyhow::Result<()> {
16158        if !self.state.is_alive() {
16159            anyhow::bail!("you are dead");
16160        }
16161        let (forward, strafe) = self.last_move_axes();
16162        self.seq += 1;
16163        self.session
16164            .submit_intent(Intent::Lunge {
16165                entity_id: self.state.entity_id,
16166                forward,
16167                strafe,
16168                seq: self.seq,
16169            })
16170            .await?;
16171        self.state.intents_sent += 1;
16172        self.state.push_log("Lunge!");
16173        Ok(())
16174    }
16175
16176    pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16177        if !self.state.is_alive() {
16178            anyhow::bail!("you are dead");
16179        }
16180        self.seq += 1;
16181        self.session
16182            .submit_intent(Intent::DirectionalJump {
16183                entity_id: self.state.entity_id,
16184                forward,
16185                strafe,
16186                seq: self.seq,
16187            })
16188            .await?;
16189        self.state.intents_sent += 1;
16190        self.state.push_log("Jump!");
16191        Ok(())
16192    }
16193
16194    /// Remembered WASD axes for lunge when not currently moving.
16195    pub fn last_move_axes(&self) -> (f32, f32) {
16196        (self.last_move_forward, self.last_move_strafe)
16197    }
16198
16199    pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
16200        if !self.state.is_alive() {
16201            anyhow::bail!("you are dead");
16202        }
16203        self.seq += 1;
16204        self.session
16205            .submit_intent(Intent::Block {
16206                entity_id: self.state.entity_id,
16207                enabled,
16208                seq: self.seq,
16209            })
16210            .await?;
16211        self.state.intents_sent += 1;
16212        if enabled {
16213            self.state.push_log("Blocking");
16214            self.state.push_audio(crate::social::AudioCue::CombatBlock);
16215        }
16216        Ok(())
16217    }
16218
16219    pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
16220        if !self.state.is_alive() {
16221            anyhow::bail!("you are dead");
16222        }
16223        self.seq += 1;
16224        self.session
16225            .submit_intent(Intent::EquipMainhand {
16226                entity_id: self.state.entity_id,
16227                template_id,
16228                instance_id: None,
16229                seq: self.seq,
16230            })
16231            .await?;
16232        self.state.intents_sent += 1;
16233        Ok(())
16234    }
16235
16236    /// Equip paperdoll: activate selected slot (unequip filled, or equip first matching candidate).
16237    pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
16238        let idx = self.state.equip_menu_index;
16239        let slots = equip_paperdoll_rows(&self.state);
16240        let Some(row) = slots.get(idx) else {
16241            return Ok(());
16242        };
16243        match row {
16244            EquipPaperdollRow::Body { slot, filled } => {
16245                if *filled {
16246                    self.equip_worn(*slot, None).await
16247                } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
16248                    self.equip_worn(*slot, Some(inst)).await
16249                } else {
16250                    self.state
16251                        .push_log(format!("No item for {}", body_slot_label(*slot)));
16252                    Ok(())
16253                }
16254            }
16255            EquipPaperdollRow::Mainhand { filled } => {
16256                if *filled {
16257                    self.unequip_mainhand().await
16258                } else if let Some(tid) = first_inventory_weapon(&self.state) {
16259                    self.equip_mainhand(Some(tid)).await
16260                } else {
16261                    self.state.push_log("No weapon in inventory".to_string());
16262                    Ok(())
16263                }
16264            }
16265            EquipPaperdollRow::Offhand { filled, locked } => {
16266                if *locked {
16267                    self.state
16268                        .push_log("Offhand locked — two-handed weapon equipped".to_string());
16269                    Ok(())
16270                } else if *filled {
16271                    self.unequip_offhand().await
16272                } else if let Some(tid) = first_inventory_offhand(&self.state) {
16273                    self.equip_offhand(Some(tid)).await
16274                } else {
16275                    self.state
16276                        .push_log("No offhand item in inventory".to_string());
16277                    Ok(())
16278                }
16279            }
16280        }
16281    }
16282
16283    pub async fn say(
16284        &mut self,
16285        channel: flatland_protocol::ChatChannel,
16286        text: &str,
16287    ) -> anyhow::Result<()> {
16288        self.say_to(channel, text, None).await
16289    }
16290
16291    pub async fn say_to(
16292        &mut self,
16293        channel: flatland_protocol::ChatChannel,
16294        text: &str,
16295        to_entity: Option<EntityId>,
16296    ) -> anyhow::Result<()> {
16297        self.seq += 1;
16298        self.session
16299            .submit_intent(Intent::Say {
16300                entity_id: self.state.entity_id,
16301                channel,
16302                text: text.to_string(),
16303                to_entity,
16304                seq: self.seq,
16305            })
16306            .await?;
16307        self.state.intents_sent += 1;
16308        Ok(())
16309    }
16310
16311    pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
16312        let Some(peer) = self.state.player_verbs.target_entity else {
16313            return Ok(());
16314        };
16315        let label = self.state.player_verbs.target_label.clone();
16316        let choice = crate::social::PlayerVerbState::options()
16317            .get(self.state.player_verbs.index)
16318            .copied()
16319            .unwrap_or("Whisper");
16320        self.state.player_verbs.close();
16321        match choice {
16322            "Trade" => {
16323                // Only request — peer must accept (Y in chat). Do not auto-respond;
16324                // TradeRespond requires a pending inbound request from the peer.
16325                self.seq += 1;
16326                self.session
16327                    .submit_intent(Intent::TradeRequest {
16328                        entity_id: self.state.entity_id,
16329                        peer_entity_id: peer,
16330                        seq: self.seq,
16331                    })
16332                    .await?;
16333                self.state.intents_sent += 1;
16334                self.state.social_chat.push_system(format!(
16335                    "Trade request sent to {label} — waiting for accept"
16336                ));
16337            }
16338            "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
16339            _ => self.state.social_chat.focus_nearby(),
16340        }
16341        Ok(())
16342    }
16343
16344    pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
16345        let Some(pending) = self.state.social_chat.pending_trade.take() else {
16346            return Ok(());
16347        };
16348        self.seq += 1;
16349        self.session
16350            .submit_intent(Intent::TradeRespond {
16351                entity_id: self.state.entity_id,
16352                peer_entity_id: pending.from_entity,
16353                accept,
16354                seq: self.seq,
16355            })
16356            .await?;
16357        self.state.intents_sent += 1;
16358        if accept {
16359            self.state
16360                .social_chat
16361                .push_system(format!("Accepted trade with {}", pending.from_name));
16362        } else {
16363            self.state
16364                .social_chat
16365                .push_system(format!("Declined trade with {}", pending.from_name));
16366        }
16367        Ok(())
16368    }
16369
16370    pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
16371        let text = self.state.social_chat.buffer.trim().to_string();
16372        if text.is_empty() {
16373            return Ok(());
16374        }
16375        self.state.social_chat.buffer.clear();
16376        if crate::social::is_chat_slash_line(&text) {
16377            match crate::social::parse_chat_slash(&text) {
16378                Some(cmd) => return self.apply_chat_slash(cmd).await,
16379                None => {
16380                    self.state.social_chat.push_system(format!(
16381                        "Unknown command — {}",
16382                        crate::social::chat_slash_help_text()
16383                    ));
16384                    return Ok(());
16385                }
16386            }
16387        }
16388        let thread = self.state.social_chat.thread;
16389        let channel = thread.channel();
16390        let to = thread.to_entity();
16391        if let Some(peer) = to {
16392            let label = self.state.social_chat.peer_label.clone();
16393            self.state
16394                .social_chat
16395                .remember_whisper_peer(peer, &label, channel);
16396        }
16397        self.say_to(channel, &text, to).await
16398    }
16399
16400    async fn apply_chat_slash(
16401        &mut self,
16402        cmd: crate::social::ChatSlashCommand,
16403    ) -> anyhow::Result<()> {
16404        use crate::social::{chat_slash_help_text, ChatSlashCommand};
16405        match cmd {
16406            ChatSlashCommand::Help => {
16407                self.state
16408                    .social_chat
16409                    .push_system(chat_slash_help_text().to_string());
16410                Ok(())
16411            }
16412            ChatSlashCommand::Nearby { message } => {
16413                self.state.social_chat.focus_nearby();
16414                self.state
16415                    .social_chat
16416                    .push_system("Nearby speech — everyone close can hear");
16417                if let Some(msg) = message {
16418                    self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
16419                        .await
16420                } else {
16421                    Ok(())
16422                }
16423            }
16424            ChatSlashCommand::Reply { message } => {
16425                let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16426                    self.state
16427                        .social_chat
16428                        .push_system("No one to reply to — wait for a whisper, or /whisper Name");
16429                    return Ok(());
16430                };
16431                let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
16432                self.state
16433                    .social_chat
16434                    .set_whisper_thread(peer.entity_id, &peer.label, stone);
16435                self.state.social_chat.push_system(format!(
16436                    "Replying to {} — type and Enter · /nearby",
16437                    peer.label
16438                ));
16439                if let Some(msg) = message {
16440                    self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
16441                } else {
16442                    Ok(())
16443                }
16444            }
16445            ChatSlashCommand::Whisper { name, message } => {
16446                let (peer_id, label, stone) = if let Some(name) = name {
16447                    match self.resolve_whisper_target(&name) {
16448                        Ok(t) => t,
16449                        Err(err) => {
16450                            self.state.social_chat.push_system(err);
16451                            return Ok(());
16452                        }
16453                    }
16454                } else {
16455                    let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16456                        self.state.social_chat.push_system(
16457                            "Usage: /whisper Name [message] · or /reply after someone whispers you",
16458                        );
16459                        return Ok(());
16460                    };
16461                    (
16462                        peer.entity_id,
16463                        peer.label,
16464                        peer.channel == flatland_protocol::ChatChannel::WhisperStone,
16465                    )
16466                };
16467                self.state
16468                    .social_chat
16469                    .set_whisper_thread(peer_id, &label, stone);
16470                let channel = if stone {
16471                    flatland_protocol::ChatChannel::WhisperStone
16472                } else {
16473                    flatland_protocol::ChatChannel::Whisper
16474                };
16475                if let Some(msg) = message {
16476                    self.state
16477                        .social_chat
16478                        .push_system(format!("Whisper → {label}"));
16479                    self.say_to(channel, &msg, Some(peer_id)).await
16480                } else {
16481                    self.state.social_chat.push_system(format!(
16482                        "Whispering {label} — type and Enter · Esc / /nearby cancels"
16483                    ));
16484                    Ok(())
16485                }
16486            }
16487        }
16488    }
16489
16490    /// Resolve `/whisper Name` against AOI players (label match).
16491    fn resolve_whisper_target(&self, name: &str) -> Result<(EntityId, String, bool), String> {
16492        let needle = name.trim().to_ascii_lowercase();
16493        if needle.is_empty() {
16494            return Err("Usage: /whisper Name [message]".into());
16495        }
16496        let mut candidates: Vec<(EntityId, String)> = self
16497            .state
16498            .entities
16499            .iter()
16500            .filter(|e| e.id != self.state.entity_id)
16501            .filter(|e| !e.label.trim().is_empty())
16502            .filter(|e| e.vitals.is_some())
16503            .filter(|e| !self.state.npcs.iter().any(|n| n.id == e.id.to_string()))
16504            .filter(|e| !self.state.hired_workers.iter().any(|w| w.entity_id == e.id))
16505            .map(|e| (e.id, e.label.clone()))
16506            .collect();
16507
16508        // Also allow matching the last whisper peer by name even if they left AOI briefly.
16509        if let Some(last) = &self.state.social_chat.last_whisper_peer {
16510            if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
16511                candidates.push((last.entity_id, last.label.clone()));
16512            }
16513        }
16514
16515        let exact: Vec<_> = candidates
16516            .iter()
16517            .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
16518            .cloned()
16519            .collect();
16520        let pool = if exact.len() == 1 {
16521            exact
16522        } else if exact.len() > 1 {
16523            return Err(format!(
16524                "Several players named '{name}' nearby — move closer and try again"
16525            ));
16526        } else {
16527            let starts: Vec<_> = candidates
16528                .iter()
16529                .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
16530                .cloned()
16531                .collect();
16532            if starts.len() == 1 {
16533                starts
16534            } else if starts.len() > 1 {
16535                let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
16536                return Err(format!(
16537                    "Ambiguous name '{name}' — matches: {}",
16538                    names.join(", ")
16539                ));
16540            } else {
16541                let contains: Vec<_> = candidates
16542                    .iter()
16543                    .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
16544                    .cloned()
16545                    .collect();
16546                if contains.len() == 1 {
16547                    contains
16548                } else if contains.is_empty() {
16549                    return Err(format!(
16550                        "No player matching '{name}' in range — get closer or check the spelling"
16551                    ));
16552                } else {
16553                    let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
16554                    return Err(format!(
16555                        "Ambiguous name '{name}' — matches: {}",
16556                        names.join(", ")
16557                    ));
16558                }
16559            }
16560        };
16561
16562        let (id, label) = pool.into_iter().next().unwrap();
16563        let stone = self
16564            .state
16565            .social_chat
16566            .last_whisper_peer
16567            .as_ref()
16568            .is_some_and(|p| {
16569                p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone
16570            });
16571        Ok((id, label, stone))
16572    }
16573
16574    pub async fn trade_present_selected(
16575        &mut self,
16576        item_instance_id: uuid::Uuid,
16577    ) -> anyhow::Result<()> {
16578        self.trade_present_quantity(item_instance_id, None).await
16579    }
16580
16581    pub async fn trade_present_quantity(
16582        &mut self,
16583        item_instance_id: uuid::Uuid,
16584        quantity: Option<u32>,
16585    ) -> anyhow::Result<()> {
16586        self.seq += 1;
16587        self.session
16588            .submit_intent(Intent::TradePresent {
16589                entity_id: self.state.entity_id,
16590                item_instance_id,
16591                quantity,
16592                seq: self.seq,
16593            })
16594            .await?;
16595        self.state.intents_sent += 1;
16596        self.state.trade_ui.qty_entry = None;
16597        self.state.trade_ui.picking_inventory = false;
16598        Ok(())
16599    }
16600
16601    /// Confirm the trade quantity prompt (or present whole stack when qty==1).
16602    pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
16603        if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
16604            let qty = self.state.trade_ui.present_quantity();
16605            return self
16606                .trade_present_quantity(entry.item_instance_id, qty)
16607                .await;
16608        }
16609        if !self.state.trade_ui.picking_inventory {
16610            return Ok(());
16611        }
16612        let stacks = self.state.trade_presentable_stacks();
16613        let Some(stack) = stacks.get(self.state.trade_ui.inventory_index).copied() else {
16614            return Ok(());
16615        };
16616        let Some(id) = stack.item_instance_id else {
16617            return Ok(());
16618        };
16619        let label = stack
16620            .display_name
16621            .clone()
16622            .unwrap_or_else(|| stack.template_id.clone());
16623        if stack.quantity <= 1 {
16624            self.trade_present_quantity(id, Some(1)).await
16625        } else {
16626            self.state
16627                .trade_ui
16628                .begin_qty_entry(id, label, stack.quantity);
16629            Ok(())
16630        }
16631    }
16632
16633    pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
16634        self.seq += 1;
16635        self.session
16636            .submit_intent(Intent::TradeSetReady {
16637                entity_id: self.state.entity_id,
16638                ready,
16639                seq: self.seq,
16640            })
16641            .await?;
16642        self.state.intents_sent += 1;
16643        Ok(())
16644    }
16645
16646    pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
16647        self.seq += 1;
16648        self.session
16649            .submit_intent(Intent::TradeCancel {
16650                entity_id: self.state.entity_id,
16651                seq: self.seq,
16652            })
16653            .await?;
16654        self.state.intents_sent += 1;
16655        self.state.trade_ui.close();
16656        Ok(())
16657    }
16658
16659    pub async fn destroy_whisper_stone(
16660        &mut self,
16661        item_instance_id: uuid::Uuid,
16662    ) -> anyhow::Result<()> {
16663        self.seq += 1;
16664        self.session
16665            .submit_intent(Intent::DestroyWhisperStone {
16666                entity_id: self.state.entity_id,
16667                item_instance_id,
16668                seq: self.seq,
16669            })
16670            .await?;
16671        self.state.intents_sent += 1;
16672        Ok(())
16673    }
16674
16675    pub async fn stop(&mut self) -> anyhow::Result<()> {
16676        self.seq += 1;
16677        self.session
16678            .submit_intent(Intent::Stop {
16679                entity_id: self.state.entity_id,
16680                seq: self.seq,
16681            })
16682            .await?;
16683        self.state.intents_sent += 1;
16684        Ok(())
16685    }
16686
16687    pub fn disconnect(&self) {
16688        self.session.disconnect();
16689    }
16690}
16691
16692fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
16693    let dx = ax - bx;
16694    let dy = ay - by;
16695    (dx * dx + dy * dy).sqrt()
16696}
16697
16698#[cfg(test)]
16699mod tests {
16700    use std::collections::BTreeMap;
16701
16702    use super::*;
16703    use flatland_protocol::{
16704        BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
16705    };
16706
16707    fn sample_state() -> GameState {
16708        let mut state = GameState {
16709            session_id: 1,
16710            entity_id: 1,
16711            character_id: None,
16712            tick: 0,
16713            chunk_rev: 0,
16714            content_rev: 0,
16715            publish_rev: 0,
16716            entities: vec![EntityState {
16717                id: 1,
16718                label: "You".into(),
16719                transform: Transform {
16720                    position: WorldCoord::surface(128.0, 128.0),
16721                    yaw: 0.0,
16722                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
16723                },
16724                vitals: None,
16725                attributes: None,
16726                skills: None,
16727                inside_building: None,
16728                tile_id: None,
16729                paperdoll_ref: None,
16730                draw_scale: 1.0,
16731                presentation_state: None,
16732                sprite_mode: None,
16733                progression_xp: None,
16734                combat_cues: vec![],
16735                statuses: vec![],
16736            }],
16737            player: None,
16738            resource_nodes: vec![ResourceNodeView {
16739                id: "oak-1".into(),
16740                label: "Oak".into(),
16741                x: 130.0,
16742                y: 128.0,
16743                z: 0.0,
16744                item_template: "oak_log".into(),
16745                state: ResourceNodeState::Available,
16746                blocking: true,
16747                blocking_radius_m: 0.8,
16748                harvest_off: false,
16749                tile_id: None,
16750                yaw: 0.0,
16751                pitch: 0.0,
16752                roll: 0.0,
16753                draw_scale: 1.0,
16754                sprite_mode: None,
16755                growth_progress: None,
16756                presentation_state: None,
16757                channel_start_tick: None,
16758                channel_end_tick: None,
16759                harvest_drop_templates: vec![],
16760            }],
16761            ground_drops: vec![],
16762            placed_containers: vec![],
16763            buildings: vec![BuildingView {
16764                id: "broker-hut".into(),
16765                label: "Broker".into(),
16766                x: 148.0,
16767                y: 118.0,
16768                width_m: 8.0,
16769                depth_m: 6.0,
16770                interior_blueprint: Some("broker_hut".into()),
16771                tags: vec![],
16772                market_boundary_zone_ids: vec![],
16773                market_max_volume: None,
16774                wall_set: None,
16775                roof_set: None,
16776            }],
16777            doors: vec![flatland_protocol::DoorView {
16778                id: "door-1".into(),
16779                building_id: "broker-hut".into(),
16780                x: 148.0,
16781                y: 118.0,
16782                open: false,
16783                portal: Some("front".into()),
16784                locked: false,
16785                accessible: true,
16786                lock_id: None,
16787            }],
16788            interior_map: None,
16789            npcs: vec![],
16790            blueprints: vec![],
16791            building_materials: vec![],
16792            world_x0: 0.0,
16793            world_y0: 0.0,
16794            world_width_m: 256.0,
16795            world_height_m: 256.0,
16796            terrain_zones: Vec::new(),
16797            z_platforms: Vec::new(),
16798            z_transitions: Vec::new(),
16799            z_bands_outdoor_backup: None,
16800            world_clock: flatland_protocol::WorldClock::default(),
16801            inventory: std::collections::HashMap::new(),
16802            inventory_hints: std::collections::HashMap::new(),
16803            item_catalog: std::collections::HashMap::new(),
16804            logs: VecDeque::new(),
16805            intents_sent: 0,
16806            ticks_received: 0,
16807            connected: true,
16808            disconnect_reason: None,
16809            show_stats: false,
16810            hud_log_hidden: false,
16811            show_equip_menu: false,
16812            equip_menu_index: 0,
16813            show_craft_menu: false,
16814            show_plot_build_menu: false,
16815            plot_build_focus_wall: true,
16816            plot_build_wall_index: 0,
16817            plot_build_roof_index: 0,
16818            craft_menu_index: 0,
16819            craft_batch_quantity: 1,
16820            craft_tab: CraftTab::Ready,
16821            craft_filter: String::new(),
16822            craft_filter_focused: false,
16823            craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
16824            show_shop_menu: false,
16825            shop_catalog: None,
16826            bank_panel: None,
16827            bank_menu_index: 0,
16828            bank_ui_mode: BankUiMode::Menu,
16829            storage_panel: None,
16830            market_panel: None,
16831            market_menu_index: 0,
16832            market_filter: String::new(),
16833            market_filter_focused: false,
16834            market_category_filter: None,
16835            market_buy_confirm: None,
16836            market_ui_mode: MarketUiMode::Browse,
16837            storage_menu_index: 0,
16838            storage_ui_mode: StorageUiMode::Menu,
16839            shop_tab: ShopTab::default(),
16840            shop_menu_index: 0,
16841            shop_quantity: 1,
16842            shop_trade_log: VecDeque::new(),
16843            show_npc_verb_menu: false,
16844            npc_verb_target: None,
16845            npc_verb_index: 0,
16846            npc_verb_notice: None,
16847            player_verbs: crate::social::PlayerVerbState::default(),
16848            social_chat: crate::social::SocialChatState::default(),
16849            trade_ui: crate::social::TradeUiState::default(),
16850            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
16851            show_npc_chat: false,
16852            npc_chat: None,
16853            show_inventory_menu: false,
16854            inventory_menu_index: 0,
16855            inventory_tab: InventoryTab::OnPerson,
16856            inventory_filter: String::new(),
16857            inventory_filter_focused: false,
16858            show_move_picker: false,
16859            show_rename_prompt: false,
16860            rename_plot_id: None,
16861            highlighted_plot_id: None,
16862            show_worker_rename: false,
16863            rename_buffer: String::new(),
16864            move_picker_index: 0,
16865            move_picker: None,
16866            show_grant_picker: false,
16867            grant_picker_index: 0,
16868            grant_picker: None,
16869            show_destroy_picker: false,
16870            destroy_confirm_pending: false,
16871            destroy_picker: None,
16872            combat_target: None,
16873            combat_target_label: None,
16874            ground_target: None,
16875            combat_fx: Vec::new(),
16876            ground_hazards: Vec::new(),
16877            property_zones: Vec::new(),
16878            tax_zones: Vec::new(),
16879            growth_zones: Vec::new(),
16880            biome_zones: Vec::new(),
16881            terrain_kind_nav: Vec::new(),
16882            property_plots: Vec::new(),
16883            property_plot_settings: None,
16884            claim_mode: None,
16885            relocate_mode: None,
16886            sell_plot_confirm: None,
16887            sell_plot_armed_at: None,
16888            show_plant_menu: false,
16889            plant_menu_index: 0,
16890            show_farm_access: false,
16891            farm_access_name_draft: String::new(),
16892            farm_access_discount_bps: 0,
16893            farm_access_index: 0,
16894            plant_quantity: 1,
16895            in_combat: false,
16896            auto_attack: true,
16897            combat_has_los: false,
16898            attack_cd_ticks: 0,
16899            gcd_ticks: 0,
16900            weapon_ability_id: "unarmed".into(),
16901            mainhand_template_id: None,
16902            mainhand_label: None,
16903            mainhand_instance_id: None,
16904            offhand_template_id: None,
16905            offhand_label: None,
16906            offhand_instance_id: None,
16907            mainhand_hand_slots: 1,
16908            defense: None,
16909            worn: BTreeMap::new(),
16910            carry_mass: 0.0,
16911            carry_mass_max: 0.0,
16912            encumbrance: flatland_protocol::EncumbranceState::Light,
16913            move_speed_mps: 0.0,
16914            move_speed_mult: 0.0,
16915            inventory_stacks: Vec::new(),
16916            keychain_stacks: Vec::new(),
16917            whisper_pouch_stacks: Vec::new(),
16918            combat_target_detail: None,
16919            statuses: Vec::new(),
16920            cast_progress: None,
16921            timed_channel: None,
16922            plot_build_offer: None,
16923            ability_cooldowns: Vec::new(),
16924            blocking_active: false,
16925            max_target_slots: 1,
16926            combat_slots: Vec::new(),
16927            rotation_presets: Vec::new(),
16928            known_abilities: Vec::new(),
16929            ability_meta: std::collections::HashMap::new(),
16930            ability_mastery: std::collections::HashMap::new(),
16931            hotbar: vec![None; 9],
16932            max_abilities_per_rotation: 0,
16933            show_loadout_menu: false,
16934            show_keychain_menu: false,
16935            keychain_menu_index: 0,
16936            show_rotation_editor: false,
16937            loadout_menu_index: 0,
16938            loadout_hotbar_slot: 1,
16939            loadout_ability_index: 0,
16940            loadout_focus_presets: false,
16941            rotation_editor: RotationEditorState::default(),
16942            harvest_in_progress: false,
16943            harvest_started_at: None,
16944            pending_craft_ack: None,
16945            craft_channel_blueprint_id: None,
16946            pending_worker_job_ack: None,
16947            attending_worker_instance_id: None,
16948            quest_log: Vec::new(),
16949            interactables: Vec::new(),
16950            ledger: None,
16951            career: None,
16952            character_sheet_tab: CharacterSheetTab::Character,
16953            ledger_period: LedgerPeriod::Day,
16954            show_quest_offer: false,
16955            pending_quest_offers: Vec::new(),
16956                quest_offer_index: 0,
16957            show_quest_menu: false,
16958            quest_menu_index: 0,
16959            quest_withdraw_confirm: false,
16960            hired_workers: Vec::new(),
16961            show_workers_menu: false,
16962            workers_menu_index: 0,
16963            worker_dismiss_confirmation: None,
16964            workers_menu_compact: false,
16965            worker_step_display: BTreeMap::new(),
16966            worker_error_display: BTreeMap::new(),
16967            worker_health_ring_until: BTreeMap::new(),
16968            pending_worker_hire_since: None,
16969            show_worker_give_picker: false,
16970            worker_give_picker_index: 0,
16971            worker_give_picker: None,
16972            show_worker_give_target_picker: false,
16973            worker_give_target_picker_index: 0,
16974            worker_give_target_picker: None,
16975            show_worker_take_picker: false,
16976            worker_take_picker_index: 0,
16977            worker_take_picker: None,
16978            show_worker_teach_picker: false,
16979            worker_teach_picker_index: 0,
16980            worker_teach_picker: None,
16981            worker_route_editor: None,
16982            progression_curve: None,
16983        };
16984        state.player = state.entities.first().cloned();
16985        state
16986    }
16987
16988    #[test]
16989    fn template_display_name_uses_item_catalog_for_uuid_ids() {
16990        let mut state = sample_state();
16991        let id = "7ac81ed5-e3d3-4b7f-a7fb-11d6c34a0995";
16992        assert_eq!(state.template_display_name(id), "Unknown item");
16993        state.item_catalog.insert(
16994            id.into(),
16995            ItemCatalogEntryView {
16996                template_id: id.into(),
16997                display_name: "Emerald".into(),
16998                category: "resource".into(),
16999                seed_for: None,
17000            },
17001        );
17002        assert_eq!(state.template_display_name(id), "Emerald");
17003    }
17004
17005    #[test]
17006    fn whisper_cancels_when_peer_walks_out_of_range() {
17007        let mut state = sample_state();
17008        state.player = state.entities.first().cloned();
17009        let mut peer = state.entities[0].clone();
17010        peer.id = 2;
17011        peer.label = "Ada".into();
17012        peer.transform.position = WorldCoord::surface(129.0, 128.0); // 1m — in range
17013        state.entities.push(peer.clone());
17014        state.social_chat.focus_whisper(2, "Ada");
17015        state.refresh_whisper_range();
17016        assert!(matches!(
17017            state.social_chat.thread,
17018            crate::social::ChatThreadKind::Whisper { peer: 2 }
17019        ));
17020
17021        peer.transform.position = WorldCoord::surface(132.0, 128.0); // 4m — out of range
17022        state.entities[1] = peer;
17023        state.refresh_whisper_range();
17024        assert_eq!(
17025            state.social_chat.thread,
17026            crate::social::ChatThreadKind::Nearby
17027        );
17028        assert!(!state.social_chat.input_focused);
17029    }
17030
17031    #[test]
17032    fn probe_use_world_hired_worker_manage() {
17033        let mut state = sample_state();
17034        state
17035            .hired_workers
17036            .push(flatland_protocol::HiredWorkerView {
17037                instance_id: "worker-1".into(),
17038                entity_id: 42,
17039                def_id: "worker_laborer".into(),
17040                label: "Sam".into(),
17041                x: 129.0,
17042                y: 128.0,
17043                z: 0.0,
17044                mode: flatland_protocol::WorkerModeView::JobLoop,
17045                state: flatland_protocol::WorkerStateView::Working,
17046                step_label: "cultivate".into(),
17047                vitals: flatland_protocol::WorkerVitalsSummary {
17048                    health_pct: 100.0,
17049                    stamina_pct: 100.0,
17050                    mana_pct: 100.0,
17051                    hunger_pct: 100.0,
17052                    thirst_pct: 100.0,
17053                },
17054                carry_pct: 0.0,
17055                last_error: None,
17056                wage_copper_per_interval: 1,
17057                effective_wage_copper: 1,
17058                wage_meters_walked: 0.0,
17059                lodging_container_id: None,
17060                route: None,
17061                route_stop_index: None,
17062                known_blueprint_ids: Vec::new(),
17063                level: 1,
17064                worker_xp: 0.0,
17065                inventory: Vec::new(),
17066                equipment: flatland_protocol::WorkerEquipmentView::default(),
17067                issue_hint: None,
17068            });
17069        let probe = state.probe_use_world();
17070        let primary = probe.primary.expect("primary");
17071        assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17072        assert_eq!(primary.id, "worker-1");
17073        assert!(primary.hint_line().contains("Manage"));
17074        assert!(primary.hint_line().contains("Sam"));
17075        assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17076    }
17077
17078    fn sample_hired_worker(x: f32, y: f32) -> flatland_protocol::HiredWorkerView {
17079        flatland_protocol::HiredWorkerView {
17080            instance_id: "worker-1".into(),
17081            entity_id: 42,
17082            def_id: "worker_laborer".into(),
17083            label: "Sam".into(),
17084            x,
17085            y,
17086            z: 0.0,
17087            mode: flatland_protocol::WorkerModeView::JobLoop,
17088            state: flatland_protocol::WorkerStateView::Working,
17089            step_label: "follow".into(),
17090            vitals: flatland_protocol::WorkerVitalsSummary {
17091                health_pct: 100.0,
17092                stamina_pct: 100.0,
17093                mana_pct: 100.0,
17094                hunger_pct: 100.0,
17095                thirst_pct: 100.0,
17096            },
17097            carry_pct: 0.0,
17098            last_error: None,
17099            wage_copper_per_interval: 1,
17100            effective_wage_copper: 1,
17101            wage_meters_walked: 0.0,
17102            lodging_container_id: None,
17103            route: None,
17104            route_stop_index: None,
17105            known_blueprint_ids: Vec::new(),
17106            level: 1,
17107            worker_xp: 0.0,
17108            inventory: Vec::new(),
17109            equipment: flatland_protocol::WorkerEquipmentView::default(),
17110            issue_hint: None,
17111        }
17112    }
17113
17114    #[test]
17115    fn probe_harvest_beats_closer_hired_worker() {
17116        let mut state = sample_state();
17117        state.resource_nodes[0].x = 129.0;
17118        state.resource_nodes[0].y = 128.0;
17119        state.hired_workers.push(sample_hired_worker(128.2, 128.0));
17120        let probe = state.probe_use_world();
17121        let primary = probe.primary.expect("primary");
17122        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
17123        assert_eq!(primary.id, "oak-1");
17124        assert!(state.harvestable_node_in_range());
17125        assert_eq!(
17126            state.nearest_interact_target().as_deref(),
17127            Some("worker-1"),
17128            "harvest is not Interact — worker remains the interact target"
17129        );
17130    }
17131
17132    #[test]
17133    fn probe_door_beats_closer_hired_worker() {
17134        let mut state = sample_state();
17135        state.doors[0].x = 129.2;
17136        state.doors[0].y = 128.0;
17137        state.hired_workers.push(sample_hired_worker(128.3, 128.0));
17138        let probe = state.probe_use_world();
17139        let primary = probe.primary.expect("primary");
17140        assert!(
17141            matches!(
17142                primary.kind,
17143                crate::UseWorldKind::EnterDoor
17144                    | crate::UseWorldKind::OpenDoor
17145                    | crate::UseWorldKind::CloseDoor
17146                    | crate::UseWorldKind::ExitDoor
17147            ),
17148            "door should win over closer worker, got {:?}",
17149            primary.kind
17150        );
17151        assert_eq!(primary.id, "door-1");
17152        assert_eq!(state.nearest_interact_target().as_deref(), Some("door-1"));
17153    }
17154
17155    #[test]
17156    fn probe_worker_when_no_resource_or_door_in_range() {
17157        let mut state = sample_state();
17158        // oak stays at 130,128 (2m, out of harvest); door stays at hut (~22m)
17159        state.hired_workers.push(sample_hired_worker(129.0, 128.0));
17160        let probe = state.probe_use_world();
17161        let primary = probe.primary.expect("primary");
17162        assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17163        assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17164        assert!(!state.harvestable_node_in_range());
17165    }
17166
17167    #[test]
17168    fn market_clerk_verb_options_include_market() {
17169        let mut state = sample_state();
17170        state.npcs.push(flatland_protocol::NpcView {
17171            id: "mira_market".into(),
17172            label: "Mira".into(),
17173            role: "market_clerk".into(),
17174            x: 129.0,
17175            y: 128.0,
17176            building_id: Some("town_market".into()),
17177            entity_id: None,
17178            life_state: None,
17179            hp_pct: None,
17180            can_trade: false,
17181            buy_templates: vec![],
17182            tile_id: None,
17183            behavior_state: None,
17184            presentation_state: None,
17185            sprite_mode: None,
17186            paperdoll_ref: None,
17187            draw_scale: 1.0,
17188            yaw: None,
17189            perception_fov_deg: None,
17190            perception_sight_m: None,
17191            perception_hear_m: None,
17192            quest_verbs: Vec::new(),
17193        });
17194        state.npc_verb_target = Some("mira_market".into());
17195        assert_eq!(
17196            state
17197                .npc_verb_options()
17198                .iter()
17199                .map(|v| v.label.as_str())
17200                .collect::<Vec<_>>(),
17201            vec!["Market", "Talk"]
17202        );
17203    }
17204
17205    #[test]
17206    fn butcher_verb_options_include_turn_in_for_give_item() {
17207        let mut state = sample_state();
17208        state.npcs.push(flatland_protocol::NpcView {
17209            id: "town_butcher_1".into(),
17210            label: "Brutus".into(),
17211            role: "butcher".into(),
17212            x: 129.0,
17213            y: 128.0,
17214            building_id: None,
17215            entity_id: None,
17216            life_state: None,
17217            hp_pct: None,
17218            can_trade: true,
17219            buy_templates: vec!["raw_venison".into()],
17220            tile_id: None,
17221            behavior_state: None,
17222            presentation_state: None,
17223            sprite_mode: None,
17224            paperdoll_ref: None,
17225            draw_scale: 1.0,
17226            yaw: None,
17227            perception_fov_deg: None,
17228            perception_sight_m: None,
17229            perception_hear_m: None,
17230            quest_verbs: Vec::new(),
17231        });
17232        state.quest_log.push(flatland_protocol::QuestLogEntry {
17233            quest_id: "deer_threat".into(),
17234            title: "Deer threat".into(),
17235            description: String::new(),
17236            status: flatland_protocol::QuestStatusView::Active,
17237            current_step_id: Some("deliver".into()),
17238            current_step_title: "Deliver venison".into(),
17239            current_step_index: 0,
17240            objectives: vec![flatland_protocol::QuestObjectiveProgress {
17241                label: "Give 3 Raw venison to Brutus".into(),
17242                current: 0,
17243                required: 3,
17244                done: false,
17245                kind: "give_item".into(),
17246                npc_ref: Some("town_butcher_1".into()),
17247                item_template: Some("raw_venison".into()),
17248                blueprint_id: None,
17249                building_id: None,
17250            }],
17251            current_step_reward: flatland_protocol::QuestRewardView::default(),
17252            completion_reward: flatland_protocol::QuestRewardView::default(),
17253            steps: Vec::new(),
17254            is_tracked: true,
17255            can_withdraw: true,
17256        });
17257        state.npc_verb_target = Some("town_butcher_1".into());
17258        assert_eq!(
17259            state
17260                .npc_verb_options()
17261                .iter()
17262                .map(|v| v.label.as_str())
17263                .collect::<Vec<_>>(),
17264            vec!["Turn in: Deer threat", "Talk", "Trade"]
17265        );
17266    }
17267
17268    #[test]
17269    fn ada_verb_options_include_quest_offer() {
17270        let mut state = sample_state();
17271        state.npcs.push(flatland_protocol::NpcView {
17272            id: "ada_broker".into(),
17273            label: "Ada".into(),
17274            role: "broker".into(),
17275            x: 129.0,
17276            y: 128.0,
17277            building_id: None,
17278            entity_id: None,
17279            life_state: None,
17280            hp_pct: None,
17281            can_trade: true,
17282            buy_templates: vec![],
17283            tile_id: None,
17284            behavior_state: None,
17285            presentation_state: None,
17286            sprite_mode: None,
17287            paperdoll_ref: Some("ada_broker".into()),
17288            draw_scale: 1.0,
17289            yaw: None,
17290            perception_fov_deg: None,
17291            perception_sight_m: None,
17292            perception_hear_m: None,
17293            quest_verbs: vec![flatland_protocol::NpcQuestVerb {
17294                quest_id: "ada_goblin_hunt".into(),
17295                label: "Ask about goblins".into(),
17296                kind: flatland_protocol::NpcQuestVerb::KIND_OFFER.into(),
17297            }],
17298        });
17299        state.npc_verb_target = Some("ada_broker".into());
17300        assert_eq!(
17301            state
17302                .npc_verb_options()
17303                .iter()
17304                .map(|v| v.label.as_str())
17305                .collect::<Vec<_>>(),
17306            vec!["Ask about goblins", "Talk", "Trade"]
17307        );
17308    }
17309
17310    #[test]
17311    fn market_list_excludes_currency_stacks() {
17312        let mut state = sample_state();
17313        state.inventory_stacks = vec![
17314            flatland_protocol::ItemStack {
17315                template_id: "copper_coin".into(),
17316                quantity: 50,
17317                item_instance_id: Some(uuid::Uuid::from_u128(10)),
17318                display_name: Some("Copper Coin".into()),
17319                ..Default::default()
17320            },
17321            flatland_protocol::ItemStack {
17322                template_id: "oak_log".into(),
17323                quantity: 2,
17324                item_instance_id: Some(uuid::Uuid::from_u128(11)),
17325                display_name: Some("Oak Log".into()),
17326                ..Default::default()
17327            },
17328            flatland_protocol::ItemStack {
17329                template_id: "whisper_stone".into(),
17330                quantity: 1,
17331                item_instance_id: Some(uuid::Uuid::from_u128(12)),
17332                display_name: Some("Whisper Stone".into()),
17333                category: Some("quest".into()),
17334                listable: Some(false),
17335                ..Default::default()
17336            },
17337        ];
17338        let opts = state.market_list_item_options(&MarketListSourceKind::Person);
17339        assert_eq!(opts.len(), 1);
17340        assert!(opts[0].label.contains("Oak"));
17341    }
17342
17343    #[test]
17344    fn market_browse_filters_by_category_and_search() {
17345        let mut state = sample_state();
17346        state.market_panel = Some(flatland_protocol::MarketPanel {
17347            npc_id: "mira_market".into(),
17348            npc_label: "Mira".into(),
17349            building_id: "town_market".into(),
17350            building_label: "Town Market".into(),
17351            used_volume: 0.0,
17352            max_volume: 100.0,
17353            listings: vec![
17354                flatland_protocol::MarketListingView {
17355                    listing_id: uuid::Uuid::from_u128(1),
17356                    seller_character_id: uuid::Uuid::from_u128(2),
17357                    seller_label: "Ada".into(),
17358                    hall_building_id: "town_market".into(),
17359                    hall_label: "Town Market".into(),
17360                    template_id: "oak_log".into(),
17361                    display_name: "Oak Log".into(),
17362                    category: "resource".into(),
17363                    quantity: 3,
17364                    unit_price_copper: 10,
17365                    line_total_copper: 30,
17366                    npc_price: false,
17367                    npc_dump_unit_copper: None,
17368                    mine: false,
17369                },
17370                flatland_protocol::MarketListingView {
17371                    listing_id: uuid::Uuid::from_u128(3),
17372                    seller_character_id: uuid::Uuid::from_u128(2),
17373                    seller_label: "Ada".into(),
17374                    hall_building_id: "town_market".into(),
17375                    hall_label: "Town Market".into(),
17376                    template_id: "short_sword".into(),
17377                    display_name: "Short Sword".into(),
17378                    category: "weapon".into(),
17379                    quantity: 1,
17380                    unit_price_copper: 100,
17381                    line_total_copper: 100,
17382                    npc_price: false,
17383                    npc_dump_unit_copper: None,
17384                    mine: false,
17385                },
17386            ],
17387            tax_bps: 0,
17388            tax_flat_copper: 0,
17389            list_vaults: vec![],
17390        });
17391        assert_eq!(state.market_filtered_listing_indices().len(), 2);
17392        state.market_category_filter = Some("Weapons");
17393        let weapons = state.market_filtered_listing_indices();
17394        assert_eq!(weapons.len(), 1);
17395        assert_eq!(
17396            state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
17397            "Short Sword"
17398        );
17399        state.market_category_filter = None;
17400        state.market_filter = "oak".into();
17401        let oak = state.market_filtered_listing_indices();
17402        assert_eq!(oak.len(), 1);
17403        assert_eq!(
17404            state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
17405            "Oak Log"
17406        );
17407    }
17408
17409    #[test]
17410    fn market_list_source_includes_person_and_vaults() {
17411        let mut state = sample_state();
17412        let item_id = uuid::Uuid::from_u128(1);
17413        state.inventory_stacks = vec![flatland_protocol::ItemStack {
17414            template_id: "oak_log".into(),
17415            quantity: 2,
17416            item_instance_id: Some(item_id),
17417            display_name: Some("Oak Log".into()),
17418            ..Default::default()
17419        }];
17420        state.market_panel = Some(flatland_protocol::MarketPanel {
17421            npc_id: "mira_market".into(),
17422            npc_label: "Mira".into(),
17423            building_id: "town_market".into(),
17424            building_label: "Town Market".into(),
17425            used_volume: 0.0,
17426            max_volume: 100.0,
17427            listings: vec![],
17428            tax_bps: 0,
17429            tax_flat_copper: 0,
17430            list_vaults: vec![flatland_protocol::MarketListVault {
17431                building_id: "town_storage".into(),
17432                building_label: "Town Storage".into(),
17433                contents: vec![flatland_protocol::ItemStack {
17434                    template_id: "lumber".into(),
17435                    quantity: 1,
17436                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
17437                    display_name: Some("Lumber".into()),
17438                    ..Default::default()
17439                }],
17440            }],
17441        });
17442        let sources = state.market_list_source_options();
17443        assert_eq!(sources.len(), 2);
17444        assert!(matches!(sources[0].0, MarketListSourceKind::Person));
17445        assert!(matches!(
17446            sources[1].0,
17447            MarketListSourceKind::TownStorage { .. }
17448        ));
17449        assert!(sources[1].1.contains("Town Storage"));
17450    }
17451
17452    #[test]
17453    fn npc_market_dump_estimate_from_town_storage_vault() {
17454        let mut state = sample_state();
17455        state.market_panel = Some(flatland_protocol::MarketPanel {
17456            npc_id: "mira_market".into(),
17457            npc_label: "Mira".into(),
17458            building_id: "town_market".into(),
17459            building_label: "Town Market".into(),
17460            used_volume: 0.0,
17461            max_volume: 100.0,
17462            listings: vec![],
17463            tax_bps: 0,
17464            tax_flat_copper: 0,
17465            list_vaults: vec![flatland_protocol::MarketListVault {
17466                building_id: "town_storage".into(),
17467                building_label: "Town Storage".into(),
17468                contents: vec![flatland_protocol::ItemStack {
17469                    template_id: "lumber".into(),
17470                    quantity: 3,
17471                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
17472                    display_name: Some("Lumber".into()),
17473                    base_value_copper: Some(20),
17474                    ..Default::default()
17475                }],
17476            }],
17477        });
17478        assert_eq!(
17479            state.npc_market_dump_unit_estimate("lumber"),
17480            Some(9),
17481            "vault stack base_value should enable NPC price estimate"
17482        );
17483    }
17484
17485    #[test]
17486    fn probe_use_world_npc_beats_nearby_loot() {
17487        let mut state = sample_state();
17488        state.npcs.push(flatland_protocol::NpcView {
17489            id: "ada".into(),
17490            label: "Ada".into(),
17491            role: "broker".into(),
17492            x: 129.0,
17493            y: 128.0,
17494            building_id: None,
17495            entity_id: None,
17496            life_state: None,
17497            hp_pct: None,
17498            can_trade: true,
17499            buy_templates: vec!["lumber".into()],
17500            tile_id: None,
17501            behavior_state: None,
17502            presentation_state: None,
17503            sprite_mode: None,
17504            paperdoll_ref: None,
17505            draw_scale: 1.0,
17506            yaw: None,
17507            perception_fov_deg: None,
17508            perception_sight_m: None,
17509            perception_hear_m: None,
17510            quest_verbs: Vec::new(),
17511        });
17512        state.ground_drops.push(flatland_protocol::GroundDropView {
17513            id: "d1".into(),
17514            template_id: "lumber".into(),
17515            quantity: 1,
17516            x: 128.5,
17517            y: 128.0,
17518            z: 0.0,
17519            tile_id: None,
17520            display_name: None,
17521            yaw: 0.0,
17522            pitch: 0.0,
17523            roll: 0.0,
17524            draw_scale: 1.0,
17525            item_instance_id: None,
17526            props: Default::default(),
17527            status_bindings: Vec::new(),
17528        });
17529        let probe = state.probe_use_world();
17530        let primary = probe.primary.expect("primary");
17531        assert_eq!(primary.kind, crate::UseWorldKind::Npc);
17532        assert_eq!(primary.id, "ada");
17533    }
17534
17535    #[test]
17536    fn probe_use_world_harvest_when_in_range() {
17537        let state = sample_state(); // oak at 130,128 — player 128,128 → dist 2 > 1.5
17538        let probe = state.probe_use_world();
17539        assert!(
17540            probe.primary.is_none(),
17541            "oak is 2m away, out of harvest range"
17542        );
17543        assert!(probe
17544            .candidates
17545            .iter()
17546            .any(|c| c.kind == crate::UseWorldKind::Harvest));
17547
17548        let mut state = sample_state();
17549        state.resource_nodes[0].x = 129.0;
17550        let probe = state.probe_use_world();
17551        let primary = probe.primary.expect("primary");
17552        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
17553    }
17554
17555    #[test]
17556    fn probe_use_world_door_uses_building_label() {
17557        let mut state = sample_state();
17558        state.doors[0].x = 129.0;
17559        state.doors[0].y = 128.0;
17560        let probe = state.probe_use_world();
17561        let primary = probe.primary.expect("primary");
17562        assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
17563        assert_eq!(primary.label, "Broker");
17564        assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
17565    }
17566
17567    #[test]
17568    fn empty_entity_tick_preserves_welcome_snapshot() {
17569        let mut state = sample_state();
17570        state.inventory.insert("carrot".into(), 3);
17571        let delta = TickDelta {
17572            tick: 1,
17573            entities: vec![],
17574            resource_nodes: vec![],
17575            ground_drops: vec![],
17576            placed_containers: vec![],
17577            buildings: vec![],
17578            doors: vec![],
17579            interior_map: None,
17580            npcs: vec![],
17581            inventory: vec![],
17582            blueprints: vec![],
17583            building_materials: vec![],
17584            world_clock: flatland_protocol::WorldClock::default(),
17585            combat: None,
17586            quest_log: vec![],
17587            hired_workers: Vec::new(),
17588            interactables: vec![],
17589            ledger: None,
17590            career: None,
17591            combat_fx: Vec::new(),
17592            ground_hazards: Vec::new(),
17593            property_plots: Vec::new(),
17594            terrain_overlays: Vec::new(),
17595        };
17596
17597        state.apply_tick_fields(&delta, 1);
17598
17599        assert_eq!(state.entities.len(), 1);
17600        assert!(state.player.is_some());
17601        assert_eq!(state.inventory.get("carrot"), Some(&3));
17602        assert_eq!(state.resource_nodes.len(), 1);
17603    }
17604
17605    #[test]
17606    fn tick_preserves_world_layers_when_delta_omits_them() {
17607        let mut state = sample_state();
17608        let delta = TickDelta {
17609            tick: 1,
17610            entities: state.entities.clone(),
17611            resource_nodes: vec![],
17612            ground_drops: vec![],
17613            placed_containers: vec![],
17614            buildings: vec![],
17615            doors: vec![],
17616            interior_map: None,
17617            npcs: vec![],
17618            inventory: vec![],
17619            blueprints: vec![],
17620            building_materials: vec![],
17621            world_clock: flatland_protocol::WorldClock::default(),
17622            combat: None,
17623            quest_log: vec![],
17624            hired_workers: Vec::new(),
17625            interactables: vec![],
17626            ledger: None,
17627            career: None,
17628            combat_fx: Vec::new(),
17629            ground_hazards: Vec::new(),
17630            property_plots: Vec::new(),
17631            terrain_overlays: Vec::new(),
17632        };
17633
17634        state.apply_tick_fields(&delta, 1);
17635
17636        assert_eq!(state.resource_nodes.len(), 1);
17637        assert_eq!(state.buildings.len(), 1);
17638        assert_eq!(state.doors.len(), 1);
17639    }
17640
17641    #[test]
17642    fn tick_updates_resource_nodes_when_server_sends_them() {
17643        let mut state = sample_state();
17644        let delta = TickDelta {
17645            tick: 1,
17646            entities: state.entities.clone(),
17647            resource_nodes: vec![ResourceNodeView {
17648                id: "oak-1".into(),
17649                label: "Oak".into(),
17650                x: 130.0,
17651                y: 128.0,
17652                z: 0.0,
17653                item_template: "oak_log".into(),
17654                state: ResourceNodeState::Cooldown,
17655                blocking: true,
17656                blocking_radius_m: 0.8,
17657                harvest_off: false,
17658                tile_id: None,
17659                yaw: 0.0,
17660                pitch: 0.0,
17661                roll: 0.0,
17662                draw_scale: 1.0,
17663                sprite_mode: None,
17664                growth_progress: None,
17665                presentation_state: None,
17666                channel_start_tick: None,
17667                channel_end_tick: None,
17668                harvest_drop_templates: vec![],
17669            }],
17670            buildings: vec![],
17671            doors: vec![],
17672            interior_map: None,
17673            npcs: vec![],
17674            inventory: vec![],
17675            blueprints: vec![],
17676            building_materials: vec![],
17677            world_clock: flatland_protocol::WorldClock::default(),
17678            ground_drops: vec![],
17679            placed_containers: vec![],
17680            combat: None,
17681            quest_log: vec![],
17682            hired_workers: Vec::new(),
17683            interactables: vec![],
17684            ledger: None,
17685            career: None,
17686            combat_fx: Vec::new(),
17687            ground_hazards: Vec::new(),
17688            property_plots: Vec::new(),
17689            terrain_overlays: Vec::new(),
17690        };
17691
17692        state.apply_tick_fields(&delta, 1);
17693
17694        assert!(matches!(
17695            state.resource_nodes[0].state,
17696            ResourceNodeState::Cooldown
17697        ));
17698    }
17699
17700    #[test]
17701    fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
17702        let mut state = GameState {
17703            session_id: 1,
17704            entity_id: 1,
17705            character_id: None,
17706            tick: 0,
17707            chunk_rev: 0,
17708            content_rev: 0,
17709            publish_rev: 0,
17710            entities: vec![EntityState {
17711                id: 1,
17712                label: "You".into(),
17713                transform: Transform {
17714                    position: WorldCoord::surface(4.5, 2.0),
17715                    yaw: 0.0,
17716                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
17717                },
17718                vitals: None,
17719                attributes: None,
17720                skills: None,
17721                inside_building: Some("broker_hut".into()),
17722                tile_id: None,
17723                paperdoll_ref: None,
17724                draw_scale: 1.0,
17725                presentation_state: None,
17726                sprite_mode: None,
17727                progression_xp: None,
17728                combat_cues: vec![],
17729                statuses: vec![],
17730            }],
17731            player: None,
17732            resource_nodes: vec![],
17733            ground_drops: vec![],
17734            placed_containers: vec![],
17735            buildings: vec![BuildingView {
17736                id: "broker_hut".into(),
17737                label: "Broker".into(),
17738                x: 158.0,
17739                y: 124.0,
17740                width_m: 8.0,
17741                depth_m: 6.0,
17742                interior_blueprint: Some("broker_hut".into()),
17743                tags: vec![],
17744                market_boundary_zone_ids: vec![],
17745                market_max_volume: None,
17746                wall_set: None,
17747                roof_set: None,
17748            }],
17749            doors: vec![flatland_protocol::DoorView {
17750                id: "broker_hut_exit".into(),
17751                building_id: "broker_hut".into(),
17752                x: 4.3,
17753                y: 0.9,
17754                open: true,
17755                portal: Some("front".into()),
17756                locked: false,
17757                accessible: true,
17758                lock_id: None,
17759            }],
17760            interior_map: None,
17761            npcs: vec![flatland_protocol::NpcView {
17762                id: "ada_broker".into(),
17763                label: "Ada".into(),
17764                x: 4.5,
17765                y: 2.0,
17766                building_id: Some("broker_hut".into()),
17767                role: "broker".into(),
17768                entity_id: None,
17769                life_state: None,
17770                hp_pct: None,
17771                can_trade: true,
17772                buy_templates: vec!["lumber".into()],
17773                tile_id: None,
17774                behavior_state: None,
17775                presentation_state: None,
17776                sprite_mode: None,
17777                paperdoll_ref: None,
17778                draw_scale: 1.0,
17779                yaw: None,
17780                perception_fov_deg: None,
17781                perception_sight_m: None,
17782                perception_hear_m: None,
17783                quest_verbs: Vec::new(),
17784            }],
17785            blueprints: vec![],
17786            building_materials: vec![],
17787            world_x0: 0.0,
17788            world_y0: 0.0,
17789            world_width_m: 256.0,
17790            world_height_m: 256.0,
17791            terrain_zones: Vec::new(),
17792            z_platforms: Vec::new(),
17793            z_transitions: Vec::new(),
17794            z_bands_outdoor_backup: None,
17795            world_clock: flatland_protocol::WorldClock::default(),
17796            inventory: std::collections::HashMap::new(),
17797            inventory_hints: std::collections::HashMap::new(),
17798            item_catalog: std::collections::HashMap::new(),
17799            logs: VecDeque::new(),
17800            intents_sent: 0,
17801            ticks_received: 0,
17802            connected: true,
17803            disconnect_reason: None,
17804            show_stats: false,
17805            hud_log_hidden: false,
17806            show_equip_menu: false,
17807            equip_menu_index: 0,
17808            show_craft_menu: false,
17809            show_plot_build_menu: false,
17810            plot_build_focus_wall: true,
17811            plot_build_wall_index: 0,
17812            plot_build_roof_index: 0,
17813            craft_menu_index: 0,
17814            craft_batch_quantity: 1,
17815            craft_tab: CraftTab::Ready,
17816            craft_filter: String::new(),
17817            craft_filter_focused: false,
17818            craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
17819            show_shop_menu: false,
17820            shop_catalog: None,
17821            bank_panel: None,
17822            bank_menu_index: 0,
17823            bank_ui_mode: BankUiMode::Menu,
17824            storage_panel: None,
17825            market_panel: None,
17826            market_menu_index: 0,
17827            market_filter: String::new(),
17828            market_filter_focused: false,
17829            market_category_filter: None,
17830            market_buy_confirm: None,
17831            market_ui_mode: MarketUiMode::Browse,
17832            storage_menu_index: 0,
17833            storage_ui_mode: StorageUiMode::Menu,
17834            shop_tab: ShopTab::default(),
17835            shop_menu_index: 0,
17836            shop_quantity: 1,
17837            shop_trade_log: VecDeque::new(),
17838            show_npc_verb_menu: false,
17839            npc_verb_target: None,
17840            npc_verb_index: 0,
17841            npc_verb_notice: None,
17842            player_verbs: crate::social::PlayerVerbState::default(),
17843            social_chat: crate::social::SocialChatState::default(),
17844            trade_ui: crate::social::TradeUiState::default(),
17845            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
17846            show_npc_chat: false,
17847            npc_chat: None,
17848            show_inventory_menu: false,
17849            inventory_menu_index: 0,
17850            inventory_tab: InventoryTab::OnPerson,
17851            inventory_filter: String::new(),
17852            inventory_filter_focused: false,
17853            show_move_picker: false,
17854            show_rename_prompt: false,
17855            rename_plot_id: None,
17856            highlighted_plot_id: None,
17857            show_worker_rename: false,
17858            rename_buffer: String::new(),
17859            move_picker_index: 0,
17860            move_picker: None,
17861            show_grant_picker: false,
17862            grant_picker_index: 0,
17863            grant_picker: None,
17864            show_destroy_picker: false,
17865            destroy_confirm_pending: false,
17866            destroy_picker: None,
17867            combat_target: None,
17868            combat_target_label: None,
17869            ground_target: None,
17870            combat_fx: Vec::new(),
17871            ground_hazards: Vec::new(),
17872            property_zones: Vec::new(),
17873            tax_zones: Vec::new(),
17874            growth_zones: Vec::new(),
17875            biome_zones: Vec::new(),
17876            terrain_kind_nav: Vec::new(),
17877            property_plots: Vec::new(),
17878            property_plot_settings: None,
17879            claim_mode: None,
17880            relocate_mode: None,
17881            sell_plot_confirm: None,
17882            sell_plot_armed_at: None,
17883            show_plant_menu: false,
17884            plant_menu_index: 0,
17885            show_farm_access: false,
17886            farm_access_name_draft: String::new(),
17887            farm_access_discount_bps: 0,
17888            farm_access_index: 0,
17889            plant_quantity: 1,
17890            in_combat: false,
17891            auto_attack: true,
17892            combat_has_los: false,
17893            attack_cd_ticks: 0,
17894            gcd_ticks: 0,
17895            weapon_ability_id: "unarmed".into(),
17896            mainhand_template_id: None,
17897            mainhand_label: None,
17898            mainhand_instance_id: None,
17899            offhand_template_id: None,
17900            offhand_label: None,
17901            offhand_instance_id: None,
17902            mainhand_hand_slots: 1,
17903            defense: None,
17904            worn: BTreeMap::new(),
17905            carry_mass: 0.0,
17906            carry_mass_max: 0.0,
17907            encumbrance: flatland_protocol::EncumbranceState::Light,
17908            move_speed_mps: 0.0,
17909            move_speed_mult: 0.0,
17910            inventory_stacks: Vec::new(),
17911            keychain_stacks: Vec::new(),
17912            whisper_pouch_stacks: Vec::new(),
17913            combat_target_detail: None,
17914            statuses: Vec::new(),
17915            cast_progress: None,
17916            timed_channel: None,
17917            plot_build_offer: None,
17918            ability_cooldowns: Vec::new(),
17919            blocking_active: false,
17920            max_target_slots: 1,
17921            combat_slots: Vec::new(),
17922            rotation_presets: Vec::new(),
17923            known_abilities: Vec::new(),
17924            ability_meta: std::collections::HashMap::new(),
17925            ability_mastery: std::collections::HashMap::new(),
17926            hotbar: vec![None; 9],
17927            max_abilities_per_rotation: 0,
17928            show_loadout_menu: false,
17929            show_keychain_menu: false,
17930            keychain_menu_index: 0,
17931            show_rotation_editor: false,
17932            loadout_menu_index: 0,
17933            loadout_hotbar_slot: 1,
17934            loadout_ability_index: 0,
17935            loadout_focus_presets: false,
17936            rotation_editor: RotationEditorState::default(),
17937            harvest_in_progress: false,
17938            harvest_started_at: None,
17939            pending_craft_ack: None,
17940            craft_channel_blueprint_id: None,
17941            pending_worker_job_ack: None,
17942            attending_worker_instance_id: None,
17943            quest_log: Vec::new(),
17944            interactables: Vec::new(),
17945            ledger: None,
17946            career: None,
17947            character_sheet_tab: CharacterSheetTab::Character,
17948            ledger_period: LedgerPeriod::Day,
17949            show_quest_offer: false,
17950            pending_quest_offers: Vec::new(),
17951                quest_offer_index: 0,
17952            show_quest_menu: false,
17953            quest_menu_index: 0,
17954            quest_withdraw_confirm: false,
17955            hired_workers: Vec::new(),
17956            show_workers_menu: false,
17957            workers_menu_index: 0,
17958            worker_dismiss_confirmation: None,
17959            workers_menu_compact: false,
17960            worker_step_display: BTreeMap::new(),
17961            worker_error_display: BTreeMap::new(),
17962            worker_health_ring_until: BTreeMap::new(),
17963            pending_worker_hire_since: None,
17964            show_worker_give_picker: false,
17965            worker_give_picker_index: 0,
17966            worker_give_picker: None,
17967            show_worker_give_target_picker: false,
17968            worker_give_target_picker_index: 0,
17969            worker_give_target_picker: None,
17970            show_worker_take_picker: false,
17971            worker_take_picker_index: 0,
17972            worker_take_picker: None,
17973            show_worker_teach_picker: false,
17974            worker_teach_picker_index: 0,
17975            worker_teach_picker: None,
17976            worker_route_editor: None,
17977            progression_curve: None,
17978        };
17979        state.player = state.entities.first().cloned();
17980        assert_eq!(
17981            state.nearest_interact_target().as_deref(),
17982            Some("ada_broker")
17983        );
17984    }
17985
17986    #[test]
17987    fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
17988        let mut state = sample_state();
17989        // Player is at (128, 128) per sample_state(). One chest just inside
17990        // CONTAINER_RANGE_M, one clearly beyond it.
17991        state.placed_containers = vec![
17992            flatland_protocol::PlacedContainerView {
17993                id: "near".into(),
17994                template_id: "wooden_chest_small".into(),
17995                display_name: "Wooden Chest".into(),
17996                x: 130.0,
17997                y: 128.0,
17998                z: 0.0,
17999                locked: true,
18000                accessible: true,
18001                owner_character_id: None,
18002                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
18003                lock_id: None,
18004                capacity_volume: None,
18005                item_instance_id: Some(uuid::Uuid::from_u128(1)),
18006                tile_id: None,
18007                worker_lodging_capacity: None,
18008                blocking: false,
18009                blocking_radius_m: 0.0,
18010                building_id: None,
18011            },
18012            flatland_protocol::PlacedContainerView {
18013                id: "far".into(),
18014                template_id: "wooden_chest_small".into(),
18015                display_name: "Distant Chest".into(),
18016                x: 128.0 + CONTAINER_RANGE_M + 5.0,
18017                y: 128.0,
18018                z: 0.0,
18019                locked: false,
18020                accessible: true,
18021                owner_character_id: None,
18022                contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
18023                lock_id: None,
18024                capacity_volume: None,
18025                item_instance_id: Some(uuid::Uuid::from_u128(2)),
18026                tile_id: None,
18027                worker_lodging_capacity: None,
18028                blocking: false,
18029                blocking_radius_m: 0.0,
18030                building_id: None,
18031            },
18032        ];
18033
18034        let nearby = state.nearby_containers();
18035        assert_eq!(
18036            nearby.len(),
18037            1,
18038            "far chest must not appear once out of range"
18039        );
18040        assert_eq!(nearby[0].view.id, "near");
18041        assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
18042        assert!(nearby[0].rows[0].is_chest_shell);
18043
18044        // The same chest, but locked and inaccessible (no key held), must hide
18045        // contents but still show the selectable chest shell row.
18046        state.placed_containers[0].accessible = false;
18047        let nearby = state.nearby_containers();
18048        assert_eq!(nearby.len(), 1);
18049        assert_eq!(nearby[0].rows.len(), 1);
18050        assert!(nearby[0].rows[0].is_chest_shell);
18051    }
18052
18053    #[test]
18054    fn chest_pickup_destinations_offer_person_and_worn_bag() {
18055        let mut state = sample_state();
18056        let back_id = uuid::Uuid::from_u128(42);
18057        state.worn.insert(
18058            BodySlot::Back,
18059            flatland_protocol::ItemStack {
18060                template_id: "travel_backpack".into(),
18061                quantity: 1,
18062                item_instance_id: Some(back_id),
18063                props: Default::default(),
18064                status_bindings: Vec::new(),
18065                contents: Vec::new(),
18066                display_name: Some("Travel Backpack".into()),
18067                category: Some("container".into()),
18068                base_mass: Some(2.5),
18069                base_volume: Some(12.0),
18070                capacity_volume: Some(80.0),
18071                stackable: Some(false),
18072                world_placeable: Some(false),
18073                worker_lodging_capacity: None,
18074                equip_slot: None,
18075                armor_physical: None,
18076                resists: vec![],
18077                hand_slots: None,
18078                listable: None,
18079                ..Default::default()
18080            },
18081        );
18082        let opts = state.chest_pickup_destinations("chest-1");
18083        assert!(matches!(
18084            opts.first().map(|o| &o.kind),
18085            Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
18086        ));
18087        assert!(opts.iter().any(|o| matches!(
18088            &o.kind,
18089            MoveOptionKind::PickupPlaced {
18090                nest_parent_instance_id: None,
18091                ..
18092            }
18093        )));
18094        assert!(opts.iter().any(|o| matches!(
18095            &o.kind,
18096            MoveOptionKind::PickupPlaced {
18097                nest_parent_instance_id: Some(id),
18098                ..
18099            } if *id == back_id
18100        )));
18101        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
18102    }
18103
18104    #[test]
18105    fn placed_container_public_label_hides_owner_custom_name() {
18106        let owner = uuid::Uuid::from_u128(99);
18107        let mut state = sample_state();
18108        state.character_id = Some(uuid::Uuid::from_u128(1));
18109        state.inventory_hints.insert(
18110            "wooden_chest_medium".into(),
18111            InventoryHint {
18112                display_name: "Medium Wooden Chest".into(),
18113                category: "container".into(),
18114                base_mass: None,
18115                base_volume: None,
18116                capacity_volume: None,
18117                stackable: false,
18118                listable: true,
18119                base_value_copper: None,
18120            },
18121        );
18122        let chest = flatland_protocol::PlacedContainerView {
18123            id: "c1".into(),
18124            template_id: "wooden_chest_medium".into(),
18125            display_name: "Barry's Loot #a3f2".into(),
18126            x: 128.0,
18127            y: 128.0,
18128            z: 0.0,
18129            locked: false,
18130            accessible: true,
18131            owner_character_id: Some(owner),
18132            contents: vec![],
18133            lock_id: None,
18134            capacity_volume: None,
18135            item_instance_id: None,
18136            tile_id: None,
18137            worker_lodging_capacity: None,
18138            blocking: false,
18139            blocking_radius_m: 0.0,
18140            building_id: None,
18141        };
18142        assert_eq!(
18143            state.placed_container_public_label(&chest),
18144            "Medium Wooden Chest"
18145        );
18146        state.character_id = Some(owner);
18147        assert_eq!(
18148            state.placed_container_public_label(&chest),
18149            "Barry's Loot #a3f2"
18150        );
18151    }
18152
18153    #[test]
18154    fn location_context_shows_crop_growth_percent_not_depleted() {
18155        let mut state = sample_state();
18156        state.player = state.entities.first().cloned();
18157        state.resource_nodes[0].label = "Carrot (growing)".into();
18158        state.resource_nodes[0].x = 128.2;
18159        state.resource_nodes[0].y = 128.0;
18160        state.resource_nodes[0].state = ResourceNodeState::Cooldown;
18161        state.resource_nodes[0].growth_progress = Some(0.47);
18162        let lines = state.location_context_lines();
18163        let line = lines
18164            .iter()
18165            .find(|l| l.text.contains("Carrot"))
18166            .map(|l| l.text.as_str())
18167            .unwrap_or("");
18168        assert!(
18169            line.contains("(growing, 47%)"),
18170            "expected growth percent, got: {line}"
18171        );
18172        assert!(
18173            !line.contains("depleted"),
18174            "growing crop should not show depleted: {line}"
18175        );
18176    }
18177
18178    #[test]
18179    fn resource_node_near_action_suffix_prefers_growth() {
18180        let node = ResourceNodeView {
18181            id: "crop".into(),
18182            label: "Wheat".into(),
18183            x: 0.0,
18184            y: 0.0,
18185            z: 0.0,
18186            item_template: "wheat".into(),
18187            state: ResourceNodeState::Cooldown,
18188            blocking: false,
18189            blocking_radius_m: 0.0,
18190            harvest_off: false,
18191            tile_id: None,
18192            yaw: 0.0,
18193            pitch: 0.0,
18194            roll: 0.0,
18195            draw_scale: 1.0,
18196            sprite_mode: None,
18197            growth_progress: Some(0.12),
18198            presentation_state: None,
18199            channel_start_tick: None,
18200            channel_end_tick: None,
18201            harvest_drop_templates: vec![],
18202        };
18203        assert_eq!(resource_node_near_action_suffix(&node), " (growing, 12%)");
18204    }
18205
18206    #[test]
18207    fn location_context_lists_nearby_resource_node() {
18208        let mut state = sample_state();
18209        state.player = state.entities.first().cloned();
18210        state.resource_nodes[0].x = 128.2;
18211        state.resource_nodes[0].y = 128.0;
18212        let lines = state.location_context_lines();
18213        assert!(
18214            lines
18215                .iter()
18216                .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
18217            "expected resource node in context: {:?}",
18218            lines
18219        );
18220    }
18221
18222    #[test]
18223    fn quest_board_usable_within_board_radius() {
18224        let mut state = sample_state();
18225        state.player = state.entities.first().cloned();
18226        state.interactables = vec![flatland_protocol::InteractableView {
18227            id: "board-1".into(),
18228            kind: "quest_board".into(),
18229            label: "Town Quest Board".into(),
18230            x: 130.5,
18231            y: 128.0,
18232            z: 0.0,
18233            board_id: Some("starter_town_board".into()),
18234        }];
18235        // ~2.5m away — outside the old 1.5m interact radius, inside the 3.0m board radius.
18236        assert_eq!(
18237            state.nearest_interact_target().as_deref(),
18238            Some("board-1"),
18239            "quest board should be selectable at ~2.5m"
18240        );
18241        let lines = state.location_context_lines();
18242        assert!(
18243            lines
18244                .iter()
18245                .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
18246            "HUD should advertise f when board is in range: {:?}",
18247            lines
18248        );
18249    }
18250
18251    #[test]
18252    fn quest_board_keeps_multiple_offers() {
18253        let mut state = sample_state();
18254        let offer = |id: &str, title: &str| flatland_protocol::QuestOffer {
18255            quest_id: id.into(),
18256            title: title.into(),
18257            description: format!("{title} desc"),
18258            step_count: 2,
18259        };
18260        state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18261        state.push_quest_offer(offer("daily_20695_1", "Town board: Deer threat"));
18262        state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18263        assert_eq!(state.pending_quest_offers.len(), 2);
18264        assert_eq!(
18265            state.selected_quest_offer().unwrap().quest_id,
18266            "ada_goblin_hunt"
18267        );
18268        state.move_quest_offer_selection(1);
18269        assert_eq!(
18270            state.selected_quest_offer().unwrap().quest_id,
18271            "daily_20695_1"
18272        );
18273        state.remove_quest_offer("daily_20695_1");
18274        assert_eq!(state.pending_quest_offers.len(), 1);
18275        assert!(state.show_quest_offer);
18276        state.remove_quest_offer("ada_goblin_hunt");
18277        assert!(!state.show_quest_offer);
18278        assert!(state.pending_quest_offers.is_empty());
18279    }
18280
18281    #[test]
18282    fn inventory_selectable_rows_excludes_equipped_shells_but_keeps_bag_contents() {
18283        let mut state = sample_state();
18284        state.worn.insert(
18285            BodySlot::Back,
18286            flatland_protocol::ItemStack {
18287                template_id: "travel_backpack".into(),
18288                quantity: 1,
18289                item_instance_id: Some(uuid::Uuid::from_u128(3)),
18290                props: Default::default(),
18291                status_bindings: Vec::new(),
18292                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
18293                display_name: None,
18294                category: None,
18295                base_mass: None,
18296                base_volume: None,
18297                capacity_volume: None,
18298                stackable: None,
18299                world_placeable: None,
18300                worker_lodging_capacity: None,
18301                equip_slot: None,
18302                armor_physical: None,
18303                resists: vec![],
18304                hand_slots: None,
18305                listable: None,
18306                ..Default::default()
18307            },
18308        );
18309        state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
18310        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18311            id: "chest-1".into(),
18312            template_id: "wooden_chest_small".into(),
18313            display_name: "Wooden Chest".into(),
18314            x: 129.0,
18315            y: 128.0,
18316            z: 0.0,
18317            locked: false,
18318            accessible: true,
18319            owner_character_id: None,
18320            contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
18321            lock_id: None,
18322            capacity_volume: None,
18323            item_instance_id: Some(uuid::Uuid::from_u128(4)),
18324            tile_id: None,
18325            worker_lodging_capacity: None,
18326            blocking: false,
18327            blocking_radius_m: 0.0,
18328            building_id: None,
18329        }];
18330
18331        state.inventory_tab = InventoryTab::OnPerson;
18332        let rows = state.inventory_selectable_rows();
18333        let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
18334        assert_eq!(
18335            sections,
18336            vec![
18337                InventorySection::Person, // iron_ore carried in backpack
18338                InventorySection::Person, // lumber
18339            ]
18340        );
18341        assert_eq!(rows[0].stack.template_id, "iron_ore");
18342        assert_eq!(rows[0].depth, 0);
18343        assert!(!rows[0].is_equip_shell);
18344        assert_eq!(rows[1].stack.template_id, "lumber");
18345
18346        let lines = state.inventory_browser_lines();
18347        assert!(lines.iter().any(|l| matches!(
18348            l,
18349            InventoryBrowserLine::Section(s) if s.contains("carried bags")
18350        )));
18351        assert!(lines.iter().any(|l| matches!(
18352            l,
18353            InventoryBrowserLine::Item { text, .. } if text.contains("iron_ore")
18354        )));
18355        assert!(!lines.iter().any(|l| matches!(
18356            l,
18357            InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
18358        )));
18359        assert!(!lines.iter().any(|l| matches!(
18360            l,
18361            InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
18362        )));
18363
18364        state.inventory_tab = InventoryTab::Nearby;
18365        let nearby_rows = state.inventory_selectable_rows();
18366        assert_eq!(nearby_rows.len(), 2);
18367        assert!(nearby_rows[0].is_chest_shell);
18368        assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
18369        let nearby_lines = state.inventory_browser_lines();
18370        assert!(nearby_lines.iter().any(|l| matches!(
18371            l,
18372            InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
18373        )));
18374    }
18375
18376    #[test]
18377    fn give_worker_notice_does_not_put_item_back_in_bag() {
18378        let mut state = sample_state();
18379        let id = uuid::Uuid::from_u128(42);
18380        let mut saw = flatland_protocol::ItemStack::simple("handsaw", 1);
18381        saw.item_instance_id = Some(id);
18382        saw.display_name = Some("Handsaw".into());
18383        state.sync_inventory_from_stacks(&[saw]);
18384        assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 1);
18385
18386        state.remove_carried_instance(id, None);
18387        assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 0);
18388        assert!(state.inventory_stacks.is_empty());
18389
18390        state.apply_interaction_notice(&flatland_protocol::InteractionNotice {
18391            target_id: "worker-1".into(),
18392            message: "Gave 1x Handsaw to Laborer".into(),
18393            coins_delta: 0,
18394            inventory_delta: vec![flatland_protocol::ItemStack::simple("handsaw", 1)],
18395        });
18396        assert_eq!(
18397            state.inventory.get("handsaw").copied().unwrap_or(0),
18398            0,
18399            "Gave notice must not restore the handed stack"
18400        );
18401    }
18402
18403    #[test]
18404    fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
18405        let mut state = sample_state();
18406        let back_id = uuid::Uuid::from_u128(5);
18407        state.worn.insert(
18408            BodySlot::Back,
18409            flatland_protocol::ItemStack {
18410                template_id: "travel_backpack".into(),
18411                quantity: 1,
18412                item_instance_id: Some(back_id),
18413                props: Default::default(),
18414                status_bindings: Vec::new(),
18415                contents: Vec::new(),
18416                display_name: None,
18417                category: Some("container".into()),
18418                base_mass: None,
18419                base_volume: None,
18420                capacity_volume: Some(80.0),
18421                stackable: None,
18422                world_placeable: None,
18423                worker_lodging_capacity: None,
18424                equip_slot: None,
18425                armor_physical: None,
18426                resists: vec![],
18427                hand_slots: None,
18428                listable: None,
18429                ..Default::default()
18430            },
18431        );
18432        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18433            id: "chest-1".into(),
18434            template_id: "wooden_chest_small".into(),
18435            display_name: "Wooden Chest".into(),
18436            x: 129.0,
18437            y: 128.0,
18438            z: 0.0,
18439            locked: false,
18440            accessible: true,
18441            owner_character_id: None,
18442            contents: Vec::new(),
18443            lock_id: None,
18444            capacity_volume: None,
18445            item_instance_id: Some(uuid::Uuid::from_u128(6)),
18446            tile_id: None,
18447            worker_lodging_capacity: None,
18448            blocking: false,
18449            blocking_radius_m: 0.0,
18450            building_id: None,
18451        }];
18452
18453        // Item currently sitting loose on the person (Root): backpack + nearby
18454        // chest should both be offered, plus Drop/Cancel, but not "Root" itself.
18455        let opts = state.move_destinations_for(
18456            &flatland_protocol::InventoryLocation::Root,
18457            None,
18458            None,
18459            "lumber",
18460        );
18461        assert!(!opts.iter().any(|o| matches!(
18462            &o.kind,
18463            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
18464        )));
18465        assert!(opts.iter().any(|o| matches!(
18466            &o.kind,
18467            MoveOptionKind::Move { location, parent_instance_id, .. }
18468                if *location == flatland_protocol::InventoryLocation::Worn {
18469                    slot: BodySlot::Back,
18470                } && *parent_instance_id == Some(back_id)
18471        )));
18472        assert!(opts.iter().any(|o| matches!(
18473            &o.kind,
18474            MoveOptionKind::Move { location, .. }
18475                if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
18476        )));
18477        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
18478        assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
18479
18480        // Item currently inside the worn backpack: the backpack itself must be
18481        // excluded from its own destination list (can't move an item into the
18482        // container it's already in).
18483        let from_backpack = flatland_protocol::InventoryLocation::Worn {
18484            slot: BodySlot::Back,
18485        };
18486        let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
18487        assert!(!opts.iter().any(|o| matches!(
18488            &o.kind,
18489            MoveOptionKind::Move { location, parent_instance_id, .. }
18490                if *location == from_backpack && *parent_instance_id == Some(back_id)
18491        )));
18492        assert!(opts.iter().any(|o| matches!(
18493            &o.kind,
18494            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
18495        )));
18496    }
18497
18498    #[test]
18499    fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
18500        let mut state = sample_state();
18501        // Insert out of display order — BTreeMap iteration must still yield the
18502        // canonical Head/Body/Arms/Legs/Feet/Back/Waist order regardless.
18503        state.worn.insert(
18504            BodySlot::Waist,
18505            flatland_protocol::ItemStack {
18506                template_id: "simple_belt".into(),
18507                quantity: 1,
18508                item_instance_id: Some(uuid::Uuid::from_u128(10)),
18509                props: Default::default(),
18510                status_bindings: Vec::new(),
18511                contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
18512                display_name: None,
18513                category: Some("container".into()),
18514                base_mass: None,
18515                base_volume: None,
18516                capacity_volume: None,
18517                stackable: None,
18518                world_placeable: None,
18519                worker_lodging_capacity: None,
18520                equip_slot: None,
18521                armor_physical: None,
18522                resists: vec![],
18523                hand_slots: None,
18524                listable: None,
18525                ..Default::default()
18526            },
18527        );
18528        state.worn.insert(
18529            BodySlot::Head,
18530            flatland_protocol::ItemStack {
18531                template_id: "cloth_cap".into(),
18532                quantity: 1,
18533                item_instance_id: Some(uuid::Uuid::from_u128(11)),
18534                props: Default::default(),
18535                status_bindings: Vec::new(),
18536                contents: Vec::new(),
18537                display_name: None,
18538                category: Some("armor".into()),
18539                base_mass: None,
18540                base_volume: None,
18541                capacity_volume: None,
18542                stackable: None,
18543                world_placeable: None,
18544                worker_lodging_capacity: None,
18545                equip_slot: None,
18546                armor_physical: None,
18547                resists: vec![],
18548                hand_slots: None,
18549                listable: None,
18550                ..Default::default()
18551            },
18552        );
18553        state.worn.insert(
18554            BodySlot::Back,
18555            flatland_protocol::ItemStack {
18556                template_id: "travel_backpack".into(),
18557                quantity: 1,
18558                item_instance_id: Some(uuid::Uuid::from_u128(12)),
18559                props: Default::default(),
18560                status_bindings: Vec::new(),
18561                contents: Vec::new(),
18562                display_name: None,
18563                category: Some("container".into()),
18564                base_mass: None,
18565                base_volume: None,
18566                capacity_volume: None,
18567                stackable: None,
18568                world_placeable: None,
18569                worker_lodging_capacity: None,
18570                equip_slot: None,
18571                armor_physical: None,
18572                resists: vec![],
18573                hand_slots: None,
18574                listable: None,
18575                ..Default::default()
18576            },
18577        );
18578
18579        let rows = state.worn_rows();
18580        // Head, then Back, then Waist (+ nested pouch) — enum declaration order.
18581        assert_eq!(rows.len(), 4);
18582        assert_eq!(rows[0].stack.template_id, "cloth_cap");
18583        assert!(rows[0].is_equip_shell);
18584        assert_eq!(rows[1].stack.template_id, "travel_backpack");
18585        assert!(rows[1].is_equip_shell);
18586        assert_eq!(rows[2].stack.template_id, "simple_belt");
18587        assert!(rows[2].is_equip_shell);
18588        assert_eq!(rows[3].stack.template_id, "leather_pouch");
18589        assert_eq!(rows[3].depth, 1);
18590        assert!(!rows[3].is_equip_shell);
18591    }
18592
18593    #[test]
18594    fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
18595        let mut state = sample_state();
18596        state.worn.insert(
18597            BodySlot::Waist,
18598            flatland_protocol::ItemStack {
18599                template_id: "simple_belt".into(),
18600                quantity: 1,
18601                item_instance_id: Some(uuid::Uuid::from_u128(20)),
18602                props: Default::default(),
18603                status_bindings: Vec::new(),
18604                contents: Vec::new(),
18605                display_name: Some("Simple Belt".into()),
18606                category: Some("container".into()),
18607                base_mass: None,
18608                base_volume: None,
18609                capacity_volume: None,
18610                stackable: None,
18611                world_placeable: None,
18612                worker_lodging_capacity: None,
18613                equip_slot: None,
18614                armor_physical: None,
18615                resists: vec![],
18616                hand_slots: None,
18617                listable: None,
18618                ..Default::default()
18619            },
18620        );
18621        state.worn.insert(
18622            BodySlot::Head,
18623            flatland_protocol::ItemStack {
18624                template_id: "cloth_cap".into(),
18625                quantity: 1,
18626                item_instance_id: Some(uuid::Uuid::from_u128(21)),
18627                props: Default::default(),
18628                status_bindings: Vec::new(),
18629                contents: Vec::new(),
18630                display_name: Some("Cloth Cap".into()),
18631                category: Some("armor".into()),
18632                base_mass: None,
18633                base_volume: None,
18634                capacity_volume: None,
18635                stackable: None,
18636                world_placeable: None,
18637                worker_lodging_capacity: None,
18638                equip_slot: None,
18639                armor_physical: None,
18640                resists: vec![],
18641                hand_slots: None,
18642                listable: None,
18643                ..Default::default()
18644            },
18645        );
18646
18647        let opts = state.move_destinations_for(
18648            &flatland_protocol::InventoryLocation::Root,
18649            None,
18650            None,
18651            "leather_pouch",
18652        );
18653        assert!(
18654            opts.iter().any(|o| matches!(
18655                &o.kind,
18656                MoveOptionKind::Move { location, .. }
18657                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18658            )),
18659            "belt loop must be offered when moving a pouch"
18660        );
18661        assert!(
18662            !opts.iter().any(|o| matches!(
18663                &o.kind,
18664                MoveOptionKind::Move { location, .. }
18665                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
18666            )),
18667            "armor slots can't hold other items and must not appear as move destinations"
18668        );
18669        let belt_opt = opts
18670            .iter()
18671            .find(|o| matches!(
18672                &o.kind,
18673                MoveOptionKind::Move { location, .. }
18674                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18675            ))
18676            .unwrap();
18677        assert!(belt_opt.label.contains("belt loop"));
18678
18679        let opts = state.move_destinations_for(
18680            &flatland_protocol::InventoryLocation::Root,
18681            None,
18682            None,
18683            "lumber",
18684        );
18685        assert!(
18686            !opts.iter().any(|o| o.label.contains("belt loop")),
18687            "loose materials must not target the belt shell — only nested pouches"
18688        );
18689    }
18690
18691    #[test]
18692    fn move_destinations_for_offers_dimensional_pouch_on_belt() {
18693        let mut state = sample_state();
18694        let belt_id = uuid::Uuid::from_u128(30);
18695        let pouch_id = uuid::Uuid::from_u128(31);
18696        state.worn.insert(
18697            BodySlot::Waist,
18698            flatland_protocol::ItemStack {
18699                template_id: "simple_belt".into(),
18700                quantity: 1,
18701                item_instance_id: Some(belt_id),
18702                props: Default::default(),
18703                status_bindings: Vec::new(),
18704                world_placeable: None,
18705                worker_lodging_capacity: None,
18706                equip_slot: None,
18707                armor_physical: None,
18708                resists: vec![],
18709                hand_slots: None,
18710                contents: vec![flatland_protocol::ItemStack {
18711                    template_id: "dimensional_pouch".into(),
18712                    quantity: 1,
18713                    item_instance_id: Some(pouch_id),
18714                    props: Default::default(),
18715                    status_bindings: Vec::new(),
18716                    contents: Vec::new(),
18717                    display_name: Some("Dimensional Pouch".into()),
18718                    category: Some("container".into()),
18719                    base_mass: None,
18720                    base_volume: None,
18721                    capacity_volume: Some(200.0),
18722                    stackable: None,
18723                    world_placeable: None,
18724                    worker_lodging_capacity: None,
18725                    equip_slot: None,
18726                    armor_physical: None,
18727                    resists: vec![],
18728                    hand_slots: None,
18729                    listable: None,
18730                    ..Default::default()
18731                }],
18732                display_name: Some("Simple Belt".into()),
18733                category: Some("container".into()),
18734                base_mass: None,
18735                base_volume: None,
18736                capacity_volume: None,
18737                stackable: None,
18738                listable: None,
18739                ..Default::default()
18740            },
18741        );
18742
18743        let opts = state.move_destinations_for(
18744            &flatland_protocol::InventoryLocation::Root,
18745            None,
18746            None,
18747            "iron_ore",
18748        );
18749        assert!(
18750            opts.iter().any(|o| matches!(
18751                &o.kind,
18752                MoveOptionKind::Move {
18753                    location,
18754                    parent_instance_id,
18755                    ..
18756                } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18757                    && *parent_instance_id == Some(pouch_id)
18758            )),
18759            "dimensional pouch clipped on belt must accept loose items"
18760        );
18761        assert!(
18762            opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
18763            "destination label should name the pouch"
18764        );
18765    }
18766
18767    #[test]
18768    fn container_volume_label_on_placed_chest_shell() {
18769        let mut state = sample_state();
18770        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18771            id: "chest-1".into(),
18772            template_id: "wooden_chest_small".into(),
18773            display_name: "Camp Chest".into(),
18774            x: 129.0,
18775            y: 128.0,
18776            z: 0.0,
18777            locked: false,
18778            accessible: true,
18779            owner_character_id: None,
18780            contents: vec![flatland_protocol::ItemStack {
18781                template_id: "iron_ore".into(),
18782                quantity: 2,
18783                item_instance_id: None,
18784                props: Default::default(),
18785                status_bindings: Vec::new(),
18786                contents: Vec::new(),
18787                display_name: None,
18788                category: None,
18789                base_mass: None,
18790                base_volume: Some(2.0),
18791                capacity_volume: None,
18792                stackable: None,
18793                world_placeable: None,
18794                worker_lodging_capacity: None,
18795                equip_slot: None,
18796                armor_physical: None,
18797                resists: vec![],
18798                hand_slots: None,
18799                listable: None,
18800                ..Default::default()
18801            }],
18802            lock_id: None,
18803            capacity_volume: Some(60.0),
18804            item_instance_id: Some(uuid::Uuid::from_u128(4)),
18805            tile_id: None,
18806            worker_lodging_capacity: None,
18807            blocking: false,
18808            blocking_radius_m: 0.0,
18809            building_id: None,
18810        }];
18811        let nearby = state.nearby_containers();
18812        let label = state.container_volume_label(&nearby[0].rows[0]);
18813        assert!(
18814            label.contains("vol 4/60"),
18815            "expected used/cap in label, got {label}"
18816        );
18817        assert!(
18818            label.contains("56 free"),
18819            "expected free space, got {label}"
18820        );
18821    }
18822
18823    #[test]
18824    fn key_pair_chest_label_from_placed_lock_id() {
18825        let mut state = sample_state();
18826        let owner = uuid::Uuid::from_u128(77);
18827        state.character_id = Some(owner);
18828        let lock = uuid::Uuid::from_u128(99).to_string();
18829        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18830            id: "chest-1".into(),
18831            template_id: "wooden_chest_small".into(),
18832            display_name: "Barry's Loot #a3f2".into(),
18833            x: 129.0,
18834            y: 128.0,
18835            z: 0.0,
18836            locked: true,
18837            accessible: true,
18838            owner_character_id: Some(owner),
18839            contents: Vec::new(),
18840            lock_id: Some(lock.clone()),
18841            capacity_volume: None,
18842            item_instance_id: Some(uuid::Uuid::from_u128(4)),
18843            tile_id: None,
18844            worker_lodging_capacity: None,
18845            blocking: false,
18846            blocking_radius_m: 0.0,
18847            building_id: None,
18848        }];
18849        let key_id = uuid::Uuid::from_u128(5);
18850        let key = flatland_protocol::ItemStack {
18851            template_id: KEY_TEMPLATE.into(),
18852            quantity: 1,
18853            item_instance_id: Some(key_id),
18854            props: BTreeMap::from([
18855                (PROP_OPENS_LOCK_ID.into(), lock),
18856                (
18857                    PROP_OPENS_CONTAINER_NAME.into(),
18858                    "Barry's Loot #a3f2".into(),
18859                ),
18860            ]),
18861            status_bindings: Vec::new(),
18862            contents: Vec::new(),
18863            display_name: Some("Container Key".into()),
18864            category: Some("key".into()),
18865            base_mass: None,
18866            base_volume: None,
18867            capacity_volume: None,
18868            stackable: None,
18869            world_placeable: None,
18870            worker_lodging_capacity: None,
18871            equip_slot: None,
18872            armor_physical: None,
18873            resists: vec![],
18874            hand_slots: None,
18875            listable: None,
18876            ..Default::default()
18877        };
18878        state.inventory_stacks = vec![key.clone()];
18879        assert_eq!(
18880            state.key_pair_chest_label(&key).as_deref(),
18881            Some("Barry's Loot #a3f2")
18882        );
18883        assert!(state.key_drop_blocked(&key));
18884    }
18885
18886    #[test]
18887    fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
18888        let mut state = sample_state();
18889        let lock = uuid::Uuid::from_u128(101).to_string();
18890        let key = flatland_protocol::ItemStack {
18891            template_id: KEY_TEMPLATE.into(),
18892            quantity: 1,
18893            item_instance_id: Some(uuid::Uuid::from_u128(7)),
18894            props: BTreeMap::from([
18895                (PROP_OPENS_LOCK_ID.into(), lock),
18896                (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
18897            ]),
18898            status_bindings: Vec::new(),
18899            contents: Vec::new(),
18900            display_name: None,
18901            category: Some("key".into()),
18902            base_mass: None,
18903            base_volume: None,
18904            capacity_volume: None,
18905            stackable: None,
18906            world_placeable: None,
18907            worker_lodging_capacity: None,
18908            equip_slot: None,
18909            armor_physical: None,
18910            resists: vec![],
18911            hand_slots: None,
18912            listable: None,
18913            ..Default::default()
18914        };
18915        state.placed_containers.clear();
18916        assert_eq!(
18917            state.key_pair_chest_label(&key).as_deref(),
18918            Some("Camp Stash")
18919        );
18920    }
18921
18922    #[test]
18923    fn key_drop_allowed_when_paired_chest_unlocked() {
18924        let mut state = sample_state();
18925        let lock = uuid::Uuid::from_u128(100).to_string();
18926        let key_id = uuid::Uuid::from_u128(6);
18927        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18928            id: "chest-1".into(),
18929            template_id: "wooden_chest_small".into(),
18930            display_name: "Camp Chest".into(),
18931            x: 129.0,
18932            y: 128.0,
18933            z: 0.0,
18934            locked: false,
18935            accessible: true,
18936            owner_character_id: None,
18937            contents: Vec::new(),
18938            lock_id: Some(lock.clone()),
18939            capacity_volume: None,
18940            item_instance_id: None,
18941            tile_id: None,
18942            worker_lodging_capacity: None,
18943            blocking: false,
18944            blocking_radius_m: 0.0,
18945            building_id: None,
18946        }];
18947        let key = flatland_protocol::ItemStack {
18948            template_id: KEY_TEMPLATE.into(),
18949            quantity: 1,
18950            item_instance_id: Some(key_id),
18951            props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
18952            status_bindings: Vec::new(),
18953            contents: Vec::new(),
18954            display_name: None,
18955            category: Some("key".into()),
18956            base_mass: None,
18957            base_volume: None,
18958            capacity_volume: None,
18959            stackable: None,
18960            world_placeable: None,
18961            worker_lodging_capacity: None,
18962            equip_slot: None,
18963            armor_physical: None,
18964            resists: vec![],
18965            hand_slots: None,
18966            listable: None,
18967            ..Default::default()
18968        };
18969        state.inventory_stacks = vec![key.clone()];
18970        assert!(!state.key_drop_blocked(&key));
18971        let opts = state.move_destinations_for(
18972            &flatland_protocol::InventoryLocation::Root,
18973            None,
18974            Some(key_id),
18975            KEY_TEMPLATE,
18976        );
18977        assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
18978    }
18979
18980    #[test]
18981    fn combat_hud_refreshes_progression_xp_when_entity_stale() {
18982        use flatland_protocol::{CombatHud, ProgressionCurve, ProgressionXp};
18983
18984        let mut state = sample_state();
18985        let curve = ProgressionCurve::default();
18986        let bootstrap =
18987            ProgressionXp::bootstrap_new(curve.baseline_display, curve.xp_base, curve.xp_growth);
18988        let mut fresh = bootstrap.clone();
18989        fresh.strength += 0.08;
18990        if let Some(player) = state.player.as_mut() {
18991            player.progression_xp = Some(bootstrap);
18992        }
18993
18994        let combat = CombatHud {
18995            progression_xp: Some(fresh.clone()),
18996            progression_baseline: curve.baseline_display,
18997            progression_xp_base: curve.xp_base,
18998            progression_xp_growth: curve.xp_growth,
18999            attributes: state.player.as_ref().and_then(|p| p.attributes),
19000            skills: state.player.as_ref().and_then(|p| p.skills.clone()),
19001            ..CombatHud::default()
19002        };
19003        state.apply_combat_hud(&combat);
19004
19005        let xp = state
19006            .player
19007            .as_ref()
19008            .and_then(|p| p.progression_xp.as_ref())
19009            .expect("xp");
19010        assert!((xp.strength - fresh.strength).abs() < 0.001);
19011        assert!(state.progression_curve.is_some());
19012    }
19013
19014    #[test]
19015    fn combat_hud_syncs_known_abilities_and_hotbar() {
19016        use flatland_protocol::CombatHud;
19017
19018        let mut state = sample_state();
19019        let combat = CombatHud {
19020            known_abilities: vec!["unarmed".into(), "fireball".into()],
19021            hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
19022            max_abilities_per_rotation: 4,
19023            ability_id: "short_sword_slash".into(),
19024            ..CombatHud::default()
19025        };
19026        state.apply_combat_hud(&combat);
19027
19028        assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
19029        assert_eq!(state.hotbar_ability(1), Some("fireball"));
19030        assert_eq!(state.hotbar_ability(2), None);
19031        assert_eq!(state.hotbar_ability(3), Some("unarmed"));
19032        assert_eq!(state.max_abilities_per_rotation, 4);
19033        let choices = state.loadout_ability_choices();
19034        assert!(choices.iter().any(|a| a == "short_sword_slash"));
19035        assert!(choices.iter().any(|a| a == "fireball"));
19036    }
19037
19038    #[test]
19039    fn loadout_hotbar_choices_include_inventory_consumables() {
19040        let mut state = sample_state();
19041        state.known_abilities = vec!["unarmed".into()];
19042        state.weapon_ability_id = "unarmed".into();
19043        state.inventory_stacks = vec![flatland_protocol::ItemStack {
19044            template_id: "empty_bottle".into(),
19045            quantity: 1,
19046            item_instance_id: Some(uuid::Uuid::from_u128(9)),
19047            display_name: Some("Glass Bottle of Water".into()),
19048            category: Some("container".into()),
19049            props: [
19050                ("serving".into(), "1".into()),
19051                ("liquid_vessel".into(), "1".into()),
19052                ("serving_holds".into(), "liquid".into()),
19053            ]
19054            .into_iter()
19055            .collect(),
19056            ..Default::default()
19057        }];
19058        state.inventory.insert("empty_bottle".into(), 1);
19059        state.inventory_hints.insert(
19060            "empty_bottle".into(),
19061            InventoryHint {
19062                display_name: "Glass Bottle".into(),
19063                category: "container".into(),
19064                ..Default::default()
19065            },
19066        );
19067
19068        let choices = state.loadout_hotbar_choices();
19069        assert!(choices.iter().any(|c| c.binding == "unarmed"));
19070        let water = choices
19071            .iter()
19072            .find(|c| c.binding == "item:empty_bottle")
19073            .expect("serving bottle binding");
19074        assert_eq!(water.meta.as_deref(), Some("use"));
19075        assert!(water.label.contains("Glass Bottle of Water"));
19076        assert_eq!(state.hotbar_slot_label(1), None, "unbound until set");
19077        state.hotbar = vec![None, None, None, None, Some("item:empty_bottle".into())];
19078        assert_eq!(
19079            state.hotbar_slot_label(5).as_deref(),
19080            Some("Glass Bottle×1")
19081        );
19082    }
19083
19084    #[test]
19085    fn loadout_hotbar_choices_exclude_blueprint_scrolls() {
19086        let mut state = sample_state();
19087        state.known_abilities = vec!["unarmed".into()];
19088        state.weapon_ability_id = "unarmed".into();
19089        state.inventory_stacks = vec![
19090            flatland_protocol::ItemStack {
19091                template_id: "carrot".into(),
19092                quantity: 2,
19093                display_name: Some("Wild Carrot".into()),
19094                category: Some("consumable".into()),
19095                ..Default::default()
19096            },
19097            flatland_protocol::ItemStack {
19098                template_id: "blueprint_dimensional_pouch".into(),
19099                quantity: 1,
19100                display_name: Some("Blueprint — Dimensional Pouch".into()),
19101                category: Some("consumable".into()),
19102                props: [("teaches_blueprint".into(), "craft_dimensional_pouch".into())]
19103                    .into_iter()
19104                    .collect(),
19105                ..Default::default()
19106            },
19107        ];
19108        state.inventory.insert("carrot".into(), 2);
19109        state.inventory.insert("blueprint_dimensional_pouch".into(), 1);
19110        state.inventory_hints.insert(
19111            "carrot".into(),
19112            InventoryHint {
19113                display_name: "Wild Carrot".into(),
19114                category: "consumable".into(),
19115                ..Default::default()
19116            },
19117        );
19118        state.inventory_hints.insert(
19119            "blueprint_dimensional_pouch".into(),
19120            InventoryHint {
19121                display_name: "Blueprint — Dimensional Pouch".into(),
19122                category: "consumable".into(),
19123                ..Default::default()
19124            },
19125        );
19126
19127        let choices = state.loadout_hotbar_choices();
19128        assert!(choices.iter().any(|c| c.binding == "item:carrot"));
19129        assert!(
19130            choices
19131                .iter()
19132                .all(|c| c.binding != "item:blueprint_dimensional_pouch"),
19133            "recipe scrolls must not appear on the hotbar picker: {choices:?}"
19134        );
19135    }
19136
19137    #[test]
19138    fn storage_store_options_excludes_hand_equipped() {
19139        let mut state = sample_state();
19140        let sword_id = uuid::Uuid::from_u128(11);
19141        let ore_id = uuid::Uuid::from_u128(22);
19142        state.inventory_stacks = vec![
19143            flatland_protocol::ItemStack {
19144                template_id: "short_sword".into(),
19145                quantity: 1,
19146                item_instance_id: Some(sword_id),
19147                display_name: Some("Short Sword".into()),
19148                category: Some("weapon".into()),
19149                ..Default::default()
19150            },
19151            flatland_protocol::ItemStack {
19152                template_id: "iron_ore".into(),
19153                quantity: 5,
19154                item_instance_id: Some(ore_id),
19155                display_name: Some("Iron Ore".into()),
19156                category: Some("resource".into()),
19157                ..Default::default()
19158            },
19159        ];
19160        state.mainhand_template_id = Some("short_sword".into());
19161        state.mainhand_instance_id = Some(sword_id);
19162
19163        let opts = state.storage_store_options();
19164        assert_eq!(opts.len(), 1);
19165        assert_eq!(opts[0].item_instance_id, ore_id);
19166        assert!(state.hand_equipped_instance_ids().contains(&sword_id));
19167    }
19168
19169    #[test]
19170    fn loose_consumable_move_picker_offers_use_and_storage() {
19171        let mut state = sample_state();
19172        let inst = uuid::Uuid::from_u128(77);
19173        state.inventory_stacks = vec![flatland_protocol::ItemStack {
19174            template_id: "carrot".into(),
19175            quantity: 2,
19176            item_instance_id: Some(inst),
19177            props: Default::default(),
19178            status_bindings: Vec::new(),
19179            contents: Vec::new(),
19180            display_name: Some("Wild Carrot".into()),
19181            category: Some("consumable".into()),
19182            base_mass: None,
19183            base_volume: None,
19184            capacity_volume: None,
19185            stackable: Some(true),
19186            world_placeable: None,
19187            worker_lodging_capacity: None,
19188            equip_slot: None,
19189            armor_physical: None,
19190            resists: vec![],
19191            hand_slots: None,
19192            listable: None,
19193            ..Default::default()
19194        }];
19195        state.inventory_hints.insert(
19196            "carrot".into(),
19197            InventoryHint {
19198                display_name: "Wild Carrot".into(),
19199                category: "consumable".into(),
19200                base_mass: Some(0.15),
19201                base_volume: Some(0.3),
19202                capacity_volume: None,
19203                stackable: true,
19204                listable: true,
19205                base_value_copper: None,
19206            },
19207        );
19208        state.show_inventory_menu = true;
19209        state.inventory_menu_index = 0;
19210
19211        let row = state.inventory_selected_row().expect("carrot row");
19212        let mut options = state.move_destinations_for(
19213            &row.from,
19214            row.from_parent_instance_id,
19215            row.stack.item_instance_id,
19216            &row.stack.template_id,
19217        );
19218        if row.from == flatland_protocol::InventoryLocation::Root
19219            && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
19220        {
19221            options.insert(
19222                0,
19223                MoveOption {
19224                    label: "Use (eat / drink)".into(),
19225                    kind: MoveOptionKind::Use,
19226                },
19227            );
19228        }
19229
19230        assert_eq!(
19231            options.first().map(|o| &o.label),
19232            Some(&"Use (eat / drink)".into())
19233        );
19234        assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
19235        assert!(options
19236            .iter()
19237            .any(|o| matches!(o.kind, MoveOptionKind::Drop)));
19238    }
19239
19240    #[test]
19241    fn inventory_category_group_order_is_stable() {
19242        assert_eq!(inventory_category_group("weapon").0, "Weapons");
19243        assert_eq!(inventory_category_group("armor").0, "Armor");
19244        assert_eq!(inventory_category_group("consumable").0, "Consumables");
19245        assert_eq!(inventory_category_group("liquid").0, "Consumables");
19246        assert_eq!(inventory_category_group("resource").0, "Resources");
19247        assert_eq!(inventory_category_group("container").0, "Containers");
19248        assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
19249        assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
19250    }
19251
19252    #[test]
19253    fn page_list_index_clamps_without_wrap() {
19254        assert_eq!(page_list_index(0, -1, 25), 0);
19255        assert_eq!(page_list_index(0, 1, 25), 10);
19256        assert_eq!(page_list_index(12, 1, 25), 22);
19257        assert_eq!(page_list_index(22, 1, 25), 24);
19258        assert_eq!(page_list_index(5, 1, 0), 0);
19259        assert_eq!(page_list_index(3, -1, 8), 0);
19260    }
19261
19262    #[test]
19263    fn inventory_filter_hides_non_matching_person_items() {
19264        let mut state = sample_state();
19265        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
19266        sword.display_name = Some("Iron Sword".into());
19267        sword.category = Some("weapon".into());
19268        let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
19269        herb.display_name = Some("Wild Herb".into());
19270        herb.category = Some("consumable".into());
19271        state.inventory_stacks = vec![sword, herb];
19272        state.inventory_tab = InventoryTab::OnPerson;
19273        state.inventory_filter = "sword".into();
19274
19275        let rows = state.inventory_selectable_rows();
19276        assert_eq!(rows.len(), 1);
19277        assert_eq!(rows[0].stack.template_id, "iron_sword");
19278
19279        let lines = state.inventory_browser_lines();
19280        assert!(lines.iter().any(|l| matches!(
19281            l,
19282            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
19283        )));
19284        assert!(!lines.iter().any(|l| matches!(
19285            l,
19286            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
19287        )));
19288    }
19289
19290    #[test]
19291    fn list_filter_chars_reject_mac_arrow_glyphs() {
19292        assert!(is_list_filter_char('a'));
19293        assert!(is_list_filter_char(' '));
19294        assert!(is_list_filter_char('-'));
19295        assert!(!is_list_filter_char('\u{F700}'));
19296        assert!(!is_list_filter_char('\u{F701}'));
19297        assert!(!is_list_filter_char('\n'));
19298    }
19299
19300    #[test]
19301    fn ready_tab_keeps_in_progress_craft_after_inputs_spent() {
19302        let mut state = sample_state();
19303        state.craft_tab = CraftTab::Ready;
19304        state.blueprints = vec![BlueprintView {
19305            id: "plank".into(),
19306            label: "Plank".into(),
19307            craft_tier: 1,
19308            craft_ticks: 30,
19309            output: "wood_plank".into(),
19310            output_qty: 1,
19311            output_display_name: "Wood Plank".into(),
19312            station: None,
19313            category: None,
19314            inputs: vec![flatland_protocol::BlueprintIngredientView {
19315                template_id: "oak_log".into(),
19316                quantity: 1,
19317                consumed: true,
19318                display_name: "Oak Log".into(),
19319            }],
19320            required_tools: vec![],
19321            skill: None,
19322            failure_chance: 0.0,
19323            worker_train_copper: 0,
19324        }];
19325        // Materials already consumed at craft begin — would drop off Ready without the pin.
19326        state.inventory.clear();
19327        state.craft_channel_blueprint_id = Some("plank".into());
19328        state.timed_channel = Some(flatland_protocol::TimedChannelHud {
19329            label: "Crafting Plank".into(),
19330            channel: flatland_protocol::TimedChannelKind::Craft,
19331            ticks_remaining: 20,
19332            ticks_total: 30,
19333            ..Default::default()
19334        });
19335
19336        let idxs = state.craft_filtered_indices();
19337        assert_eq!(idxs, vec![0]);
19338        assert!(state.craft_blueprint_in_channel("plank"));
19339
19340        // Channel finished → clear pin → Ready empty.
19341        state.timed_channel = None;
19342        state.craft_channel_blueprint_id = None;
19343        assert!(state.craft_filtered_indices().is_empty());
19344    }
19345
19346    #[test]
19347    fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
19348        let mut state = sample_state();
19349        let id_a = uuid::Uuid::from_u128(0xa1);
19350        let id_b = uuid::Uuid::from_u128(0xb2);
19351        let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
19352        sword_a.display_name = Some("Iron Sword".into());
19353        sword_a.category = Some("weapon".into());
19354        sword_a.item_instance_id = Some(id_a);
19355        let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
19356        sword_b.display_name = Some("Iron Sword".into());
19357        sword_b.category = Some("weapon".into());
19358        sword_b.item_instance_id = Some(id_b);
19359        state.inventory_stacks = vec![sword_a, sword_b];
19360        state.inventory_tab = InventoryTab::OnPerson;
19361
19362        let lines = state.inventory_browser_lines();
19363        let items: Vec<_> = lines
19364            .iter()
19365            .filter_map(|l| match l {
19366                InventoryBrowserLine::Item {
19367                    title,
19368                    instance_tooltip,
19369                    ..
19370                } => Some((title.clone(), instance_tooltip.clone())),
19371                _ => None,
19372            })
19373            .collect();
19374        assert_eq!(items.len(), 2);
19375        for (title, tip) in &items {
19376            assert!(
19377                !title.contains('#'),
19378                "title should not show instance suffix: {title}"
19379            );
19380            assert!(
19381                tip.is_some(),
19382                "two identical rows should expose instance on hover"
19383            );
19384        }
19385
19386        state.inventory_stacks.pop();
19387        let lines = state.inventory_browser_lines();
19388        let one = lines.iter().find_map(|l| match l {
19389            InventoryBrowserLine::Item {
19390                title,
19391                instance_tooltip,
19392                ..
19393            } => Some((title.clone(), instance_tooltip.clone())),
19394            _ => None,
19395        });
19396        let (title, tip) = one.expect("one sword row");
19397        assert!(!title.contains('#'));
19398        assert!(tip.is_none(), "single row should not need instance tooltip");
19399    }
19400
19401    #[test]
19402    fn inventory_person_rows_group_by_category() {
19403        let mut state = sample_state();
19404        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
19405        sword.category = Some("weapon".into());
19406        sword.display_name = Some("Iron Sword".into());
19407        let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
19408        ore.category = Some("resource".into());
19409        ore.display_name = Some("Iron Ore".into());
19410        let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
19411        potion.category = Some("consumable".into());
19412        potion.display_name = Some("Health Potion".into());
19413        state.inventory_stacks = vec![ore, potion, sword];
19414        state.inventory_tab = InventoryTab::OnPerson;
19415
19416        let lines = state.inventory_browser_lines();
19417        let labels: Vec<&str> = lines
19418            .iter()
19419            .filter_map(|l| match l {
19420                InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
19421                _ => None,
19422            })
19423            .collect();
19424        assert!(
19425            labels.iter().any(|s| s.contains("Weapons")),
19426            "expected Weapons group: {labels:?}"
19427        );
19428        assert!(labels.iter().any(|s| s.contains("Consumables")));
19429        assert!(labels.iter().any(|s| s.contains("Resources")));
19430
19431        let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
19432        let consumable_pos = labels
19433            .iter()
19434            .position(|s| s.contains("Consumables"))
19435            .unwrap();
19436        let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
19437        assert!(weapon_pos < consumable_pos);
19438        assert!(consumable_pos < resource_pos);
19439    }
19440
19441    #[test]
19442    fn inventory_tab_cycle_resets_selection() {
19443        let mut state = sample_state();
19444        state.inventory_tab = InventoryTab::OnPerson;
19445        state.inventory_menu_index = 3;
19446        state.inventory_tab = state.inventory_tab.cycle(true);
19447        assert_eq!(state.inventory_tab, InventoryTab::Nearby);
19448        // Client method resets index; enum cycle alone does not — verify cycle labels.
19449        assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
19450        assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
19451        assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
19452        assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
19453    }
19454
19455    #[test]
19456    fn parse_bank_copper_amount_blank_and_zero_mean_all() {
19457        assert_eq!(parse_bank_copper_amount(""), Some(0));
19458        assert_eq!(parse_bank_copper_amount("  "), Some(0));
19459        assert_eq!(parse_bank_copper_amount("0"), Some(0));
19460        assert_eq!(parse_bank_copper_amount("250"), Some(250));
19461        assert_eq!(parse_bank_copper_amount("nope"), None);
19462    }
19463
19464    #[test]
19465    fn parse_storage_quantity_blank_and_zero_mean_all() {
19466        assert_eq!(parse_storage_quantity(""), Some(None));
19467        assert_eq!(parse_storage_quantity("  "), Some(None));
19468        assert_eq!(parse_storage_quantity("0"), Some(None));
19469        assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
19470        assert_eq!(parse_storage_quantity("nope"), None);
19471    }
19472
19473    #[test]
19474    fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
19475        assert!(worker_error_is_hud_noise("path stuck — repathing"));
19476        assert!(worker_error_is_hud_noise(
19477            "path stuck — nudged clear, repathing"
19478        ));
19479        assert!(worker_error_is_hud_noise(
19480            "returned to lodging after path failures"
19481        ));
19482        // Real problem: player may need to place lodging / fix assignment.
19483        assert!(!worker_error_is_hud_noise(
19484            "path stuck — no lodging to reset to"
19485        ));
19486        assert!(!worker_error_is_hud_noise(
19487            "cannot reach Eli — idling"
19488        ));
19489    }
19490
19491    #[test]
19492    fn leaving_building_restores_outdoor_z_bands() {
19493        use flatland_protocol::{InteriorMapView, ZPlatformView};
19494
19495        let mut state = sample_state();
19496        state.z_platforms.clear();
19497        state.z_transitions.clear();
19498        state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
19499        state.interior_map = Some(InteriorMapView {
19500            building_id: "broker_hut".into(),
19501            blueprint_id: "broker_hut".into(),
19502            background_color: "#000".into(),
19503            default_floor_color: None,
19504            floor_height_m: 3.0,
19505            z_platforms: vec![ZPlatformView {
19506                id: "floor_0".into(),
19507                z: 0.0,
19508                x0: 0.0,
19509                y0: 0.0,
19510                x1: 8.0,
19511                y1: 8.0,
19512            }],
19513            z_transitions: vec![],
19514            rooms: vec![],
19515            room_doors: vec![],
19516        });
19517        state.sync_interior_map_context();
19518        assert_eq!(
19519            state.z_platforms.len(),
19520            1,
19521            "indoors installs interior platforms"
19522        );
19523        assert!(state.z_bands_outdoor_backup.is_some());
19524
19525        state.player.as_mut().unwrap().inside_building = None;
19526        state.sync_interior_map_context();
19527        assert!(
19528            state.z_platforms.is_empty(),
19529            "leaving must restore outdoor bands (empty), not leave interior platforms"
19530        );
19531        assert!(state.z_bands_outdoor_backup.is_none());
19532        assert!(state.interior_map.is_none());
19533    }
19534
19535    #[test]
19536    fn resource_node_route_label_prefers_friendly_label_with_suffix() {
19537        let node = ResourceNodeView {
19538            id: "crop-carrot-1_copy10".into(),
19539            label: "crop-carrot-1_copy10".into(),
19540            x: 0.0,
19541            y: 0.0,
19542            z: 0.0,
19543            item_template: "carrot".into(),
19544            state: ResourceNodeState::Available,
19545            blocking: false,
19546            blocking_radius_m: 0.5,
19547            harvest_off: false,
19548            tile_id: None,
19549            yaw: 0.0,
19550            pitch: 0.0,
19551            roll: 0.0,
19552            draw_scale: 1.0,
19553            sprite_mode: None,
19554            growth_progress: None,
19555            presentation_state: None,
19556            channel_start_tick: None,
19557            channel_end_tick: None,
19558            harvest_drop_templates: vec![],
19559        };
19560        let label = super::resource_node_route_label(&node);
19561        assert!(label.starts_with("Carrot ("), "got {label}");
19562        assert!(label.ends_with(')'), "got {label}");
19563
19564        let mut named = node;
19565        named.label = "Sweet Pad".into();
19566        named.id = "crop-carrot-a3f2b1c0".into();
19567        assert_eq!(super::resource_node_route_label(&named), "Sweet Pad (b1c0)");
19568    }
19569
19570    #[test]
19571    fn plot_public_label_uses_owner_zone_and_label() {
19572        let plot = flatland_protocol::PropertyPlotView {
19573            plot_id: uuid::Uuid::nil(),
19574            property_zone_id: "zone_a".into(),
19575            zone_label: Some("Starter Town East 1".into()),
19576            deed_instance_id: uuid::Uuid::nil(),
19577            x0: 0.0,
19578            y0: 0.0,
19579            x1: 4.0,
19580            y1: 4.0,
19581            upkeep_copper_per_day: 1,
19582            arrears_days: 0,
19583            is_mine: true,
19584            may_farm: true,
19585            purchase_basis_copper: 0,
19586            farm_public: false,
19587            public_tax_discount_bps: 0,
19588            farm_allow: vec![],
19589            owner_character_id: None,
19590            owner_label: Some("Madsin".into()),
19591            building_id: None,
19592            plot_code: "xyz1234a".into(),
19593            label: "Food Pad".into(),
19594        };
19595        assert_eq!(
19596            super::plot_public_label(&plot),
19597            "Madsin — Starter Town East 1 — Food Pad"
19598        );
19599    }
19600
19601    #[test]
19602    fn plot_public_label_uses_size_when_label_and_code_blank() {
19603        let plot = flatland_protocol::PropertyPlotView {
19604            plot_id: uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap(),
19605            property_zone_id: String::new(),
19606            zone_label: None,
19607            deed_instance_id: uuid::Uuid::nil(),
19608            x0: 10.0,
19609            y0: 20.0,
19610            x1: 18.0,
19611            y1: 28.0,
19612            upkeep_copper_per_day: 1,
19613            arrears_days: 0,
19614            is_mine: true,
19615            may_farm: true,
19616            purchase_basis_copper: 0,
19617            farm_public: false,
19618            public_tax_discount_bps: 0,
19619            farm_allow: vec![],
19620            owner_character_id: None,
19621            owner_label: None,
19622            building_id: None,
19623            plot_code: String::new(),
19624            label: String::new(),
19625        };
19626        assert_eq!(super::plot_public_label(&plot), "Homestead — Plot (8×8 m)");
19627        assert!(!super::plot_public_label(&plot).contains("19fe35f"));
19628    }
19629
19630    #[test]
19631    fn plot_stop_label_prefers_view_over_hex() {
19632        let plot_id = uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap();
19633        let plot = flatland_protocol::PropertyPlotView {
19634            plot_id,
19635            property_zone_id: "zone_a".into(),
19636            zone_label: Some("Starter Town East".into()),
19637            deed_instance_id: uuid::Uuid::nil(),
19638            x0: 0.0,
19639            y0: 0.0,
19640            x1: 4.0,
19641            y1: 4.0,
19642            upkeep_copper_per_day: 1,
19643            arrears_days: 0,
19644            is_mine: true,
19645            may_farm: true,
19646            purchase_basis_copper: 0,
19647            farm_public: false,
19648            public_tax_discount_bps: 0,
19649            farm_allow: vec![],
19650            owner_character_id: None,
19651            owner_label: Some("Madsin".into()),
19652            building_id: None,
19653            plot_code: "xyz1234a".into(),
19654            label: "Food Pad".into(),
19655        };
19656        assert_eq!(
19657            super::plot_stop_label(&[plot.clone()], plot_id),
19658            "Madsin — Starter Town East — Food Pad"
19659        );
19660        let missing = uuid::Uuid::parse_str("1a001590-0000-4000-8000-000000000002").unwrap();
19661        assert_eq!(super::plot_stop_label(&[plot], missing), "plot 1a001590");
19662    }
19663}