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 stamina-hint UI.
113const CRAFT_STAMINA_COST: f32 = 3.0;
114/// Hard cap on the craft-menu batch stepper (materials can exceed this).
115const CRAFT_BATCH_SELECT_CAP: u32 = 999;
116/// Minimum time the workers-menu `step:` line holds a value before accepting a change.
117const WORKER_STEP_HOLD: Duration = Duration::from_millis(1200);
118/// Keep the last worker route error visible in the HUD after the server clears it.
119const WORKER_ERROR_HOLD: Duration = Duration::from_secs(45);
120/// Keep a defender's HP ring visible after a hit when combat has already ended.
121const WORKER_HEALTH_RING_HOLD: Duration = Duration::from_secs(6);
122/// Prevent repeated hire-key presses while the new worker roster is in flight.
123const WORKER_HIRE_PENDING_TIMEOUT: Duration = Duration::from_secs(15);
124
125/// Catalog hints synced from server `ItemStack` wire rows.
126#[derive(Debug, Clone, Default)]
127pub struct InventoryHint {
128    pub display_name: String,
129    pub category: String,
130    pub base_mass: Option<f32>,
131    pub base_volume: Option<f32>,
132    pub capacity_volume: Option<f32>,
133    pub stackable: bool,
134    /// Market-hall list eligibility (from catalog enrichment).
135    pub listable: bool,
136    /// NPC trade base for dump-queue payout hints.
137    pub base_value_copper: Option<u32>,
138}
139
140/// One row in the loadout hotbar picker (ability or inventory consumable).
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub struct LoadoutHotbarChoice {
143    /// Value sent via [`Intent::SetHotbarSlot`] (`ability_id` or `item:<template>`).
144    pub binding: String,
145    /// Display label (ability id, or "Carrot ×3").
146    pub label: String,
147    /// Optional meta badge (`weapon`, `use`, …).
148    pub meta: Option<String>,
149}
150
151/// Rotation editor overlay mode (`plans/26` §C2.5).
152#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
153pub enum RotationEditorMode {
154    #[default]
155    List,
156    EditSequence,
157    PickAbility,
158    EditLabel,
159}
160
161/// Local rotation editor UI state (not persisted).
162#[derive(Debug, Clone, Default)]
163pub struct RotationEditorState {
164    pub mode: RotationEditorMode,
165    pub list_index: usize,
166    pub ability_index: usize,
167    pub picker_index: usize,
168    pub draft: Option<RotationPreset>,
169    pub label_buffer: String,
170}
171
172impl RotationEditorState {
173    pub fn reset(&mut self) {
174        *self = Self::default();
175    }
176}
177
178/// Max distance (m) a placed chest can be browsed/moved-into from the inventory
179/// UI. Mirrors `flatland_sim::interaction::CONTAINER_INTERACTION_RADIUS_M` so the
180/// client only ever shows chests the server will actually let you use — this is
181/// what makes a chest disappear from the menu as soon as you walk away.
182pub const CONTAINER_RANGE_M: f32 = 3.0;
183
184/// Broad section of the inventory browser a row belongs to (drives the grouped
185/// "Worn" / "On you" / "Nearby chest" headers in the UI).
186#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187pub enum InventorySection {
188    /// Inside a worn body-slot item (backpack, belt w/ clipped pouches, armor).
189    Worn,
190    /// Loose on your person — not worn, not inside a placed chest.
191    Person,
192    /// Inside a placed chest within reach.
193    Nearby,
194}
195
196/// Top-level inventory browser tab (`b` menu).
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
198pub enum InventoryTab {
199    #[default]
200    OnPerson,
201    Nearby,
202}
203
204impl InventoryTab {
205    pub fn label(self) -> &'static str {
206        match self {
207            Self::OnPerson => "On person",
208            Self::Nearby => "Nearby storage",
209        }
210    }
211
212    pub fn cycle(self, forward: bool) -> Self {
213        match (self, forward) {
214            (Self::OnPerson, true) | (Self::OnPerson, false) => Self::Nearby,
215            (Self::Nearby, true) | (Self::Nearby, false) => Self::OnPerson,
216        }
217    }
218}
219
220/// Rows jumped by PageUp / PageDown in list UIs.
221pub const LIST_PAGE_SIZE: usize = 10;
222
223/// Case-insensitive substring match for list filters (name / template / label).
224pub fn list_label_matches(haystack: &str, filter: &str) -> bool {
225    if filter.is_empty() {
226        return true;
227    }
228    haystack
229        .to_ascii_lowercase()
230        .contains(&filter.to_ascii_lowercase())
231}
232
233/// True for characters that belong in `/` list search fields.
234/// Rejects control chars and macOS private-use arrow glyphs (`U+F700`…).
235pub fn is_list_filter_char(ch: char) -> bool {
236    match ch {
237        ' '..='~' => true,
238        c if c.is_alphanumeric() => true,
239        _ => false,
240    }
241}
242
243/// Clamp a list selection after a page jump (`pages` is typically ±1).
244pub fn page_list_index(index: usize, pages: i32, len: usize) -> usize {
245    if len == 0 {
246        return 0;
247    }
248    let page = LIST_PAGE_SIZE as i32;
249    let next = index as i32 + pages * page;
250    next.clamp(0, (len as i32) - 1) as usize
251}
252
253/// Advance `index` by `delta` among indices where `pred` is true (wraps).
254pub fn step_filtered_index(
255    index: usize,
256    delta: i32,
257    len: usize,
258    pred: impl Fn(usize) -> bool,
259) -> usize {
260    if len == 0 {
261        return 0;
262    }
263    let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
264    if matching.is_empty() {
265        return index.min(len - 1);
266    }
267    let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
268    let next = (pos as i32 + delta).rem_euclid(matching.len() as i32) as usize;
269    matching[next]
270}
271
272/// Page among filtered indices (no wrap — clamp like [`page_list_index`]).
273pub fn page_filtered_index(
274    index: usize,
275    pages: i32,
276    len: usize,
277    pred: impl Fn(usize) -> bool,
278) -> usize {
279    if len == 0 {
280        return 0;
281    }
282    let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
283    if matching.is_empty() {
284        return index.min(len - 1);
285    }
286    let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
287    let next = page_list_index(pos, pages, matching.len());
288    matching[next]
289}
290
291/// Category group for On-person loose items (stable order).
292pub fn inventory_category_group(category: &str) -> (&'static str, u8) {
293    match category {
294        "weapon" | "ammo" => ("Weapons", 0),
295        "armor" | "shield" | "offhand" => ("Armor", 1),
296        "consumable" | "liquid" | "bulk" => ("Consumables", 2),
297        "resource" | "harvest_node" | "seed" => ("Resources", 3),
298        "container" | "lodging" => ("Containers", 4),
299        "currency" | "key" => ("Currency & keys", 5),
300        "tool" | "misc" | "furniture" | "quest" | "document" => ("Gear & misc", 6),
301        _ => ("Other", 7),
302    }
303}
304
305/// Whether a template category is listable when the wire/hint omits an explicit flag.
306pub fn category_default_listable(category: &str) -> bool {
307    !matches!(
308        category,
309        "currency" | "harvest_node" | "key" | "quest" | "document" | "lodging"
310    )
311}
312
313fn vessel_holds_category(stack: &flatland_protocol::ItemStack, category: Option<&str>) -> bool {
314    let cat = category.unwrap_or("");
315    if let Some(holds) = stack.props.get("serving_holds") {
316        return holds.split(',').any(|p| {
317            let p = p.trim();
318            p == cat
319                || (cat == "liquid" && p == "liquid")
320                || (cat == "bulk" && p == "bulk")
321                || (matches!(cat, "consumable") && p == "food")
322        });
323    }
324    // Props incomplete (older sync) — fall back to vessel flags.
325    match cat {
326        "bulk" => stack.props.get("bulk_vessel").is_some_and(|v| v == "1"),
327        "liquid" => stack.props.get("liquid_vessel").is_some_and(|v| v == "1"),
328        _ => false,
329    }
330}
331
332fn serving_capacity_of(stack: &flatland_protocol::ItemStack) -> u32 {
333    stack
334        .props
335        .get("serving_capacity")
336        .and_then(|s| s.parse().ok())
337        .unwrap_or(0)
338}
339
340fn payload_units_in_vessel(stack: &flatland_protocol::ItemStack) -> u32 {
341    stack.contents.iter().map(|c| c.quantity).sum()
342}
343
344fn is_serving_vessel_stack(stack: &flatland_protocol::ItemStack) -> bool {
345    stack.props.get("serving").is_some_and(|v| v == "1")
346        || stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
347        || stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
348        || stack.props.contains_key("serving_holds")
349        || stack.props.contains_key("serving_capacity")
350}
351
352fn vessel_free_room_for_payload(
353    stack: &flatland_protocol::ItemStack,
354    payload_id: &str,
355    payload_category: Option<&str>,
356) -> u32 {
357    if !is_serving_vessel_stack(stack) || !vessel_holds_category(stack, payload_category) {
358        return 0;
359    }
360    let primary = stack.contents.iter().find(|c| c.quantity > 0);
361    let compatible = primary.is_none_or(|c| c.template_id == payload_id);
362    if !compatible {
363        return 0;
364    }
365    let cap = serving_capacity_of(stack);
366    let used = payload_units_in_vessel(stack);
367    let per_shell = cap.saturating_sub(used);
368    if per_shell == 0 {
369        return 0;
370    }
371    // Empty stacked shells each contribute full capacity.
372    let shells = if stack.contents.is_empty() {
373        stack.quantity.max(1)
374    } else {
375        1
376    };
377    per_shell.saturating_mul(shells)
378}
379
380fn drain_payload_from_stacks(
381    stacks: &mut [flatland_protocol::ItemStack],
382    template_id: &str,
383    remaining: &mut u32,
384) {
385    if *remaining == 0 {
386        return;
387    }
388    for stack in stacks.iter_mut() {
389        if *remaining == 0 {
390            return;
391        }
392        if stack.template_id == template_id && stack.quantity > 0 {
393            let take = (*remaining).min(stack.quantity);
394            stack.quantity -= take;
395            *remaining -= take;
396        }
397        drain_payload_from_stacks(&mut stack.contents, template_id, remaining);
398        // Drop emptied nested payload shells so vessel room frees.
399        stack.contents.retain(|c| c.quantity > 0);
400    }
401}
402
403fn vessel_room_for_payload_in_stacks(
404    stacks: &[flatland_protocol::ItemStack],
405    payload_id: &str,
406    payload_category: Option<&str>,
407) -> u32 {
408    let mut room = 0u32;
409    for stack in stacks {
410        room = room.saturating_add(vessel_free_room_for_payload(
411            stack,
412            payload_id,
413            payload_category,
414        ));
415        room = room.saturating_add(vessel_room_for_payload_in_stacks(
416            &stack.contents,
417            payload_id,
418            payload_category,
419        ));
420    }
421    room
422}
423
424/// One serving vessel shown in the craft detail panel.
425#[derive(Debug, Clone)]
426pub struct CraftVesselLine {
427    pub label: String,
428    pub holds: String,
429    pub capacity: u32,
430    pub used: u32,
431    pub free: u32,
432    pub quantity: u32,
433    pub accepts_output: bool,
434    pub location: &'static str,
435}
436
437/// Full vessel transparency for a selected craft recipe.
438#[derive(Debug, Clone)]
439pub struct CraftVesselStatus {
440    pub needs_vessel: bool,
441    pub output_label: String,
442    pub need_units: u32,
443    pub free_after_inputs: u32,
444    pub ok: bool,
445    pub vessels: Vec<CraftVesselLine>,
446}
447
448/// Craft menu primary tabs — mutually exclusive list scopes.
449#[derive(Debug, Clone, Copy, PartialEq, Eq)]
450pub enum CraftTab {
451    Ready,
452    Favorites,
453    Recent,
454    Tier(u32),
455}
456
457impl CraftTab {
458    pub fn label(self) -> String {
459        match self {
460            Self::Ready => "Ready".into(),
461            Self::Favorites => "★".into(),
462            Self::Recent => "Recent".into(),
463            Self::Tier(n) => format!("T{n}"),
464        }
465    }
466}
467
468/// Estimated net copper per unit for hall NPC-price dump queue (default buyer rates).
469pub fn npc_market_dump_unit_estimate_copper(base_value: u32) -> Option<u32> {
470    if base_value == 0 {
471        return None;
472    }
473    let unit = ((base_value as f32) * 0.5).floor() as u32;
474    if unit == 0 {
475        return None;
476    }
477    Some(((unit as u64).saturating_mul(9500) / 10_000).max(1) as u32)
478}
479
480/// Parse a bank deposit/withdraw amount. Blank or `0` means "all" (server convention).
481fn parse_bank_copper_amount(input: &str) -> Option<u64> {
482    let s = input.trim();
483    if s.is_empty() {
484        return Some(0);
485    }
486    s.parse::<u64>().ok()
487}
488
489/// Parse a storage store/take/ship quantity. Blank or `0` means all (`None` intent qty).
490fn parse_storage_quantity(input: &str) -> Option<Option<u32>> {
491    let s = input.trim();
492    if s.is_empty() || s == "0" {
493        return Some(None);
494    }
495    let n = s.parse::<u32>().ok()?;
496    if n == 0 {
497        return Some(None);
498    }
499    Some(Some(n))
500}
501
502fn storage_stack_label(stack: &flatland_protocol::ItemStack) -> String {
503    let name = stack
504        .display_name
505        .as_deref()
506        .unwrap_or(stack.template_id.as_str());
507    if stack.quantity > 1 {
508        format!("{name} ×{}", stack.quantity)
509    } else {
510        name.to_string()
511    }
512}
513
514/// Short display label for a body slot (`plans/08` §4.1) — shared by the inventory
515/// browser section headers and the move-destination picker.
516pub fn body_slot_label(slot: BodySlot) -> &'static str {
517    match slot {
518        BodySlot::Head => "Head",
519        BodySlot::Chest => "Chest",
520        BodySlot::Forearms => "Forearms",
521        BodySlot::Legs => "Legs",
522        BodySlot::Feet => "Feet",
523        BodySlot::Cloak => "Cloak",
524        BodySlot::Back => "Back",
525        BodySlot::Waist => "Waist",
526        BodySlot::Earrings => "Earrings",
527        BodySlot::Necklace => "Necklace",
528        BodySlot::Eyeglasses => "Eyeglasses",
529        BodySlot::RingLeft1 => "Ring L1",
530        BodySlot::RingLeft2 => "Ring L2",
531        BodySlot::RingRight1 => "Ring R1",
532        BodySlot::RingRight2 => "Ring R2",
533    }
534}
535
536fn grant_target_matches_mode(stack: &flatland_protocol::ItemStack, mode: &str) -> bool {
537    let cat = stack.category.as_deref().unwrap_or("");
538    match mode {
539        "while_equipped" => {
540            stack.equip_slot.is_some()
541                || cat == "weapon"
542                || cat == "shield"
543                || cat == "offhand"
544                || cat == "armor"
545        }
546        _ => cat == "weapon" || cat == "ammo" || stack.props.contains_key("weapon_ability_id"),
547    }
548}
549
550fn grant_tags_match(stack: &flatland_protocol::ItemStack, grant_tags: &[&str]) -> bool {
551    if grant_tags.is_empty() {
552        return true;
553    }
554    let target_tags: Vec<&str> = stack
555        .props
556        .get("allowed_enchant_tags")
557        .map(|s| {
558            s.split(',')
559                .map(str::trim)
560                .filter(|t| !t.is_empty())
561                .collect()
562        })
563        .unwrap_or_default();
564    if target_tags.is_empty() {
565        return true;
566    }
567    grant_tags.iter().any(|t| target_tags.contains(t))
568}
569
570/// Default sim tick rate when the client has no settings (matches server-settings).
571pub const DEFAULT_TICK_HZ: u32 = 30;
572
573/// Human-readable remaining time for an item status binding.
574pub fn format_binding_ttl(
575    binding: &flatland_protocol::ItemStatusBinding,
576    tick: u64,
577    tick_hz: u32,
578) -> String {
579    let Some(expires) = binding.expires_at_tick else {
580        return "permanent".into();
581    };
582    let hz = tick_hz.max(1) as f32;
583    let remaining = expires.saturating_sub(tick) as f32 / hz;
584    if remaining <= 0.0 {
585        return "expired".into();
586    }
587    if remaining >= 120.0 {
588        format!("{:.0}m left", remaining / 60.0)
589    } else if remaining >= 10.0 {
590        format!("{remaining:.0}s left")
591    } else {
592        format!("{remaining:.1}s left")
593    }
594}
595
596pub fn format_binding_mode(mode: flatland_protocol::ItemStatusBindingMode) -> &'static str {
597    match mode {
598        flatland_protocol::ItemStatusBindingMode::OnHit => "on hit",
599        flatland_protocol::ItemStatusBindingMode::WhileEquipped => "while equipped",
600    }
601}
602
603/// Compact suffix for inventory / paperdoll rows, e.g. ` · fortify (while equipped, 58m left)`.
604pub fn format_status_bindings_suffix(
605    bindings: &[flatland_protocol::ItemStatusBinding],
606    tick: u64,
607    tick_hz: u32,
608) -> String {
609    if bindings.is_empty() {
610        return String::new();
611    }
612    let parts: Vec<String> = bindings
613        .iter()
614        .map(|b| {
615            format!(
616                "{} ({}, {})",
617                b.effect_id,
618                format_binding_mode(b.mode),
619                format_binding_ttl(b, tick, tick_hz)
620            )
621        })
622        .collect();
623    format!(" · {}", parts.join("; "))
624}
625
626#[derive(Debug, Clone, Copy, PartialEq, Eq)]
627pub enum EquipPaperdollRow {
628    Body { slot: BodySlot, filled: bool },
629    Mainhand { filled: bool },
630    Offhand { filled: bool, locked: bool },
631}
632
633pub fn equip_paperdoll_rows(state: &GameState) -> Vec<EquipPaperdollRow> {
634    let mut rows: Vec<EquipPaperdollRow> = BodySlot::ALL
635        .iter()
636        .map(|slot| EquipPaperdollRow::Body {
637            slot: *slot,
638            filled: state.worn.contains_key(slot),
639        })
640        .collect();
641    let two_hand = state.mainhand_hand_slots >= 2;
642    rows.push(EquipPaperdollRow::Mainhand {
643        filled: state.mainhand_template_id.is_some(),
644    });
645    rows.push(EquipPaperdollRow::Offhand {
646        filled: state.offhand_template_id.is_some(),
647        locked: two_hand,
648    });
649    rows
650}
651
652fn first_inventory_for_slot(state: &GameState, slot: BodySlot) -> Option<uuid::Uuid> {
653    for stack in &state.inventory_stacks {
654        let matches = stack
655            .equip_slot
656            .map(|s| s == slot || (is_client_ring(s) && is_client_ring(slot)))
657            .unwrap_or(false)
658            || guess_body_slot(&stack.template_id) == Some(slot);
659        if matches {
660            return stack.item_instance_id;
661        }
662    }
663    None
664}
665
666fn is_client_ring(slot: BodySlot) -> bool {
667    matches!(
668        slot,
669        BodySlot::RingLeft1 | BodySlot::RingLeft2 | BodySlot::RingRight1 | BodySlot::RingRight2
670    )
671}
672
673fn first_inventory_weapon(state: &GameState) -> Option<String> {
674    for stack in &state.inventory_stacks {
675        if stack.category.as_deref() == Some("weapon") {
676            return Some(stack.template_id.clone());
677        }
678    }
679    None
680}
681
682fn first_inventory_offhand(state: &GameState) -> Option<String> {
683    for stack in &state.inventory_stacks {
684        let cat = stack.category.as_deref().unwrap_or("");
685        if matches!(cat, "shield" | "offhand") {
686            return Some(stack.template_id.clone());
687        }
688    }
689    None
690}
691
692/// Client-side naming heuristic for "Enter equips this" — prefers catalog `equip_slot`
693/// on the stack when present; otherwise guesses from `template_id`.
694fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
695    if template_id.contains("backpack") {
696        Some(BodySlot::Back)
697    } else if template_id.contains("belt") {
698        Some(BodySlot::Waist)
699    } else if template_id.contains("cloak") || template_id.contains("cape") {
700        Some(BodySlot::Cloak)
701    } else if template_id.contains("cap")
702        || template_id.contains("hat")
703        || template_id.contains("helm")
704    {
705        Some(BodySlot::Head)
706    } else if template_id.contains("shirt")
707        || template_id.contains("robe")
708        || template_id.contains("vest")
709        || template_id.contains("chest")
710        || template_id.contains("jerkin")
711    {
712        Some(BodySlot::Chest)
713    } else if template_id.contains("sleeves")
714        || template_id.contains("gloves")
715        || template_id.contains("gauntlets")
716    {
717        Some(BodySlot::Forearms)
718    } else if template_id.contains("pants") || template_id.contains("leggings") {
719        Some(BodySlot::Legs)
720    } else if template_id.contains("boots") || template_id.contains("shoes") {
721        Some(BodySlot::Feet)
722    } else if template_id.contains("earring") {
723        Some(BodySlot::Earrings)
724    } else if template_id.contains("necklace") || template_id.contains("amulet") {
725        Some(BodySlot::Necklace)
726    } else if template_id.contains("glass")
727        || template_id.contains("spectacles")
728        || template_id.contains("goggles")
729    {
730        Some(BodySlot::Eyeglasses)
731    } else if template_id.contains("ring") {
732        Some(BodySlot::RingLeft1)
733    } else {
734        None
735    }
736}
737
738/// One row in the inventory browser tree.
739#[derive(Debug, Clone)]
740pub struct InventoryRow {
741    pub depth: usize,
742    pub stack: flatland_protocol::ItemStack,
743    /// `MoveItem` source location for this stack.
744    pub from: flatland_protocol::InventoryLocation,
745    /// Parent container instance when nested (belt shell, backpack, chest, pouch).
746    pub from_parent_instance_id: Option<uuid::Uuid>,
747    /// Equipped bag/chest shell — unequip via Enter instead of the move picker.
748    pub is_equip_shell: bool,
749    /// Placed world chest shell — lock/unlock via Enter or `l`.
750    pub is_chest_shell: bool,
751    pub section: InventorySection,
752}
753
754/// Formatted inventory row text shared by TUI and gfx browsers.
755#[derive(Debug, Clone)]
756pub struct InventoryRowView {
757    pub depth: usize,
758    /// Dense single-line label (legacy / TUI).
759    pub text: String,
760    /// Primary label for redesigned gfx rows (name, qty, slot).
761    pub title: String,
762    pub mass_kg: Option<f32>,
763    pub volume: Option<(f32, f32)>,
764    /// Hover hint when multiple rows share the same visible identity (template + label + mods).
765    pub instance_tooltip: Option<String>,
766}
767
768/// One line in the sectioned inventory browser (headers are non-selectable).
769#[derive(Debug, Clone)]
770pub enum InventoryBrowserLine {
771    Section(String),
772    SlotLabel(String),
773    Hint(String),
774    Blank,
775    Item {
776        selectable_index: usize,
777        selected: bool,
778        depth: usize,
779        text: String,
780        title: String,
781        mass_kg: Option<f32>,
782        volume: Option<(f32, f32)>,
783        instance_tooltip: Option<String>,
784    },
785}
786
787/// Bank teller panel focus (action list vs amount / transfer prompts).
788#[derive(Debug, Clone, PartialEq, Eq, Default)]
789pub enum BankUiMode {
790    #[default]
791    Menu,
792    DepositAmount {
793        input: String,
794    },
795    WithdrawAmount {
796        input: String,
797    },
798    TransferName {
799        input: String,
800    },
801    TransferAmount {
802        to_name: String,
803        input: String,
804    },
805}
806
807/// Town storage manager focus (action list vs item pickers vs quantity).
808#[derive(Debug, Clone, PartialEq, Eq, Default)]
809pub enum StorageUiMode {
810    #[default]
811    Menu,
812    /// Pick a loose on-person stack to store.
813    StorePick { index: usize },
814    /// Quantity for a chosen store stack (blank/0 = all).
815    StoreAmount {
816        pick_index: usize,
817        item_instance_id: uuid::Uuid,
818        label: String,
819        max_qty: u32,
820        input: String,
821    },
822    /// Pick a vault stack to take.
823    TakePick { index: usize },
824    /// Quantity for a chosen take stack (blank/0 = all).
825    TakeAmount {
826        pick_index: usize,
827        item_instance_id: uuid::Uuid,
828        label: String,
829        max_qty: u32,
830        input: String,
831    },
832    /// Pick a vault stack to ship to `dest_building_id`.
833    ShipPick {
834        dest_building_id: String,
835        dest_label: String,
836        index: usize,
837    },
838    /// Quantity for a chosen ship stack (blank/0 = all).
839    ShipAmount {
840        dest_building_id: String,
841        dest_label: String,
842        pick_index: usize,
843        item_instance_id: uuid::Uuid,
844        label: String,
845        max_qty: u32,
846        input: String,
847    },
848}
849
850/// Where market list goods are taken from (`GoodsLocation` on submit).
851#[derive(Debug, Clone, PartialEq, Eq)]
852pub enum MarketListSourceKind {
853    Person,
854    TownStorage { building_id: String },
855}
856
857/// Market hall clerk focus (browse vs list wizard).
858#[derive(Debug, Clone, PartialEq, Eq, Default)]
859pub enum MarketUiMode {
860    #[default]
861    Browse,
862    /// Pick on-person vs an eligible town vault.
863    ListSource { index: usize },
864    /// Pick a stack from the chosen source.
865    ListPick {
866        source: MarketListSourceKind,
867        index: usize,
868    },
869    /// Quantity to list (blank/0 = all).
870    ListAmount {
871        source: MarketListSourceKind,
872        pick_index: usize,
873        item_instance_id: uuid::Uuid,
874        template_id: String,
875        label: String,
876        max_qty: u32,
877        input: String,
878    },
879    /// Choose Fixed copper vs NPC-price dump queue (`plans/53`).
880    ListPricingMode {
881        source: MarketListSourceKind,
882        pick_index: usize,
883        item_instance_id: uuid::Uuid,
884        template_id: String,
885        label: String,
886        quantity: Option<u32>,
887        max_qty: u32,
888        /// 0 = NPC price, 1 = Fixed.
889        index: usize,
890    },
891    /// Unit price in copper.
892    ListPrice {
893        source: MarketListSourceKind,
894        pick_index: usize,
895        item_instance_id: uuid::Uuid,
896        template_id: String,
897        label: String,
898        /// Resolved quantity to list (`None` = all / omit on wire).
899        quantity: Option<u32>,
900        max_qty: u32,
901        input: String,
902    },
903}
904
905/// One selectable stack in a storage store/take/ship picker.
906#[derive(Debug, Clone)]
907pub struct StoragePickOption {
908    pub item_instance_id: uuid::Uuid,
909    pub template_id: String,
910    pub label: String,
911    pub quantity: u32,
912    /// Catalog category when known (market list filters).
913    pub category: String,
914}
915
916/// A placed chest within `CONTAINER_RANGE_M`, with its contents pre-flattened for
917/// the browser (empty when locked without the matching key).
918#[derive(Debug, Clone)]
919pub struct NearbyContainer {
920    pub view: flatland_protocol::PlacedContainerView,
921    pub distance_m: f32,
922    pub rows: Vec<InventoryRow>,
923}
924
925/// One key row in the keychain overlay (carried vs stowed).
926#[derive(Debug, Clone)]
927pub struct KeychainEntry {
928    pub stack: flatland_protocol::ItemStack,
929    pub stowed: bool,
930}
931
932/// A destination the currently-picked item could be moved to.
933#[derive(Debug, Clone)]
934pub struct MoveOption {
935    pub label: String,
936    pub kind: MoveOptionKind,
937    /// Used / capacity volume when this destination is a storage container.
938    pub volume: Option<(f32, f32)>,
939}
940
941impl MoveOption {
942    fn action(label: impl Into<String>, kind: MoveOptionKind) -> Self {
943        Self {
944            label: label.into(),
945            kind,
946            volume: None,
947        }
948    }
949
950    /// Inventory-style `vol used/cap (N free)` for picker rows.
951    pub fn volume_usage_label(&self) -> Option<String> {
952        self.volume
953            .map(|(used, cap)| format_container_volume_usage(used, cap))
954    }
955}
956
957/// Volume used / capacity / free space, matching inventory container rows.
958pub fn format_container_volume_usage(used: f32, cap: f32) -> String {
959    let used = used.max(0.0);
960    let cap = cap.max(0.0);
961    let free = (cap - used).max(0.0);
962    format!("vol {used:.0}/{cap:.0} ({free:.0} free)")
963}
964
965#[derive(Debug, Clone, PartialEq)]
966pub enum MoveOptionKind {
967    Move {
968        location: flatland_protocol::InventoryLocation,
969        parent_instance_id: Option<uuid::Uuid>,
970    },
971    /// Pick up a placed chest/crate; optionally nest into a worn bag afterward.
972    PickupPlaced {
973        container_id: String,
974        nest_location: flatland_protocol::InventoryLocation,
975        nest_parent_instance_id: Option<uuid::Uuid>,
976    },
977    /// Enter map relocate mode for a placed chest (no pickup).
978    RelocatePlaced {
979        container_id: String,
980    },
981    /// Eat/drink a loose consumable (one unit per use).
982    Use,
983    /// Open grant-target picker for `grants_item_status` consumables.
984    GrantApply,
985    Drop,
986    /// Sell the plot tied to a property deed back to the crown.
987    SellPlotToCrown {
988        plot_id: uuid::Uuid,
989    },
990    Cancel,
991}
992
993/// One navigable row in the deed farm-access panel.
994#[derive(Debug, Clone, PartialEq)]
995pub enum FarmAccessRow {
996    PublicToggle,
997    PublicDiscount,
998    AllowRemove {
999        character_id: uuid::Uuid,
1000        label: String,
1001        tax_discount_bps: u32,
1002    },
1003    NearbyAdd {
1004        name: String,
1005    },
1006}
1007
1008/// Active "apply grant onto…" picker (fortify oil, frost edge scroll, …).
1009#[derive(Debug, Clone)]
1010pub struct GrantTargetPicker {
1011    pub grant_instance_id: uuid::Uuid,
1012    pub grant_label: String,
1013    pub effect_id: String,
1014    pub mode: String,
1015    pub options: Vec<GrantTargetOption>,
1016    pub filter: String,
1017    pub filter_focused: bool,
1018}
1019
1020#[derive(Debug, Clone)]
1021pub struct GrantTargetOption {
1022    pub label: String,
1023    pub target_instance_id: uuid::Uuid,
1024}
1025
1026/// Active "move to…" destination picker state for the selected inventory item.
1027#[derive(Debug, Clone)]
1028pub struct MovePicker {
1029    pub item_instance_id: uuid::Uuid,
1030    pub from: flatland_protocol::InventoryLocation,
1031    pub item_label: String,
1032    pub template_id: String,
1033    pub stack_quantity: u32,
1034    pub quantity: u32,
1035    pub options: Vec<MoveOption>,
1036    pub filter: String,
1037    pub filter_focused: bool,
1038}
1039
1040/// Active permanent-delete picker for the selected inventory item.
1041#[derive(Debug, Clone)]
1042pub struct DestroyPicker {
1043    pub item_instance_id: uuid::Uuid,
1044    pub from: flatland_protocol::InventoryLocation,
1045    pub item_label: String,
1046    pub stack_quantity: u32,
1047    pub quantity: u32,
1048}
1049
1050/// One giveable stack in the workers-menu give picker.
1051#[derive(Debug, Clone)]
1052pub struct WorkerGiveOption {
1053    pub item_instance_id: uuid::Uuid,
1054    pub label: String,
1055    pub quantity: u32,
1056    pub template_id: String,
1057}
1058
1059/// Give an on-person inventory stack to the selected hired worker.
1060#[derive(Debug, Clone)]
1061pub struct WorkerGivePicker {
1062    pub worker_instance_id: String,
1063    pub worker_label: String,
1064    pub options: Vec<WorkerGiveOption>,
1065}
1066
1067/// Nearby hired worker choice when giving a selected inventory stack (`g`).
1068#[derive(Debug, Clone)]
1069pub struct WorkerGiveTargetOption {
1070    pub instance_id: String,
1071    pub label: String,
1072    pub distance_m: f32,
1073}
1074
1075/// Pick which nearby worker receives the selected inventory item.
1076#[derive(Debug, Clone)]
1077pub struct WorkerGiveTargetPicker {
1078    pub item_instance_id: uuid::Uuid,
1079    pub item_label: String,
1080    pub quantity: Option<u32>,
1081    pub options: Vec<WorkerGiveTargetOption>,
1082}
1083
1084/// Take an item from a hired worker back into the employer's inventory.
1085#[derive(Debug, Clone)]
1086pub struct WorkerTakePicker {
1087    pub worker_instance_id: String,
1088    pub worker_label: String,
1089    pub options: Vec<WorkerGiveOption>,
1090    /// How many of the selected stack to take (1..=stack quantity).
1091    pub quantity: u32,
1092}
1093
1094/// Max distance (m) to hand an item to a hired worker.
1095pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
1096
1097/// One teachable blueprint in the workers-menu teach picker.
1098#[derive(Debug, Clone)]
1099pub struct WorkerTeachOption {
1100    pub blueprint_id: String,
1101    pub label: String,
1102    pub cost_copper: u64,
1103    pub min_level: u32,
1104    pub worker_level: u32,
1105    pub can_afford: bool,
1106    pub level_ok: bool,
1107}
1108
1109/// Teach a known blueprint to the selected hired worker.
1110#[derive(Debug, Clone)]
1111pub struct WorkerTeachPicker {
1112    pub worker_instance_id: String,
1113    pub worker_label: String,
1114    pub worker_level: u32,
1115    pub options: Vec<WorkerTeachOption>,
1116}
1117
1118/// Selected worker awaiting confirmation before permanent dismissal.
1119#[derive(Debug, Clone)]
1120pub struct WorkerDismissConfirmation {
1121    pub worker_instance_id: String,
1122    pub worker_label: String,
1123}
1124
1125/// Sticky workers-menu step line — holds a coarse label so travel/harvest ticks
1126/// do not thrash the UI.
1127#[derive(Debug, Clone, Default)]
1128pub struct StickyWorkerStep {
1129    shown: String,
1130    pending: String,
1131    pending_since: Option<Instant>,
1132}
1133
1134impl StickyWorkerStep {
1135    fn from_label(label: String) -> Self {
1136        Self {
1137            shown: label.clone(),
1138            pending: label,
1139            pending_since: Some(Instant::now()),
1140        }
1141    }
1142
1143    fn observe(&mut self, label: &str, now: Instant) {
1144        let pending_since = self.pending_since.unwrap_or(now);
1145        if label == self.pending {
1146            if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD {
1147                self.shown = self.pending.clone();
1148            }
1149            return;
1150        }
1151        self.pending = label.to_string();
1152        self.pending_since = Some(now);
1153        // Empty → first value, or first observation: show immediately.
1154        if self.shown.is_empty() {
1155            self.shown = self.pending.clone();
1156        }
1157    }
1158}
1159
1160/// Last non-transient worker error — held so route flicker stays readable.
1161/// Path→lodging recoveries are log-only (`worker_error_is_hud_noise`) and not held.
1162#[derive(Debug, Clone, Default)]
1163pub struct StickyWorkerError {
1164    message: String,
1165    last_seen: Option<Instant>,
1166}
1167
1168impl StickyWorkerError {
1169    fn observe(&mut self, err: Option<&str>, now: Instant) {
1170        if let Some(e) = err {
1171            if !worker_error_is_transient(e) && !worker_error_is_hud_noise(e) {
1172                self.message = e.to_string();
1173                self.last_seen = Some(now);
1174            }
1175            return;
1176        }
1177        if let Some(seen) = self.last_seen {
1178            if now.duration_since(seen) > WORKER_ERROR_HOLD {
1179                self.message.clear();
1180                self.last_seen = None;
1181            }
1182        }
1183    }
1184
1185    pub fn shown(&self, now: Instant) -> Option<&str> {
1186        if self.message.is_empty() {
1187            return None;
1188        }
1189        let seen = self.last_seen?;
1190        if now.duration_since(seen) > WORKER_ERROR_HOLD {
1191            return None;
1192        }
1193        Some(self.message.as_str())
1194    }
1195}
1196
1197/// First non-transient worker issue for the status bar (strike, route error, etc.).
1198/// Routine recovery noise (path failures → lodging) stays in the game log only.
1199pub fn worker_attention_line(state: &GameState) -> Option<String> {
1200    use flatland_protocol::WorkerStateView;
1201    let now = Instant::now();
1202    for w in &state.hired_workers {
1203        if matches!(w.state, WorkerStateView::Strike) {
1204            return Some(format!(
1205                "Worker {}: on strike — fund bank, pay wages, or stock lodging chest",
1206                w.label
1207            ));
1208        }
1209        let sticky = state
1210            .worker_error_display
1211            .get(&w.instance_id)
1212            .and_then(|s| s.shown(now))
1213            .filter(|e| !worker_error_is_hud_noise(e));
1214        let live = w
1215            .last_error
1216            .as_deref()
1217            .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e));
1218        if let Some(err) = sticky.or(live) {
1219            if let Some(hint) = w
1220                .issue_hint
1221                .as_deref()
1222                .filter(|h| !h.is_empty())
1223                .or_else(|| worker_issue_fix_hint(err))
1224            {
1225                return Some(format!("Worker {}: {err} — {hint}", w.label));
1226            }
1227            return Some(format!("Worker {}: {err}", w.label));
1228        }
1229        // Plan issue with hint but cleared last_error (sticky already shown above).
1230        if let Some(hint) = w.issue_hint.as_deref().filter(|h| !h.is_empty()) {
1231            return Some(format!("Worker {}: {hint}", w.label));
1232        }
1233    }
1234    None
1235}
1236
1237/// Fallback fix copy when the server did not send `issue_hint`.
1238pub fn worker_issue_fix_hint(err: &str) -> Option<&'static str> {
1239    let e = err.to_ascii_lowercase();
1240    if e.contains("missing")
1241        || e.contains("container not found")
1242        || e.contains("lodging container not found")
1243    {
1244        return Some("edit route (e): replace the missing chest/bed");
1245    }
1246    if e.contains("stranded at interior") || e.contains("interior map coords") {
1247        return Some("recovered — continuing route");
1248    }
1249    if e.contains("stuck inside")
1250        || e.contains("sent outside")
1251        || e.contains("sent to door")
1252        || e.contains("left building")
1253    {
1254        return Some("auto-exit for outdoor work — restart after update if it still loops");
1255    }
1256    if e.contains("collapsed") || e.contains("need food") {
1257        return Some("stock lodging bed with food and drink");
1258    }
1259    if e.contains("overburdened") {
1260        return Some("add a deposit/sell stop, or empty their pack");
1261    }
1262    if e.contains("storage full") {
1263        return Some("empty or upgrade the destination chest, or reassign the deposit");
1264    }
1265    if e.contains("need a hoe") || e.contains("need a dibber") {
1266        return Some("give them the tool or withdraw it on the route");
1267    }
1268    if e.contains("idling at lodging") && e.contains("no path") {
1269        return Some("clear blockers or edit the harvest route");
1270    }
1271    None
1272}
1273
1274/// True when a worker `last_error` is transient noise (soft-skip chatter).
1275pub fn worker_error_is_transient(err: &str) -> bool {
1276    let e = err.to_ascii_lowercase();
1277    e.contains("trying next node")
1278        || e.contains("continuing route")
1279        || e.starts_with("nothing to withdraw")
1280}
1281
1282/// True for routine worker recoveries that should not paint the status bar.
1283/// Still logged when they change (see [`GameState::apply_hired_workers`]).
1284pub fn worker_error_is_hud_noise(err: &str) -> bool {
1285    let e = err.to_ascii_lowercase();
1286    // Durable complete-or-idle park must stay visible on HUD and attention lines.
1287    if e.contains("idling")
1288        && (e.contains("cannot reach")
1289            || e.contains("unreachable")
1290            || e.contains("idling at lodging"))
1291    {
1292        return false;
1293    }
1294    e.contains("returned to lodging after path")
1295        || e.contains("path failure")
1296        || (e.contains("no path to") && !e.contains("idling"))
1297        || e.contains("pathfinding")
1298        // Soft stuck recovery — worker keeps working / replans; not a player action item.
1299        || e.contains("repathing")
1300        || e.contains("nudged clear")
1301        // A* is not failing — stale latch used to paint this on Iron / Travel.
1302        || e.contains("path unreachable (plan failures 0, leg 0)")
1303        // Auto space rescue — informative in the log, not a red alert.
1304        || e.contains("auto-recovery")
1305        || e.contains("stranded at interior map coords")
1306}
1307
1308/// In-flight `SetWorkerJob` waiting for IntentAck (or a reject Interaction).
1309#[derive(Debug, Clone)]
1310pub struct PendingWorkerJobAck {
1311    pub seq: u32,
1312    pub worker_instance_id: String,
1313    pub worker_label: String,
1314    pub idle: bool,
1315    pub stop_count: usize,
1316    pub prev_route: Option<flatland_protocol::WorkerRouteView>,
1317    pub prev_mode: flatland_protocol::WorkerModeView,
1318    pub prev_step_label: String,
1319    pub prev_last_error: Option<String>,
1320}
1321
1322fn push_inventory_rows(
1323    rows: &mut Vec<InventoryRow>,
1324    depth: usize,
1325    stack: &flatland_protocol::ItemStack,
1326    from: &flatland_protocol::InventoryLocation,
1327    from_parent_instance_id: Option<uuid::Uuid>,
1328    section: InventorySection,
1329) {
1330    push_inventory_rows_filtered(
1331        rows,
1332        depth,
1333        stack,
1334        from,
1335        from_parent_instance_id,
1336        section,
1337        "",
1338    );
1339}
1340
1341fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
1342    if filter.is_empty() {
1343        return true;
1344    }
1345    let f = filter.to_ascii_lowercase();
1346    let name = stack
1347        .display_name
1348        .as_deref()
1349        .unwrap_or("")
1350        .to_ascii_lowercase();
1351    let tid = stack.template_id.to_ascii_lowercase();
1352    name.contains(&f)
1353        || tid.contains(&f)
1354        || stack
1355            .contents
1356            .iter()
1357            .any(|c| stack_matches_filter(c, filter))
1358}
1359
1360fn push_inventory_rows_filtered(
1361    rows: &mut Vec<InventoryRow>,
1362    depth: usize,
1363    stack: &flatland_protocol::ItemStack,
1364    from: &flatland_protocol::InventoryLocation,
1365    from_parent_instance_id: Option<uuid::Uuid>,
1366    section: InventorySection,
1367    filter: &str,
1368) {
1369    if !filter.is_empty() && !stack_matches_filter(stack, filter) {
1370        return;
1371    }
1372    let self_hit = filter.is_empty() || {
1373        let f = filter.to_ascii_lowercase();
1374        let name = stack
1375            .display_name
1376            .as_deref()
1377            .unwrap_or("")
1378            .to_ascii_lowercase();
1379        let tid = stack.template_id.to_ascii_lowercase();
1380        name.contains(&f) || tid.contains(&f)
1381    };
1382    rows.push(InventoryRow {
1383        depth,
1384        stack: stack.clone(),
1385        from: from.clone(),
1386        from_parent_instance_id,
1387        is_equip_shell: false,
1388        is_chest_shell: false,
1389        section,
1390    });
1391    for child in &stack.contents {
1392        if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
1393            push_inventory_rows_filtered(
1394                rows,
1395                depth + 1,
1396                child,
1397                from,
1398                stack.item_instance_id,
1399                section,
1400                if self_hit { "" } else { filter },
1401            );
1402        }
1403    }
1404}
1405
1406#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1407pub enum ShopTab {
1408    #[default]
1409    Buy,
1410    Sell,
1411}
1412
1413#[derive(Debug, Clone)]
1414pub struct NpcChatState {
1415    pub npc_id: String,
1416    pub npc_label: String,
1417    pub lines: Vec<String>,
1418    pub input: String,
1419    pub pending: bool,
1420    pub talk_depth: flatland_protocol::NpcTalkDepth,
1421    pub trade_allowed: bool,
1422    pub banner: Option<String>,
1423    pub suggested_topics: Vec<String>,
1424}
1425
1426impl Default for NpcChatState {
1427    fn default() -> Self {
1428        Self {
1429            npc_id: String::new(),
1430            npc_label: String::new(),
1431            lines: Vec::new(),
1432            input: String::new(),
1433            pending: false,
1434            talk_depth: flatland_protocol::NpcTalkDepth::Full,
1435            trade_allowed: true,
1436            banner: None,
1437            suggested_topics: Vec::new(),
1438        }
1439    }
1440}
1441
1442/// World XY for HUD, probes, and gfx: prefer live entity transform for wildlife combat bodies.
1443pub fn npc_world_xy(state: &GameState, npc: &NpcView) -> (f32, f32) {
1444    npc.entity_id
1445        .and_then(|eid| state.entities.iter().find(|e| e.id == eid))
1446        .map(|e| (e.transform.position.x, e.transform.position.y))
1447        .unwrap_or((npc.x, npc.y))
1448}
1449
1450#[derive(Debug, Clone)]
1451pub struct GameState {
1452    pub session_id: SessionId,
1453    pub entity_id: EntityId,
1454    /// Logged-in character — used to show owner-only container labels.
1455    pub character_id: Option<uuid::Uuid>,
1456    pub tick: Tick,
1457    pub chunk_rev: u64,
1458    pub content_rev: u64,
1459    pub publish_rev: u64,
1460    pub entities: Vec<EntityState>,
1461    pub player: Option<EntityState>,
1462    pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1463    /// Worker Harvest picker cache — region-wide nodes from welcome, kept across AOI ticks.
1464    pub harvest_route_nodes: Vec<flatland_protocol::ResourceNodeView>,
1465    pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1466    pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1467    pub buildings: Vec<BuildingView>,
1468    pub doors: Vec<DoorView>,
1469    pub interior_map: Option<InteriorMapView>,
1470    pub npcs: Vec<NpcView>,
1471    pub blueprints: Vec<BlueprintView>,
1472    /// Wall/roof packs for the B plot-build menu.
1473    pub building_materials: Vec<flatland_protocol::BuildingMaterialView>,
1474    /// Outdoor play AABB origin (meters).
1475    pub world_x0: f32,
1476    pub world_y0: f32,
1477    pub world_width_m: f32,
1478    pub world_height_m: f32,
1479    pub terrain_zones: Vec<TerrainZoneView>,
1480    pub z_platforms: Vec<ZPlatformView>,
1481    pub z_transitions: Vec<ZTransitionView>,
1482    /// Outdoor z-bands saved before an interior overwrite; restored when leaving.
1483    /// Tick deltas never refresh `z_platforms`, so without this, interior platforms stick.
1484    #[doc(hidden)]
1485    pub z_bands_outdoor_backup: Option<(Vec<ZPlatformView>, Vec<ZTransitionView>)>,
1486    pub world_clock: flatland_protocol::WorldClock,
1487    pub inventory: std::collections::HashMap<String, u32>,
1488    pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1489    /// Server item catalog (labels/categories). Survives inventory resync.
1490    pub item_catalog: std::collections::HashMap<String, ItemCatalogEntryView>,
1491    pub logs: VecDeque<String>,
1492    pub intents_sent: u64,
1493    pub ticks_received: u64,
1494    pub connected: bool,
1495    pub disconnect_reason: Option<String>,
1496    pub show_stats: bool,
1497    /// When true, the bottom system LOG dock is collapsed (sidebar gains the space).
1498    pub hud_log_hidden: bool,
1499    pub show_equip_menu: bool,
1500    pub equip_menu_index: usize,
1501    pub show_craft_menu: bool,
1502    pub craft_menu_index: usize,
1503    /// How many timed crafts to queue when confirming the craft menu.
1504    pub craft_batch_quantity: u32,
1505    /// Active craft list tab (Ready default).
1506    pub craft_tab: CraftTab,
1507    /// Search within the active craft tab (`/` to focus).
1508    pub craft_filter: String,
1509    pub craft_filter_focused: bool,
1510    /// Per-character favorites / recent (loaded when character id is known).
1511    pub craft_prefs: crate::craft_prefs::CraftCharacterPrefs,
1512    /// B — wall/roof material picker for plot buildings.
1513    pub show_plot_build_menu: bool,
1514    /// True = navigate walls; false = navigate roofs (Tab switches).
1515    pub plot_build_focus_wall: bool,
1516    pub plot_build_wall_index: usize,
1517    pub plot_build_roof_index: usize,
1518    pub show_shop_menu: bool,
1519    pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1520    pub bank_panel: Option<flatland_protocol::BankPanel>,
1521    pub bank_menu_index: usize,
1522    pub bank_ui_mode: BankUiMode,
1523    pub storage_panel: Option<flatland_protocol::StoragePanel>,
1524    pub market_panel: Option<flatland_protocol::MarketPanel>,
1525    /// Selected row among filtered market browse listings.
1526    pub market_menu_index: usize,
1527    /// Browse / list-pick text filter (`/` to focus).
1528    pub market_filter: String,
1529    pub market_filter_focused: bool,
1530    /// Category group filter (`None` = All). Values match [`inventory_category_group`] labels.
1531    pub market_category_filter: Option<&'static str>,
1532    /// Pending buy confirm: (listing_id, qty, unit_price, line_total, display_name).
1533    pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
1534    pub market_ui_mode: MarketUiMode,
1535    pub storage_menu_index: usize,
1536    pub storage_ui_mode: StorageUiMode,
1537    pub shop_tab: ShopTab,
1538    pub shop_menu_index: usize,
1539    pub shop_quantity: u32,
1540    /// Recent buy/sell lines while the shop panel is open (gfx dock).
1541    pub shop_trade_log: VecDeque<String>,
1542    pub show_npc_verb_menu: bool,
1543    pub npc_verb_target: Option<String>,
1544    pub npc_verb_index: usize,
1545    /// Last refuse/system line shown while the Talk/Trade verb dock is open.
1546    pub npc_verb_notice: Option<String>,
1547    /// Nearby player Speak / Whisper / Trade dock.
1548    pub player_verbs: crate::social::PlayerVerbState,
1549    pub social_chat: crate::social::SocialChatState,
1550    pub trade_ui: crate::social::TradeUiState,
1551    pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1552    pub show_npc_chat: bool,
1553    pub npc_chat: Option<NpcChatState>,
1554    pub show_inventory_menu: bool,
1555    pub inventory_menu_index: usize,
1556    pub inventory_tab: InventoryTab,
1557    pub inventory_filter: String,
1558    pub inventory_filter_focused: bool,
1559    pub show_move_picker: bool,
1560    pub move_picker_index: usize,
1561    pub move_picker: Option<MovePicker>,
1562    pub show_grant_picker: bool,
1563    pub grant_picker_index: usize,
1564    pub grant_picker: Option<GrantTargetPicker>,
1565    pub show_destroy_picker: bool,
1566    pub destroy_confirm_pending: bool,
1567    pub destroy_picker: Option<DestroyPicker>,
1568    /// Deconstruct (salvage) picker for the selected crafted item (`r` in inventory).
1569    pub show_deconstruct_picker: bool,
1570    pub deconstruct_confirm_pending: bool,
1571    pub deconstruct_picker: Option<DestroyPicker>,
1572    /// Rename prompt for a selected container (`n` in inventory).
1573    pub show_rename_prompt: bool,
1574    /// When set, the rename prompt targets a property plot (deed or on-plot).
1575    pub rename_plot_id: Option<uuid::Uuid>,
1576    /// Plot AABB to emphasize on the map (farm picker / deed focus).
1577    pub highlighted_plot_id: Option<uuid::Uuid>,
1578    /// Rename prompt for a hired worker (`n` in workers menu).
1579    pub show_worker_rename: bool,
1580    pub rename_buffer: String,
1581    /// Slot-1 combat target (mirrors server after SetTarget).
1582    pub combat_target: Option<EntityId>,
1583    pub combat_target_label: Option<String>,
1584    /// Local free-aim ground target (world meters) set via Shift+click on open ground.
1585    /// Used as `Intent::Cast.target_point` for ground/either aim-mode abilities.
1586    pub ground_target: Option<(f32, f32, f32)>,
1587    /// Active combat footprints from the server (`plans/39`).
1588    pub combat_fx: Vec<flatland_protocol::CombatFx>,
1589    /// Lingering ground hazards (boss puddles) from the server.
1590    pub ground_hazards: Vec<flatland_protocol::GroundHazardView>,
1591    /// Authored crown property zones (claimable land) — plan 40.
1592    pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1593    /// Tax overlays for claim cost premium preview.
1594    pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1595    /// Growth / fertility overlays (farming, respawn).
1596    pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1597    /// Climate / biome overlays.
1598    pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1599    /// Terrain kind speeds / impassable flags for auto-nav (from content).
1600    pub terrain_kind_nav: Vec<flatland_protocol::TerrainKindNavView>,
1601    /// Claimed property plots near the observer.
1602    pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1603    /// Server knobs for client-side claim quotes.
1604    pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1605    /// Local claim-footprint editor (not server state).
1606    pub claim_mode: Option<ClaimModeState>,
1607    /// Local relocate ghost for a placed chest/lodging (not server state).
1608    pub relocate_mode: Option<RelocateModeState>,
1609    /// Second `f` within a short window confirms selling this plot back to the crown.
1610    pub sell_plot_confirm: Option<uuid::Uuid>,
1611    /// When `sell_plot_confirm` was armed (for double-tap window).
1612    pub sell_plot_armed_at: Option<Instant>,
1613    /// Choose seed type when planting on owned tilled soil (`f`).
1614    pub show_plant_menu: bool,
1615    pub plant_menu_index: usize,
1616    /// Deed-holder farm access panel (public toggle + allow-list).
1617    pub show_farm_access: bool,
1618    /// Draft name for adding a tenant from the farm-access panel.
1619    pub farm_access_name_draft: String,
1620    /// Public / grant discount draft (bps) while editing farm access.
1621    pub farm_access_discount_bps: u32,
1622    /// Selected row in the farm-access panel (0 = public toggle).
1623    pub farm_access_index: usize,
1624    pub plant_quantity: u32,
1625    pub in_combat: bool,
1626    pub auto_attack: bool,
1627    pub combat_has_los: bool,
1628    pub attack_cd_ticks: u64,
1629    pub gcd_ticks: u64,
1630    pub weapon_ability_id: String,
1631    pub mainhand_template_id: Option<String>,
1632    pub mainhand_label: Option<String>,
1633    pub mainhand_instance_id: Option<uuid::Uuid>,
1634    pub offhand_template_id: Option<String>,
1635    pub offhand_label: Option<String>,
1636    pub offhand_instance_id: Option<uuid::Uuid>,
1637    pub mainhand_hand_slots: u8,
1638    pub defense: Option<flatland_protocol::DefenseHud>,
1639    /// Worn body-slot items — armor, cloak, jewelry, backpack, belt.
1640    pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1641    pub carry_mass: f32,
1642    pub carry_mass_max: f32,
1643    pub encumbrance: flatland_protocol::EncumbranceState,
1644    /// Walk m/s after encumbrance / DEX (from combat HUD). `0` until first HUD.
1645    pub move_speed_mps: f32,
1646    /// Encumbrance+status walk multiplier (`1.0` unloaded). `0` until first HUD.
1647    pub move_speed_mult: f32,
1648    /// Full nested inventory stacks from the server (root only; worn are separate).
1649    pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1650    /// Keys stowed on the virtual keychain (zero carry mass).
1651    pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1652    /// Whisper stones in the pouch (zero carry mass).
1653    pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1654    /// Active status effects on the local player (buff/debuff strip).
1655    pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1656    pub combat_target_detail: Option<CombatTargetHud>,
1657    pub cast_progress: Option<CastProgressHud>,
1658    /// Till / plant / other non-combat timed channels on the local player.
1659    pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1660    /// Owned plot underfoot offer (pad + storage pool) for the B menu.
1661    pub plot_build_offer: Option<flatland_protocol::PlotBuildOfferHud>,
1662    pub ability_cooldowns: Vec<AbilityCooldownHud>,
1663    pub blocking_active: bool,
1664    pub max_target_slots: u8,
1665    pub combat_slots: Vec<CombatSlotHud>,
1666    pub rotation_presets: Vec<RotationPreset>,
1667    /// Learned abilities from combat HUD (usable casts).
1668    pub known_abilities: Vec<String>,
1669    /// Aim / blast metadata for known abilities (ground cast UX), keyed by ability id.
1670    pub ability_meta: std::collections::HashMap<String, flatland_protocol::AbilityMetaHud>,
1671    /// Per-ability mastery from combat HUD (tier / XP).
1672    pub ability_mastery: std::collections::HashMap<String, flatland_protocol::AbilityMasteryHud>,
1673    /// Server-persisted hotbar bindings (index 0 = key 1).
1674    pub hotbar: Vec<Option<String>>,
1675    /// Max abilities allowed in one rotation (mind score).
1676    pub max_abilities_per_rotation: u8,
1677    pub show_loadout_menu: bool,
1678    pub show_keychain_menu: bool,
1679    pub keychain_menu_index: usize,
1680    pub show_rotation_editor: bool,
1681    /// Selected rotation preset in the loadout menu.
1682    pub loadout_menu_index: usize,
1683    /// Selected hotbar slot (`1`–`9`) for bind/clear in the loadout menu.
1684    pub loadout_hotbar_slot: u8,
1685    /// Selected known-ability row in the loadout menu.
1686    pub loadout_ability_index: usize,
1687    /// When true, ↑/↓ navigate presets; when false, navigate known abilities.
1688    pub loadout_focus_presets: bool,
1689    pub rotation_editor: RotationEditorState,
1690    /// True after a harvest intent is accepted until result/reject/disconnect.
1691    pub harvest_in_progress: bool,
1692    /// Wall-clock start of the current harvest; clears stale client state on timeout.
1693    pub harvest_started_at: Option<Instant>,
1694    /// Craft log deferred until the server acks the craft intent.
1695    pub pending_craft_ack: Option<(u32, String, u32)>,
1696    /// Blueprint kept on the Ready tab while its craft channel runs (inputs
1697    /// are spent at begin, so `can_craft` goes false before progress finishes).
1698    pub craft_channel_blueprint_id: Option<String>,
1699    /// True after a craft timed-channel HUD was observed for the current pin.
1700    /// Ticks *before* the channel starts must not drop the pin — the last batch
1701    /// item spends remaining inputs at begin, so Ready would otherwise empty
1702    /// and hide in-progress UI.
1703    pub craft_channel_seen: bool,
1704    pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1705    pub interactables: Vec<flatland_protocol::InteractableView>,
1706    pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1707    pub career: Option<flatland_protocol::PlayerCareerView>,
1708    pub character_sheet_tab: CharacterSheetTab,
1709    pub ledger_period: LedgerPeriod,
1710    pub show_quest_offer: bool,
1711    pub pending_quest_offers: Vec<flatland_protocol::QuestOffer>,
1712    pub quest_offer_index: usize,
1713    pub show_quest_menu: bool,
1714    pub quest_menu_index: usize,
1715    pub quest_withdraw_confirm: bool,
1716    pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1717    pub show_workers_menu: bool,
1718    pub workers_menu_index: usize,
1719    pub worker_dismiss_confirmation: Option<WorkerDismissConfirmation>,
1720    /// Workers panel: one-line rows instead of full cards (more on screen).
1721    pub workers_menu_compact: bool,
1722    /// Coarse `step:` line held per worker so AOI ticks cannot thrash the menu.
1723    /// Public so out-of-crate tests can construct [`GameState`].
1724    pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1725    /// Held worker route errors (readable when server clears `last_error` each tick).
1726    pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1727    /// Wall-clock expiry for defender HP rings after their last observed hit.
1728    pub worker_health_ring_until: BTreeMap<EntityId, Instant>,
1729    /// Set after a hire request until its worker appears in a roster update.
1730    pub pending_worker_hire_since: Option<Instant>,
1731    /// Give-item sheet opened from the workers menu (`g`) — pick which item to give.
1732    pub show_worker_give_picker: bool,
1733    pub worker_give_picker_index: usize,
1734    pub worker_give_picker: Option<WorkerGivePicker>,
1735    /// Inventory `g` — pick which nearby worker receives the selected stack.
1736    pub show_worker_give_target_picker: bool,
1737    pub worker_give_target_picker_index: usize,
1738    pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1739    /// Take-item sheet opened from the workers menu (`i`) — pick which worker stack to take.
1740    pub show_worker_take_picker: bool,
1741    pub worker_take_picker_index: usize,
1742    pub worker_take_picker: Option<WorkerTakePicker>,
1743    /// Teach-blueprint sheet opened from the workers menu (`t`).
1744    pub show_worker_teach_picker: bool,
1745    pub worker_teach_picker_index: usize,
1746    pub worker_teach_picker: Option<WorkerTeachPicker>,
1747    /// Active harvest-route editor (`h` → `e` on a worker).
1748    pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1749    /// Awaiting IntentAck for the last route save (`SetWorkerJob`).
1750    pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1751    /// Worker currently paused via `AttendHiredWorker` (opened with `f`).
1752    pub attending_worker_instance_id: Option<String>,
1753    /// Server progression curve from the latest combat HUD (matches server-settings.yaml).
1754    pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1755}
1756
1757#[derive(Debug, Clone, PartialEq, Eq)]
1758pub enum NpcVerbAction {
1759    Talk,
1760    Trade,
1761    Bank,
1762    Storage,
1763    Market,
1764    QuestTalk { quest_id: String },
1765    QuestGive { quest_id: String },
1766}
1767
1768#[derive(Debug, Clone, PartialEq, Eq)]
1769pub struct NpcVerbChoice {
1770    pub label: String,
1771    pub action: NpcVerbAction,
1772}
1773
1774impl std::fmt::Display for NpcVerbChoice {
1775    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1776        f.write_str(&self.label)
1777    }
1778}
1779
1780impl GameState {
1781    pub fn selected_quest_offer(&self) -> Option<&flatland_protocol::QuestOffer> {
1782        self.pending_quest_offers.get(self.quest_offer_index)
1783    }
1784
1785    pub fn push_quest_offer(&mut self, offer: flatland_protocol::QuestOffer) {
1786        if self
1787            .pending_quest_offers
1788            .iter()
1789            .any(|existing| existing.quest_id == offer.quest_id)
1790        {
1791            self.show_quest_offer = true;
1792            return;
1793        }
1794        self.pending_quest_offers.push(offer);
1795        self.show_quest_offer = true;
1796    }
1797
1798    pub fn remove_quest_offer(&mut self, quest_id: &str) {
1799        self.pending_quest_offers
1800            .retain(|offer| offer.quest_id != quest_id);
1801        if self.pending_quest_offers.is_empty() {
1802            self.show_quest_offer = false;
1803            self.quest_offer_index = 0;
1804            return;
1805        }
1806        self.quest_offer_index = self
1807            .quest_offer_index
1808            .min(self.pending_quest_offers.len() - 1);
1809        self.show_quest_offer = true;
1810    }
1811
1812    pub fn clear_quest_offers(&mut self) {
1813        self.pending_quest_offers.clear();
1814        self.quest_offer_index = 0;
1815        self.show_quest_offer = false;
1816    }
1817
1818    pub fn move_quest_offer_selection(&mut self, delta: i32) {
1819        let n = self.pending_quest_offers.len();
1820        if n == 0 {
1821            self.quest_offer_index = 0;
1822            return;
1823        }
1824        let idx = self.quest_offer_index as i32;
1825        self.quest_offer_index = (idx + delta).rem_euclid(n as i32) as usize;
1826    }
1827
1828    pub fn push_log(&mut self, line: impl Into<String>) {
1829        self.logs.push_back(line.into());
1830        while self.logs.len() > MAX_LOG_LINES {
1831            self.logs.pop_front();
1832        }
1833    }
1834
1835    pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1836        self.shop_trade_log.push_back(line.into());
1837        while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1838            self.shop_trade_log.pop_front();
1839        }
1840    }
1841
1842    pub fn clear_shop_trade_log(&mut self) {
1843        self.shop_trade_log.clear();
1844    }
1845
1846    fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1847        if !self.show_shop_menu {
1848            return;
1849        }
1850        let msg = notice.message.trim();
1851        if msg.is_empty() {
1852            return;
1853        }
1854        if notice.coins_delta != 0
1855            || msg.starts_with("Bought ")
1856            || msg.starts_with("Sold ")
1857            || msg.contains("taught you how to craft")
1858            || msg.starts_with("need ")
1859        {
1860            self.push_shop_trade_log(msg);
1861        }
1862    }
1863
1864    pub fn is_alive(&self) -> bool {
1865        self.player
1866            .as_ref()
1867            .and_then(|p| p.vitals)
1868            .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1869            .unwrap_or(true)
1870    }
1871
1872    pub fn push_audio(&mut self, cue: crate::social::AudioCue) {
1873        self.social_chat.push_cue(cue);
1874    }
1875
1876    /// Rising/falling edges for combat and progression SFX (call after snapshot/tick apply).
1877    fn sync_gameplay_audio(&mut self) {
1878        use crate::social::AudioCue;
1879        use flatland_protocol::PrimaryAttributes;
1880
1881        let alive = self.is_alive();
1882        let casting = self.cast_progress.is_some();
1883        let telegraph = self.focus_attack_telegraph_active();
1884        let in_aoe = self.player_inside_spatial_telegraph();
1885        let quest_sig = self.quest_audio_signature();
1886        let entity_id = self.entity_id;
1887        let char_level = self
1888            .player
1889            .as_ref()
1890            .and_then(|p| p.attributes)
1891            .map(|a| {
1892                PrimaryAttributes::display(a.strength)
1893                    .saturating_add(PrimaryAttributes::display(a.dexterity))
1894                    .saturating_add(PrimaryAttributes::display(a.intelligence))
1895                    .saturating_add(PrimaryAttributes::display(a.stamina))
1896                    .saturating_add(PrimaryAttributes::display(a.vitality))
1897                    .saturating_add(PrimaryAttributes::display(a.wisdom))
1898                    .saturating_add(PrimaryAttributes::display(a.charisma))
1899            })
1900            .unwrap_or(0);
1901
1902        let fx_ids: Vec<u64> = self.combat_fx.iter().map(|fx| fx.id).collect();
1903        let mut hit_cues = Vec::new();
1904        {
1905            let seen = &self.social_chat.audio_seen_fx_ids;
1906            for fx in &self.combat_fx {
1907                if seen.contains(&fx.id) {
1908                    continue;
1909                }
1910                let Some(hit) = fx.hits.iter().find(|h| h.entity_id == entity_id) else {
1911                    continue;
1912                };
1913                if hit.outcome == CombatFxHitOutcome::Blocked {
1914                    hit_cues.push(AudioCue::CombatBlock);
1915                } else {
1916                    let heavy = matches!(
1917                        fx.kind,
1918                        CombatFxKind::Sphere | CombatFxKind::Cone | CombatFxKind::Beam
1919                    );
1920                    hit_cues.push(if heavy {
1921                        AudioCue::CombatHitHeavy
1922                    } else {
1923                        AudioCue::CombatHitLight
1924                    });
1925                }
1926            }
1927        }
1928
1929        let audio = &mut self.social_chat;
1930        if !audio.audio_bootstrapped {
1931            audio.audio_was_alive = alive;
1932            audio.audio_was_casting = casting;
1933            audio.audio_had_target_telegraph = telegraph;
1934            audio.audio_was_in_aoe = in_aoe;
1935            audio.audio_quest_sig = quest_sig;
1936            audio.audio_char_level = char_level;
1937            audio.audio_seen_fx_ids = fx_ids;
1938            audio.audio_bootstrapped = true;
1939            return;
1940        }
1941
1942        if telegraph && !audio.audio_had_target_telegraph {
1943            audio.push_cue(AudioCue::CombatTelegraphStart);
1944        } else if !telegraph && audio.audio_had_target_telegraph {
1945            audio.push_cue(AudioCue::CombatTelegraphImpact);
1946        }
1947        audio.audio_had_target_telegraph = telegraph;
1948
1949        if in_aoe && !audio.audio_was_in_aoe {
1950            audio.push_cue(AudioCue::CombatAoeWarn);
1951        }
1952        audio.audio_was_in_aoe = in_aoe;
1953
1954        if casting && !audio.audio_was_casting {
1955            audio.push_cue(AudioCue::AbilityCastSelf);
1956        }
1957        audio.audio_was_casting = casting;
1958
1959        if !alive && audio.audio_was_alive {
1960            audio.push_cue(AudioCue::PlayerDeath);
1961        }
1962        audio.audio_was_alive = alive;
1963
1964        if quest_sig != audio.audio_quest_sig && audio.audio_quest_sig != 0 {
1965            audio.push_cue(AudioCue::QuestUpdate);
1966        }
1967        audio.audio_quest_sig = quest_sig;
1968
1969        if char_level > audio.audio_char_level && audio.audio_char_level > 0 {
1970            audio.push_cue(AudioCue::LevelUp);
1971        }
1972        audio.audio_char_level = char_level;
1973
1974        for cue in hit_cues {
1975            audio.push_cue(cue);
1976        }
1977        audio.audio_seen_fx_ids = fx_ids;
1978    }
1979
1980    fn focus_attack_telegraph_active(&self) -> bool {
1981        let Some(tid) = self.combat_target else {
1982            return false;
1983        };
1984        self.entities
1985            .iter()
1986            .find(|e| e.id == tid)
1987            .map(|e| {
1988                e.combat_cues.iter().any(|c| {
1989                    matches!(c.kind, CombatCueKind::AttackTelegraph) && c.until_tick > self.tick
1990                })
1991            })
1992            .unwrap_or(false)
1993    }
1994
1995    fn player_inside_spatial_telegraph(&self) -> bool {
1996        let (px, py) = self.player_position();
1997        for e in &self.entities {
1998            for cue in &e.combat_cues {
1999                if !matches!(cue.kind, CombatCueKind::AttackTelegraph)
2000                    || cue.until_tick <= self.tick
2001                {
2002                    continue;
2003                }
2004                let Some(kind) = cue.telegraph_kind else {
2005                    continue;
2006                };
2007                let (ox, oy) = match (cue.origin_x, cue.origin_y) {
2008                    (Some(x), Some(y)) => (x, y),
2009                    _ => continue,
2010                };
2011                match kind {
2012                    CombatFxKind::Sphere => {
2013                        let r = cue.radius_m.unwrap_or(1.0);
2014                        let dx = px - ox;
2015                        let dy = py - oy;
2016                        if dx * dx + dy * dy <= r * r {
2017                            return true;
2018                        }
2019                    }
2020                    CombatFxKind::Cone | CombatFxKind::MeleeArc => {
2021                        let reach = cue.reach_m.unwrap_or(2.0);
2022                        let yaw = cue.yaw.unwrap_or(0.0);
2023                        let arc = cue.arc_deg.unwrap_or(90.0).to_radians();
2024                        let dx = px - ox;
2025                        let dy = py - oy;
2026                        let dist = (dx * dx + dy * dy).sqrt();
2027                        if dist > reach || dist < 0.05 {
2028                            continue;
2029                        }
2030                        let ang = dx.atan2(dy);
2031                        let mut delta = ang - yaw;
2032                        while delta > std::f32::consts::PI {
2033                            delta -= std::f32::consts::TAU;
2034                        }
2035                        while delta < -std::f32::consts::PI {
2036                            delta += std::f32::consts::TAU;
2037                        }
2038                        if delta.abs() <= arc * 0.5 {
2039                            return true;
2040                        }
2041                    }
2042                    _ => {}
2043                }
2044            }
2045        }
2046        false
2047    }
2048
2049    fn quest_audio_signature(&self) -> u64 {
2050        use std::collections::hash_map::DefaultHasher;
2051        use std::hash::{Hash, Hasher};
2052        let mut h = DefaultHasher::new();
2053        for q in &self.quest_log {
2054            q.quest_id.hash(&mut h);
2055            format!("{:?}", q.status).hash(&mut h);
2056            q.current_step_id.hash(&mut h);
2057            for o in &q.objectives {
2058                o.done.hash(&mut h);
2059                o.current.hash(&mut h);
2060            }
2061        }
2062        h.finish()
2063    }
2064
2065    /// Verb menu entries for the current `npc_verb_target`.
2066    pub fn npc_verb_options(&self) -> Vec<NpcVerbChoice> {
2067        let Some(ref id) = self.npc_verb_target else {
2068            return vec![];
2069        };
2070        let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
2071            return self.with_quest_verbs(id, vec![Self::talk_choice()]);
2072        };
2073        let role = npc.role.as_str();
2074        let rest = if Self::npc_role_is_bank(role) {
2075            vec![
2076                NpcVerbChoice {
2077                    label: "Bank".into(),
2078                    action: NpcVerbAction::Bank,
2079                },
2080                Self::talk_choice(),
2081            ]
2082        } else if Self::npc_role_is_storage(role) {
2083            vec![
2084                NpcVerbChoice {
2085                    label: "Storage".into(),
2086                    action: NpcVerbAction::Storage,
2087                },
2088                Self::talk_choice(),
2089            ]
2090        } else if Self::npc_role_is_market(role) {
2091            vec![
2092                NpcVerbChoice {
2093                    label: "Market".into(),
2094                    action: NpcVerbAction::Market,
2095                },
2096                Self::talk_choice(),
2097            ]
2098        } else if npc.can_trade || Self::npc_role_can_trade(role) {
2099            vec![
2100                Self::talk_choice(),
2101                NpcVerbChoice {
2102                    label: "Trade".into(),
2103                    action: NpcVerbAction::Trade,
2104                },
2105            ]
2106        } else {
2107            vec![Self::talk_choice()]
2108        };
2109        self.with_quest_verbs(id, rest)
2110    }
2111
2112    fn talk_choice() -> NpcVerbChoice {
2113        NpcVerbChoice {
2114            label: "Talk".into(),
2115            action: NpcVerbAction::Talk,
2116        }
2117    }
2118
2119    fn with_quest_verbs(&self, npc_id: &str, rest: Vec<NpcVerbChoice>) -> Vec<NpcVerbChoice> {
2120        let mut opts = self.quest_verb_choices(npc_id);
2121        opts.extend(rest);
2122        opts
2123    }
2124
2125    fn quest_verb_choices(&self, npc_id: &str) -> Vec<NpcVerbChoice> {
2126        if let Some(npc) = self.npcs.iter().find(|n| n.id == npc_id) {
2127            if !npc.quest_verbs.is_empty() {
2128                return npc
2129                    .quest_verbs
2130                    .iter()
2131                    .map(|v| {
2132                        let action = if v.kind == flatland_protocol::NpcQuestVerb::KIND_GIVE {
2133                            NpcVerbAction::QuestGive {
2134                                quest_id: v.quest_id.clone(),
2135                            }
2136                        } else {
2137                            NpcVerbAction::QuestTalk {
2138                                quest_id: v.quest_id.clone(),
2139                            }
2140                        };
2141                        NpcVerbChoice {
2142                            label: v.label.clone(),
2143                            action,
2144                        }
2145                    })
2146                    .collect();
2147            }
2148        }
2149        let catalog_ref = self.npc_quest_catalog_ref(npc_id);
2150        let mut opts = Vec::new();
2151        for q in &self.quest_log {
2152            if q.status != flatland_protocol::QuestStatusView::Active {
2153                continue;
2154            }
2155            let title = if q.title.trim().is_empty() {
2156                "Quest".to_string()
2157            } else {
2158                q.title.clone()
2159            };
2160            for o in &q.objectives {
2161                if o.done || o.npc_ref.as_deref() != Some(catalog_ref.as_str()) {
2162                    continue;
2163                }
2164                if o.kind == "give_item" {
2165                    opts.push(NpcVerbChoice {
2166                        label: format!("Turn in: {title}"),
2167                        action: NpcVerbAction::QuestGive {
2168                            quest_id: q.quest_id.clone(),
2169                        },
2170                    });
2171                } else if o.kind == "talk_npc" {
2172                    opts.push(NpcVerbChoice {
2173                        label: title.clone(),
2174                        action: NpcVerbAction::QuestTalk {
2175                            quest_id: q.quest_id.clone(),
2176                        },
2177                    });
2178                }
2179            }
2180        }
2181        opts
2182    }
2183
2184    fn npc_quest_catalog_ref(&self, npc_id: &str) -> String {
2185        self.npcs
2186            .iter()
2187            .find(|n| n.id == npc_id)
2188            .and_then(|n| n.paperdoll_ref.clone())
2189            .unwrap_or_else(|| npc_id.to_string())
2190    }
2191
2192    fn count_inventory_template(&self, template: &str) -> u32 {
2193        self.inventory_stacks
2194            .iter()
2195            .filter(|s| s.template_id == template)
2196            .map(|s| s.quantity)
2197            .sum()
2198    }
2199
2200    fn npc_role_can_trade(role: &str) -> bool {
2201        matches!(
2202            role,
2203            "broker"
2204                | "cook"
2205                | "farmer"
2206                | "merchant"
2207                | "smith"
2208                | "blacksmith"
2209                | "woodworker"
2210                | "butcher"
2211                | "armorer"
2212                | "weaponsmith"
2213                | "wanderer"
2214        )
2215    }
2216
2217    fn npc_role_is_bank(role: &str) -> bool {
2218        role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
2219    }
2220
2221    fn npc_role_is_storage(role: &str) -> bool {
2222        role.eq_ignore_ascii_case("storage_manager")
2223    }
2224
2225    fn npc_role_is_market(role: &str) -> bool {
2226        role.eq_ignore_ascii_case("market_clerk")
2227    }
2228
2229    pub fn bank_menu_options(&self) -> Vec<&'static str> {
2230        vec![
2231            "Deposit…",
2232            "Withdraw…",
2233            "Deposit all",
2234            "Withdraw all",
2235            "Transfer…",
2236        ]
2237    }
2238
2239    pub fn storage_menu_options(&self) -> Vec<String> {
2240        let mut opts = vec!["Store…".into(), "Take…".into()];
2241        if let Some(panel) = &self.storage_panel {
2242            for dest in &panel.ship_destinations {
2243                opts.push(format!(
2244                    "Ship → {} ({} cp / {} ticks)",
2245                    dest.label, dest.fee_copper, dest.travel_ticks
2246                ));
2247            }
2248        }
2249        opts
2250    }
2251
2252    /// Loose on-person stacks eligible for town-storage store.
2253    /// Excludes hand-equipped weapons/tools (unequip first) — worn body gear is
2254    /// already outside root inventory.
2255    pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
2256        let equipped = self.hand_equipped_instance_ids();
2257        self.person_rows()
2258            .into_iter()
2259            .filter(|r| r.depth == 0)
2260            .filter_map(|r| {
2261                let id = r.stack.item_instance_id?;
2262                if equipped.contains(&id) {
2263                    return None;
2264                }
2265                Some(StoragePickOption {
2266                    item_instance_id: id,
2267                    template_id: r.stack.template_id.clone(),
2268                    label: storage_stack_label(&r.stack),
2269                    quantity: r.stack.quantity,
2270                    category: r.stack.category.clone().unwrap_or_default(),
2271                })
2272            })
2273            .collect()
2274    }
2275
2276    /// Instance ids currently occupying mainhand / offhand (for store / list filters).
2277    pub fn hand_equipped_instance_ids(&self) -> std::collections::HashSet<uuid::Uuid> {
2278        let mut ids = std::collections::HashSet::new();
2279        if let Some(id) = self.mainhand_instance_id {
2280            ids.insert(id);
2281        } else if let Some(tid) = &self.mainhand_template_id {
2282            if let Some(id) = self
2283                .inventory_stacks
2284                .iter()
2285                .find(|s| &s.template_id == tid)
2286                .and_then(|s| s.item_instance_id)
2287            {
2288                ids.insert(id);
2289            }
2290        }
2291        if let Some(id) = self.offhand_instance_id {
2292            ids.insert(id);
2293        } else if let Some(tid) = &self.offhand_template_id {
2294            if let Some(id) = self
2295                .inventory_stacks
2296                .iter()
2297                .find(|s| {
2298                    &s.template_id == tid
2299                        && s.item_instance_id.is_some_and(|iid| !ids.contains(&iid))
2300                })
2301                .and_then(|s| s.item_instance_id)
2302            {
2303                ids.insert(id);
2304            }
2305        }
2306        ids
2307    }
2308
2309    /// Vault stacks eligible for take / ship.
2310    pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
2311        let Some(panel) = &self.storage_panel else {
2312            return Vec::new();
2313        };
2314        panel
2315            .contents
2316            .iter()
2317            .filter_map(|s| {
2318                let id = s.item_instance_id?;
2319                Some(StoragePickOption {
2320                    item_instance_id: id,
2321                    template_id: s.template_id.clone(),
2322                    label: storage_stack_label(s),
2323                    quantity: s.quantity,
2324                    category: s.category.clone().unwrap_or_default(),
2325                })
2326            })
2327            .collect()
2328    }
2329
2330    /// Source rows for market list: on-person, then each eligible vault (only if they hold stacks).
2331    pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
2332        let mut opts = Vec::new();
2333        if !self
2334            .market_list_item_options(&MarketListSourceKind::Person)
2335            .is_empty()
2336        {
2337            opts.push((MarketListSourceKind::Person, "On person".into()));
2338        }
2339        if let Some(panel) = &self.market_panel {
2340            for vault in &panel.list_vaults {
2341                let source = MarketListSourceKind::TownStorage {
2342                    building_id: vault.building_id.clone(),
2343                };
2344                if self.market_list_item_options(&source).is_empty() {
2345                    continue;
2346                }
2347                let label = if vault.building_label.is_empty() {
2348                    format!("Town storage ({})", vault.building_id)
2349                } else {
2350                    format!("Town storage — {}", vault.building_label)
2351                };
2352                opts.push((source, label));
2353            }
2354        }
2355        opts
2356    }
2357
2358    /// Stacks eligible to list from the chosen market source (excludes non-listable templates).
2359    pub fn market_list_item_options(
2360        &self,
2361        source: &MarketListSourceKind,
2362    ) -> Vec<StoragePickOption> {
2363        let filter = self.market_filter.as_str();
2364        let cat_filter = self.market_category_filter;
2365        let mut opts: Vec<StoragePickOption> = match source {
2366            MarketListSourceKind::Person => {
2367                let equipped = self.hand_equipped_instance_ids();
2368                self.person_rows()
2369                    .into_iter()
2370                    .filter(|r| r.depth == 0)
2371                    .filter(|r| self.stack_is_market_listable(&r.stack))
2372                    .filter_map(|r| {
2373                        let id = r.stack.item_instance_id?;
2374                        if equipped.contains(&id) {
2375                            return None;
2376                        }
2377                        Some(StoragePickOption {
2378                            item_instance_id: id,
2379                            template_id: r.stack.template_id.clone(),
2380                            label: storage_stack_label(&r.stack),
2381                            quantity: r.stack.quantity,
2382                            category: r
2383                                .stack
2384                                .category
2385                                .clone()
2386                                .or_else(|| {
2387                                    self.inventory_item_category(&r.stack.template_id)
2388                                        .map(str::to_string)
2389                                })
2390                                .unwrap_or_default(),
2391                        })
2392                    })
2393                    .collect()
2394            }
2395            MarketListSourceKind::TownStorage { building_id } => {
2396                let Some(panel) = &self.market_panel else {
2397                    return Vec::new();
2398                };
2399                let Some(vault) = panel
2400                    .list_vaults
2401                    .iter()
2402                    .find(|v| &v.building_id == building_id)
2403                else {
2404                    return Vec::new();
2405                };
2406                vault
2407                    .contents
2408                    .iter()
2409                    .filter(|s| self.stack_is_market_listable(s))
2410                    .filter_map(|s| {
2411                        let id = s.item_instance_id?;
2412                        Some(StoragePickOption {
2413                            item_instance_id: id,
2414                            template_id: s.template_id.clone(),
2415                            label: storage_stack_label(s),
2416                            quantity: s.quantity,
2417                            category: s
2418                                .category
2419                                .clone()
2420                                .or_else(|| {
2421                                    self.inventory_item_category(&s.template_id)
2422                                        .map(str::to_string)
2423                                })
2424                                .unwrap_or_default(),
2425                        })
2426                    })
2427                    .collect()
2428            }
2429        };
2430        opts.retain(|o| {
2431            if !list_label_matches(&o.label, filter) {
2432                return false;
2433            }
2434            if let Some(group) = cat_filter {
2435                inventory_category_group(&o.category).0 == group
2436            } else {
2437                true
2438            }
2439        });
2440        opts
2441    }
2442
2443    /// Catalog `base_value_copper` when synced on stacks or inventory hints.
2444    pub fn item_base_value_copper_hint(&self, template_id: &str) -> Option<u32> {
2445        if let Some(hint) = self.inventory_hints.get(template_id) {
2446            if let Some(v) = hint.base_value_copper.filter(|v| *v > 0) {
2447                return Some(v);
2448            }
2449        }
2450        if let Some(v) = self
2451            .inventory_stacks
2452            .iter()
2453            .find(|s| s.template_id == template_id)
2454            .and_then(|s| s.base_value_copper.filter(|v| *v > 0))
2455        {
2456            return Some(v);
2457        }
2458        self.market_panel.as_ref().and_then(|panel| {
2459            panel.list_vaults.iter().find_map(|vault| {
2460                vault.contents.iter().find_map(|stack| {
2461                    (stack.template_id == template_id)
2462                        .then(|| stack.base_value_copper.filter(|v| *v > 0))
2463                        .flatten()
2464                })
2465            })
2466        })
2467    }
2468
2469    /// Estimated net copper per unit for hall NPC-price dump queue (default buyer rates).
2470    pub fn npc_market_dump_unit_estimate(&self, template_id: &str) -> Option<u32> {
2471        let base = self.item_base_value_copper_hint(template_id)?;
2472        npc_market_dump_unit_estimate_copper(base)
2473    }
2474
2475    fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
2476        if crate::currency::is_currency(&stack.template_id) {
2477            return false;
2478        }
2479        if let Some(flag) = stack.listable {
2480            return flag;
2481        }
2482        if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
2483            return hint.listable;
2484        }
2485        let cat = stack
2486            .category
2487            .as_deref()
2488            .or_else(|| self.inventory_item_category(&stack.template_id))
2489            .unwrap_or("");
2490        category_default_listable(cat)
2491    }
2492
2493    /// Category group labels present in the current browse book or list-pick source.
2494    pub fn market_available_category_groups(&self) -> Vec<&'static str> {
2495        let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
2496        match &self.market_ui_mode {
2497            MarketUiMode::ListPick { source, .. } => {
2498                let raw: Vec<_> = match source {
2499                    MarketListSourceKind::Person => self
2500                        .person_rows()
2501                        .into_iter()
2502                        .filter(|r| r.depth == 0)
2503                        .filter(|r| self.stack_is_market_listable(&r.stack))
2504                        .filter(|r| {
2505                            list_label_matches(&storage_stack_label(&r.stack), &self.market_filter)
2506                        })
2507                        .map(|r| {
2508                            r.stack
2509                                .category
2510                                .clone()
2511                                .or_else(|| {
2512                                    self.inventory_item_category(&r.stack.template_id)
2513                                        .map(str::to_string)
2514                                })
2515                                .unwrap_or_default()
2516                        })
2517                        .collect(),
2518                    MarketListSourceKind::TownStorage { building_id } => self
2519                        .market_panel
2520                        .as_ref()
2521                        .and_then(|p| p.list_vaults.iter().find(|v| &v.building_id == building_id))
2522                        .map(|vault| {
2523                            vault
2524                                .contents
2525                                .iter()
2526                                .filter(|s| self.stack_is_market_listable(s))
2527                                .filter(|s| {
2528                                    list_label_matches(&storage_stack_label(s), &self.market_filter)
2529                                })
2530                                .map(|s| {
2531                                    s.category
2532                                        .clone()
2533                                        .or_else(|| {
2534                                            self.inventory_item_category(&s.template_id)
2535                                                .map(str::to_string)
2536                                        })
2537                                        .unwrap_or_default()
2538                                })
2539                                .collect::<Vec<_>>()
2540                        })
2541                        .unwrap_or_default(),
2542                };
2543                for category in raw {
2544                    let (label, ord) = inventory_category_group(&category);
2545                    seen.insert(ord, label);
2546                }
2547            }
2548            _ => {
2549                if let Some(panel) = &self.market_panel {
2550                    for listing in &panel.listings {
2551                        if !list_label_matches(&listing.display_name, &self.market_filter)
2552                            && !list_label_matches(&listing.seller_label, &self.market_filter)
2553                        {
2554                            continue;
2555                        }
2556                        let (label, ord) = inventory_category_group(&listing.category);
2557                        seen.insert(ord, label);
2558                    }
2559                }
2560            }
2561        }
2562        seen.into_values().collect()
2563    }
2564
2565    /// Indices into `market_panel.listings` after category + text filter.
2566    pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
2567        let Some(panel) = &self.market_panel else {
2568            return Vec::new();
2569        };
2570        let filter = self.market_filter.as_str();
2571        let cat_filter = self.market_category_filter;
2572        panel
2573            .listings
2574            .iter()
2575            .enumerate()
2576            .filter(|(_, listing)| {
2577                if !list_label_matches(&listing.display_name, filter)
2578                    && !list_label_matches(&listing.seller_label, filter)
2579                    && !list_label_matches(&listing.template_id, filter)
2580                {
2581                    return false;
2582                }
2583                if let Some(group) = cat_filter {
2584                    inventory_category_group(&listing.category).0 == group
2585                } else {
2586                    true
2587                }
2588            })
2589            .map(|(i, _)| i)
2590            .collect()
2591    }
2592
2593    pub fn clear_harvest_state(&mut self) {
2594        self.harvest_in_progress = false;
2595        self.harvest_started_at = None;
2596    }
2597
2598    fn harvest_state_stale(&self) -> bool {
2599        match self.harvest_started_at {
2600            Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
2601            None => self.harvest_in_progress,
2602        }
2603    }
2604
2605    pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
2606        self.player.as_ref().and_then(|p| p.vitals)
2607    }
2608
2609    pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
2610        let materials_ok = blueprint.inputs.iter().all(|input| {
2611            self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
2612        });
2613        let tools_ok = blueprint
2614            .required_tools
2615            .iter()
2616            .all(|tool| self.player_has_craft_tool(&tool.item));
2617        let station_ok = match blueprint.station.as_deref() {
2618            None | Some("hand") => true,
2619            Some(tag) => self.player_at_station_tag(tag),
2620        };
2621        materials_ok && tools_ok && station_ok && self.craft_has_vessel_room_for_output(blueprint)
2622    }
2623
2624    /// Inventory/worn **or** a nearby placed furniture tool in the same space.
2625    pub fn player_has_craft_tool(&self, tool_template: &str) -> bool {
2626        if self.inventory.get(tool_template).copied().unwrap_or(0) >= 1 {
2627            return true;
2628        }
2629        let Some(player) = self.player.as_ref() else {
2630            return false;
2631        };
2632        let px = player.transform.position.x;
2633        let py = player.transform.position.y;
2634        // Match sim `CONTAINER_INTERACTION_RADIUS_M`.
2635        const RANGE: f32 = 3.0;
2636        self.placed_containers.iter().any(|c| {
2637            if c.template_id != tool_template {
2638                return false;
2639            }
2640            if !self.placed_container_in_current_space(c) {
2641                return false;
2642            }
2643            let dx = c.x - px;
2644            let dy = c.y - py;
2645            dx * dx + dy * dy <= RANGE * RANGE
2646        })
2647    }
2648
2649    /// Bulk/liquid craft outputs need an empty (or matching) vessel after inputs drain.
2650    fn craft_output_needs_vessel(&self, blueprint: &BlueprintView) -> bool {
2651        matches!(
2652            self.inventory_item_category(&blueprint.output),
2653            Some("bulk") | Some("liquid")
2654        ) || matches!(
2655            blueprint.output.as_str(),
2656            "dirt" | "mud" | "sand" | "water" | "milk"
2657        )
2658    }
2659
2660    fn craft_output_category(&self, blueprint: &BlueprintView) -> Option<&str> {
2661        self.inventory_item_category(&blueprint.output).or_else(|| {
2662            match blueprint.output.as_str() {
2663                "dirt" | "mud" | "sand" => Some("bulk"),
2664                "water" | "milk" => Some("liquid"),
2665                _ => None,
2666            }
2667        })
2668    }
2669
2670    fn craft_has_vessel_room_for_output(&self, blueprint: &BlueprintView) -> bool {
2671        if !self.craft_output_needs_vessel(blueprint) {
2672            return true;
2673        }
2674        let need = blueprint.output_qty.max(1);
2675        self.vessel_room_after_craft_inputs(blueprint) >= need
2676    }
2677
2678    /// Free capacity in vessels that can hold `blueprint.output` after consuming inputs.
2679    fn vessel_room_after_craft_inputs(&self, blueprint: &BlueprintView) -> u32 {
2680        let mut stacks = self.inventory_stacks.clone();
2681        for worn in self.worn.values() {
2682            stacks.push(worn.clone());
2683        }
2684        for input in &blueprint.inputs {
2685            let mut left = input.quantity;
2686            drain_payload_from_stacks(&mut stacks, &input.template_id, &mut left);
2687            if left > 0 {
2688                return 0;
2689            }
2690        }
2691        vessel_room_for_payload_in_stacks(
2692            &stacks,
2693            &blueprint.output,
2694            self.craft_output_category(blueprint),
2695        )
2696    }
2697
2698    /// Vessel inventory transparency for the craft detail panel.
2699    pub fn craft_vessel_status(&self, blueprint: &BlueprintView) -> CraftVesselStatus {
2700        let output_label = self.blueprint_output_label(blueprint);
2701        let needs_vessel = self.craft_output_needs_vessel(blueprint);
2702        let need_units = if needs_vessel {
2703            blueprint.output_qty.max(1)
2704        } else {
2705            0
2706        };
2707        let free_after_inputs = if needs_vessel {
2708            self.vessel_room_after_craft_inputs(blueprint)
2709        } else {
2710            0
2711        };
2712        let payload_cat = self.craft_output_category(blueprint);
2713        let mut vessels = Vec::new();
2714        Self::collect_craft_vessel_lines(
2715            &self.inventory_stacks,
2716            "pack",
2717            &blueprint.output,
2718            payload_cat,
2719            &mut vessels,
2720        );
2721        for worn in self.worn.values() {
2722            Self::collect_craft_vessel_lines(
2723                std::slice::from_ref(worn),
2724                "worn",
2725                &blueprint.output,
2726                payload_cat,
2727                &mut vessels,
2728            );
2729        }
2730        CraftVesselStatus {
2731            needs_vessel,
2732            output_label,
2733            need_units,
2734            free_after_inputs,
2735            ok: !needs_vessel || free_after_inputs >= need_units,
2736            vessels,
2737        }
2738    }
2739
2740    fn collect_craft_vessel_lines(
2741        stacks: &[flatland_protocol::ItemStack],
2742        location: &'static str,
2743        payload_id: &str,
2744        payload_category: Option<&str>,
2745        out: &mut Vec<CraftVesselLine>,
2746    ) {
2747        for stack in stacks {
2748            if is_serving_vessel_stack(stack) {
2749                let cap = serving_capacity_of(stack);
2750                let used = payload_units_in_vessel(stack);
2751                let free = vessel_free_room_for_payload(stack, payload_id, payload_category);
2752                let holds = stack
2753                    .props
2754                    .get("serving_holds")
2755                    .cloned()
2756                    .unwrap_or_else(|| {
2757                        if stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
2758                            && stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
2759                        {
2760                            "liquid,bulk".into()
2761                        } else if stack.props.get("bulk_vessel").is_some_and(|v| v == "1") {
2762                            "bulk".into()
2763                        } else if stack.props.get("liquid_vessel").is_some_and(|v| v == "1") {
2764                            "liquid".into()
2765                        } else {
2766                            "?".into()
2767                        }
2768                    });
2769                let label = stack
2770                    .display_name
2771                    .clone()
2772                    .unwrap_or_else(|| stack.template_id.clone());
2773                out.push(CraftVesselLine {
2774                    label,
2775                    holds,
2776                    capacity: cap,
2777                    used,
2778                    free,
2779                    quantity: stack.quantity.max(1),
2780                    accepts_output: free > 0,
2781                    location,
2782                });
2783            }
2784            Self::collect_craft_vessel_lines(
2785                &stack.contents,
2786                location,
2787                payload_id,
2788                payload_category,
2789                out,
2790            );
2791        }
2792    }
2793
2794    fn craft_prefs_key(&self) -> String {
2795        if let Some(cid) = self.character_id {
2796            cid.to_string()
2797        } else if self.entity_id != 0 {
2798            format!("entity:{}", self.entity_id)
2799        } else {
2800            String::new()
2801        }
2802    }
2803
2804    pub fn reload_craft_prefs(&mut self) {
2805        let key = self.craft_prefs_key();
2806        self.craft_prefs = crate::craft_prefs::load_for_character(&key);
2807    }
2808
2809    fn persist_craft_prefs(&self) {
2810        crate::craft_prefs::save_for_character(&self.craft_prefs_key(), &self.craft_prefs);
2811    }
2812
2813    /// Distinct craft tiers present in known blueprints (sorted ascending).
2814    pub fn craft_known_tiers(&self) -> Vec<u32> {
2815        let mut tiers: Vec<u32> = self
2816            .blueprints
2817            .iter()
2818            .map(|bp| bp.craft_tier.max(1))
2819            .collect::<std::collections::BTreeSet<_>>()
2820            .into_iter()
2821            .collect();
2822        tiers.sort_unstable();
2823        tiers
2824    }
2825
2826    /// Tab strip order: Ready, ★, Recent, then T1..Tn that have known recipes.
2827    pub fn craft_tab_strip(&self) -> Vec<CraftTab> {
2828        let mut tabs = vec![CraftTab::Ready, CraftTab::Favorites, CraftTab::Recent];
2829        for t in self.craft_known_tiers() {
2830            tabs.push(CraftTab::Tier(t));
2831        }
2832        tabs
2833    }
2834
2835    pub fn craft_set_tab(&mut self, tab: CraftTab) {
2836        self.craft_tab = tab;
2837        self.craft_menu_index = 0;
2838        self.clamp_craft_menu_index();
2839        self.clamp_craft_batch_quantity();
2840    }
2841
2842    pub fn craft_cycle_tab(&mut self, delta: i32) {
2843        let tabs = self.craft_tab_strip();
2844        if tabs.is_empty() {
2845            return;
2846        }
2847        let cur = tabs.iter().position(|t| *t == self.craft_tab).unwrap_or(0) as i32;
2848        let next = (cur + delta).rem_euclid(tabs.len() as i32) as usize;
2849        self.craft_set_tab(tabs[next]);
2850    }
2851
2852    pub fn craft_matches_search(&self, bp: &BlueprintView) -> bool {
2853        let f = self.craft_filter.trim();
2854        if f.is_empty() {
2855            return true;
2856        }
2857        if list_label_matches(&bp.label, f)
2858            || list_label_matches(&bp.output, f)
2859            || list_label_matches(&bp.output_display_name, f)
2860            || bp
2861                .category
2862                .as_deref()
2863                .is_some_and(|c| list_label_matches(c, f))
2864            || bp
2865                .station
2866                .as_deref()
2867                .is_some_and(|s| list_label_matches(s, f))
2868        {
2869            return true;
2870        }
2871        bp.inputs.iter().any(|i| {
2872            list_label_matches(&i.template_id, f) || list_label_matches(&i.display_name, f)
2873        }) || bp
2874            .required_tools
2875            .iter()
2876            .any(|t| list_label_matches(&t.item, f) || list_label_matches(&t.display_name, f))
2877    }
2878
2879    /// True while this blueprint is pinned as the in-flight / in-progress craft.
2880    pub fn craft_blueprint_in_channel(&self, blueprint_id: &str) -> bool {
2881        self.craft_channel_blueprint_id.as_deref() == Some(blueprint_id)
2882    }
2883
2884    fn sync_craft_ready_pin(&mut self) {
2885        if self.active_craft_channel().is_some() {
2886            self.craft_channel_seen = true;
2887            return;
2888        }
2889        if self.craft_channel_seen {
2890            self.clear_craft_ready_pin();
2891        }
2892    }
2893
2894    fn clear_craft_ready_pin(&mut self) {
2895        self.craft_channel_blueprint_id = None;
2896        self.craft_channel_seen = false;
2897    }
2898
2899    /// Blueprint indices for the active tab + search, ready-first then A–Z.
2900    pub fn craft_filtered_indices(&self) -> Vec<usize> {
2901        let mut idxs: Vec<usize> = (0..self.blueprints.len())
2902            .filter(|&i| {
2903                let bp = &self.blueprints[i];
2904                if !self.craft_matches_search(bp) {
2905                    return false;
2906                }
2907                match self.craft_tab {
2908                    CraftTab::Ready => {
2909                        self.can_craft_blueprint(bp) || self.craft_blueprint_in_channel(&bp.id)
2910                    }
2911                    CraftTab::Favorites => self.craft_prefs.is_favorite(&bp.id),
2912                    CraftTab::Recent => self.craft_prefs.recent.iter().any(|id| id == &bp.id),
2913                    CraftTab::Tier(t) => bp.craft_tier.max(1) == t,
2914                }
2915            })
2916            .collect();
2917        match self.craft_tab {
2918            CraftTab::Recent => {
2919                idxs.sort_by_key(|&i| {
2920                    self.craft_prefs
2921                        .recent
2922                        .iter()
2923                        .position(|id| id == &self.blueprints[i].id)
2924                        .unwrap_or(usize::MAX)
2925                });
2926            }
2927            _ => {
2928                idxs.sort_by(|&a, &b| {
2929                    let ba = &self.blueprints[a];
2930                    let bb = &self.blueprints[b];
2931                    let ia = self.craft_blueprint_in_channel(&ba.id);
2932                    let ib = self.craft_blueprint_in_channel(&bb.id);
2933                    // In-progress first, then craftable, then A–Z.
2934                    ib.cmp(&ia)
2935                        .then_with(|| {
2936                            let ra = self.can_craft_blueprint(ba);
2937                            let rb = self.can_craft_blueprint(bb);
2938                            rb.cmp(&ra)
2939                        })
2940                        .then_with(|| {
2941                            ba.label
2942                                .to_ascii_lowercase()
2943                                .cmp(&bb.label.to_ascii_lowercase())
2944                        })
2945                });
2946            }
2947        }
2948        idxs
2949    }
2950
2951    pub fn craft_selected_blueprint(&self) -> Option<&BlueprintView> {
2952        let idxs = self.craft_filtered_indices();
2953        idxs.get(self.craft_menu_index)
2954            .and_then(|&i| self.blueprints.get(i))
2955    }
2956
2957    pub fn clamp_craft_menu_index(&mut self) {
2958        let n = self.craft_filtered_indices().len();
2959        if n == 0 {
2960            self.craft_menu_index = 0;
2961        } else {
2962            self.craft_menu_index = self.craft_menu_index.min(n - 1);
2963        }
2964    }
2965
2966    pub fn craft_is_favorite(&self, blueprint_id: &str) -> bool {
2967        self.craft_prefs.is_favorite(blueprint_id)
2968    }
2969
2970    pub fn craft_toggle_favorite_selected(&mut self) {
2971        let Some(id) = self.craft_selected_blueprint().map(|bp| bp.id.clone()) else {
2972            return;
2973        };
2974        self.craft_prefs.toggle_favorite(&id);
2975        self.persist_craft_prefs();
2976        if matches!(self.craft_tab, CraftTab::Favorites) {
2977            self.clamp_craft_menu_index();
2978        }
2979    }
2980
2981    pub fn craft_record_completed(&mut self, blueprint_id: &str) {
2982        self.craft_prefs.record_crafted(blueprint_id);
2983        self.persist_craft_prefs();
2984    }
2985
2986    pub fn focus_craft_filter(&mut self) {
2987        self.craft_filter_focused = true;
2988    }
2989
2990    pub fn append_craft_filter_char(&mut self, ch: char) {
2991        if !self.craft_filter_focused {
2992            return;
2993        }
2994        if is_list_filter_char(ch) {
2995            self.craft_filter.push(ch);
2996            self.craft_menu_index = 0;
2997            self.clamp_craft_menu_index();
2998        }
2999    }
3000
3001    pub fn craft_filter_backspace(&mut self) {
3002        if !self.craft_filter_focused {
3003            return;
3004        }
3005        self.craft_filter.pop();
3006        self.craft_menu_index = 0;
3007        self.clamp_craft_menu_index();
3008    }
3009
3010    /// Esc while filter focused: clear then blur. Returns true if handled.
3011    pub fn clear_or_blur_craft_filter(&mut self) -> bool {
3012        if self.craft_filter_focused {
3013            if !self.craft_filter.is_empty() {
3014                self.craft_filter.clear();
3015                self.craft_menu_index = 0;
3016                self.clamp_craft_menu_index();
3017            } else {
3018                self.craft_filter_focused = false;
3019            }
3020            return true;
3021        }
3022        if !self.craft_filter.is_empty() {
3023            self.craft_filter.clear();
3024            self.craft_menu_index = 0;
3025            self.clamp_craft_menu_index();
3026            return true;
3027        }
3028        false
3029    }
3030
3031    pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
3032        if !self.can_craft_blueprint(blueprint) {
3033            return 0;
3034        }
3035        let mut limit = u32::MAX;
3036        for input in &blueprint.inputs {
3037            if input.quantity == 0 {
3038                continue;
3039            }
3040            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
3041            limit = limit.min(have / input.quantity);
3042        }
3043        for tool in &blueprint.required_tools {
3044            if tool.consumed {
3045                let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
3046                limit = limit.min(have);
3047            }
3048        }
3049        if self.craft_output_needs_vessel(blueprint) {
3050            let need = blueprint.output_qty.max(1);
3051            let room = self.vessel_room_after_craft_inputs(blueprint);
3052            if need > 0 {
3053                limit = limit.min(room / need);
3054            }
3055        }
3056        limit.min(CRAFT_BATCH_SELECT_CAP)
3057    }
3058
3059    /// How many sequential crafts current stamina can finish before a rest.
3060    pub fn craft_stamina_batch_cap(&self) -> u32 {
3061        let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
3062        if CRAFT_STAMINA_COST > 0.0 {
3063            (stamina / CRAFT_STAMINA_COST).floor() as u32
3064        } else {
3065            u32::MAX
3066        }
3067    }
3068
3069    pub fn clamp_craft_batch_quantity(&mut self) {
3070        let Some(bp) = self.craft_selected_blueprint().cloned() else {
3071            self.craft_batch_quantity = 1;
3072            return;
3073        };
3074        let max = self.max_craft_batches(&bp).max(1);
3075        self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
3076    }
3077
3078    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
3079        let Some(bp) = self.craft_selected_blueprint().cloned() else {
3080            return;
3081        };
3082        let max = self.max_craft_batches(&bp).max(1);
3083        let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
3084        self.craft_batch_quantity = next as u32;
3085    }
3086
3087    pub fn craft_batch_set_max(&mut self) {
3088        let Some(bp) = self.craft_selected_blueprint().cloned() else {
3089            return;
3090        };
3091        let max = self.max_craft_batches(&bp);
3092        self.craft_batch_quantity = if max == 0 { 1 } else { max };
3093    }
3094
3095    pub fn craft_batch_set_min(&mut self) {
3096        self.craft_batch_quantity = 1;
3097    }
3098
3099    pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
3100        let preserve_ui = self.show_shop_menu;
3101        let tab = self.shop_tab;
3102        let index = self.shop_menu_index;
3103        let qty = self.shop_quantity;
3104
3105        self.show_shop_menu = true;
3106        self.bank_panel = None;
3107        self.show_craft_menu = false;
3108        self.show_inventory_menu = false;
3109        self.show_stats = false;
3110        if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
3111            self.npc_verb_target = Some(catalog.npc_id.clone());
3112        }
3113        self.shop_catalog = Some(catalog);
3114
3115        if preserve_ui {
3116            self.shop_tab = tab;
3117            self.shop_menu_index = index;
3118            self.shop_quantity = qty;
3119        } else {
3120            self.shop_tab = ShopTab::Buy;
3121            self.shop_menu_index = 0;
3122            self.shop_quantity = 1;
3123            self.clear_shop_trade_log();
3124        }
3125        self.show_npc_verb_menu = false;
3126        self.clamp_shop_selection();
3127    }
3128
3129    pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
3130        let same_teller = self
3131            .bank_panel
3132            .as_ref()
3133            .is_some_and(|p| p.npc_id == panel.npc_id);
3134        self.bank_panel = Some(panel);
3135        self.storage_panel = None;
3136        self.market_panel = None;
3137        self.shop_catalog = None;
3138        self.show_shop_menu = false;
3139        self.show_craft_menu = false;
3140        self.show_inventory_menu = false;
3141        self.show_stats = false;
3142        self.show_npc_verb_menu = false;
3143        self.show_npc_chat = false;
3144        self.npc_chat = None;
3145        if !same_teller {
3146            self.bank_menu_index = 0;
3147            self.bank_ui_mode = BankUiMode::Menu;
3148        }
3149        if let Some(panel) = &self.bank_panel {
3150            if self.npc_verb_target.is_none() {
3151                self.npc_verb_target = Some(panel.npc_id.clone());
3152            }
3153        }
3154    }
3155
3156    pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
3157        let same_manager = self
3158            .storage_panel
3159            .as_ref()
3160            .is_some_and(|p| p.npc_id == panel.npc_id);
3161        self.storage_panel = Some(panel);
3162        self.bank_panel = None;
3163        self.market_panel = None;
3164        self.bank_ui_mode = BankUiMode::Menu;
3165        self.shop_catalog = None;
3166        self.show_shop_menu = false;
3167        self.show_craft_menu = false;
3168        self.show_inventory_menu = false;
3169        self.show_stats = false;
3170        self.show_npc_verb_menu = false;
3171        self.show_npc_chat = false;
3172        self.npc_chat = None;
3173        if !same_manager {
3174            self.storage_menu_index = 0;
3175            self.storage_ui_mode = StorageUiMode::Menu;
3176        } else {
3177            self.clamp_storage_pick_index();
3178        }
3179        if let Some(panel) = &self.storage_panel {
3180            if self.npc_verb_target.is_none() {
3181                self.npc_verb_target = Some(panel.npc_id.clone());
3182            }
3183        }
3184    }
3185
3186    pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
3187        for vault in &panel.list_vaults {
3188            self.merge_stack_catalog_hints(&vault.contents);
3189        }
3190        self.market_panel = Some(panel);
3191        self.bank_panel = None;
3192        self.storage_panel = None;
3193        self.shop_catalog = None;
3194        self.show_shop_menu = false;
3195        self.show_craft_menu = false;
3196        self.show_inventory_menu = false;
3197        self.show_stats = false;
3198        self.show_npc_verb_menu = false;
3199        self.show_npc_chat = false;
3200        self.npc_chat = None;
3201        self.market_menu_index = 0;
3202        self.market_buy_confirm = None;
3203        self.market_ui_mode = MarketUiMode::Browse;
3204        self.market_filter.clear();
3205        self.market_filter_focused = false;
3206        self.market_category_filter = None;
3207        if let Some(panel) = &self.market_panel {
3208            if self.npc_verb_target.is_none() {
3209                self.npc_verb_target = Some(panel.npc_id.clone());
3210            }
3211        }
3212    }
3213
3214    pub fn clear_market_panel(&mut self) {
3215        self.market_panel = None;
3216        self.market_menu_index = 0;
3217        self.market_buy_confirm = None;
3218        self.market_ui_mode = MarketUiMode::Browse;
3219        self.market_filter.clear();
3220        self.market_filter_focused = false;
3221        self.market_category_filter = None;
3222    }
3223
3224    pub fn clear_bank_panel(&mut self) {
3225        self.bank_panel = None;
3226        self.bank_menu_index = 0;
3227        self.bank_ui_mode = BankUiMode::Menu;
3228    }
3229
3230    pub fn clear_storage_panel(&mut self) {
3231        self.storage_panel = None;
3232        self.storage_menu_index = 0;
3233        self.storage_ui_mode = StorageUiMode::Menu;
3234    }
3235
3236    fn clamp_storage_pick_index(&mut self) {
3237        match &self.storage_ui_mode {
3238            StorageUiMode::StorePick { index } => {
3239                let n = self.storage_store_options().len();
3240                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3241                self.storage_ui_mode = StorageUiMode::StorePick { index: next };
3242            }
3243            StorageUiMode::TakePick { index } => {
3244                let n = self.storage_vault_options().len();
3245                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3246                self.storage_ui_mode = StorageUiMode::TakePick { index: next };
3247            }
3248            StorageUiMode::ShipPick {
3249                dest_building_id,
3250                dest_label,
3251                index,
3252            } => {
3253                let n = self.storage_vault_options().len();
3254                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3255                self.storage_ui_mode = StorageUiMode::ShipPick {
3256                    dest_building_id: dest_building_id.clone(),
3257                    dest_label: dest_label.clone(),
3258                    index: next,
3259                };
3260            }
3261            StorageUiMode::Menu
3262            | StorageUiMode::StoreAmount { .. }
3263            | StorageUiMode::TakeAmount { .. }
3264            | StorageUiMode::ShipAmount { .. } => {}
3265        }
3266    }
3267
3268    pub fn shop_list_len(&self) -> usize {
3269        let Some(catalog) = &self.shop_catalog else {
3270            return 0;
3271        };
3272        match self.shop_tab {
3273            ShopTab::Buy => catalog.sells.len(),
3274            ShopTab::Sell => catalog.buys.len(),
3275        }
3276    }
3277
3278    pub fn shop_menu_move(&mut self, delta: i32) {
3279        let n = self.shop_list_len();
3280        if n == 0 {
3281            return;
3282        }
3283        let idx = self.shop_menu_index as i32;
3284        let next = (idx + delta).rem_euclid(n as i32);
3285        self.shop_menu_index = next as usize;
3286        self.clamp_shop_quantity();
3287    }
3288
3289    pub fn shop_quantity_adjust(&mut self, delta: i32) {
3290        let max = self.shop_quantity_max();
3291        if max == 0 {
3292            self.shop_quantity = 0;
3293            return;
3294        }
3295        let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
3296        self.shop_quantity = next as u32;
3297    }
3298
3299    pub(crate) fn clamp_shop_selection(&mut self) {
3300        let n = self.shop_list_len();
3301        if n == 0 {
3302            self.shop_menu_index = 0;
3303        } else {
3304            self.shop_menu_index = self.shop_menu_index.min(n - 1);
3305        }
3306        self.clamp_shop_quantity();
3307    }
3308
3309    fn shop_quantity_max(&self) -> u32 {
3310        let Some(catalog) = &self.shop_catalog else {
3311            return 1;
3312        };
3313        match self.shop_tab {
3314            ShopTab::Buy => {
3315                if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
3316                    if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
3317                        return 1;
3318                    }
3319                }
3320                99
3321            }
3322            ShopTab::Sell => catalog
3323                .buys
3324                .get(self.shop_menu_index)
3325                .map(|l| l.quantity)
3326                .unwrap_or(0),
3327        }
3328    }
3329
3330    pub fn shop_quantity_set_max(&mut self) {
3331        self.shop_quantity = self.shop_quantity_max();
3332    }
3333
3334    pub fn shop_quantity_set_min(&mut self) {
3335        let max = self.shop_quantity_max();
3336        self.shop_quantity = if max == 0 { 0 } else { 1 };
3337    }
3338
3339    fn clamp_shop_quantity(&mut self) {
3340        let max = self.shop_quantity_max();
3341        if max == 0 {
3342            self.shop_quantity = 0;
3343        } else {
3344            self.shop_quantity = self.shop_quantity.max(1).min(max);
3345        }
3346    }
3347
3348    pub fn player_at_station_tag(&self, tag: &str) -> bool {
3349        let Some(id) = self.effective_inside_building() else {
3350            return false;
3351        };
3352        self.buildings
3353            .iter()
3354            .find(|b| b.id == id)
3355            .is_some_and(|b| b.tags.iter().any(|t| t == tag))
3356    }
3357
3358    /// Short hint for UI when a recipe cannot be started.
3359    pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
3360        if self.can_craft_blueprint(blueprint) {
3361            return None;
3362        }
3363        let mut missing = Vec::new();
3364        for input in &blueprint.inputs {
3365            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
3366            if have < input.quantity {
3367                let name = self.blueprint_ingredient_label(input);
3368                let vessel_note = if self.inventory_item_category(&input.template_id)
3369                    == Some("liquid")
3370                    || matches!(input.template_id.as_str(), "water" | "milk")
3371                {
3372                    "; fill a bottle/waterskin"
3373                } else if self.inventory_item_category(&input.template_id) == Some("bulk")
3374                    || matches!(input.template_id.as_str(), "dirt" | "mud" | "sand")
3375                {
3376                    "; scoop into a sack/bucket"
3377                } else {
3378                    ""
3379                };
3380                missing.push(format!(
3381                    "{}×{} (have {have}{vessel_note})",
3382                    input.quantity, name
3383                ));
3384            }
3385        }
3386        for tool in &blueprint.required_tools {
3387            if !self.player_has_craft_tool(&tool.item) {
3388                missing.push(format!("tool: {}", self.blueprint_tool_label(tool)));
3389            }
3390        }
3391        if let Some(station) = blueprint.station.as_deref() {
3392            if station != "hand" && !self.player_at_station_tag(station) {
3393                missing.push(format!("station: {station} (enter building)"));
3394            }
3395        }
3396        if self.craft_output_needs_vessel(blueprint)
3397            && !self.craft_has_vessel_room_for_output(blueprint)
3398        {
3399            let name = self
3400                .inventory_hints
3401                .get(&blueprint.output)
3402                .map(|h| h.display_name.as_str())
3403                .unwrap_or(blueprint.output.as_str());
3404            let need = blueprint.output_qty.max(1);
3405            let free = self.vessel_room_after_craft_inputs(blueprint);
3406            let accepting = self
3407                .craft_vessel_status(blueprint)
3408                .vessels
3409                .iter()
3410                .filter(|v| v.accepts_output)
3411                .count();
3412            missing.push(format!(
3413                "vessel room for {name}: need {need} free after inputs, have {free} ({accepting} accepting vessel(s))"
3414            ));
3415        }
3416        if missing.is_empty() {
3417            None
3418        } else {
3419            Some(missing.join(", "))
3420        }
3421    }
3422
3423    /// In-progress inventory craft (n-menu), if any.
3424    pub fn active_craft_channel(&self) -> Option<&flatland_protocol::TimedChannelHud> {
3425        self.timed_channel
3426            .as_ref()
3427            .filter(|c| c.channel == flatland_protocol::TimedChannelKind::Craft)
3428    }
3429
3430    pub fn player_entity(&self) -> Option<&EntityState> {
3431        self.player
3432            .as_ref()
3433            .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
3434    }
3435
3436    /// Apply persisted HUD / workers UI prefs from `client.json`.
3437    pub fn apply_client_ui_prefs(&mut self) {
3438        let cfg = crate::client_config::ClientConfig::load();
3439        if let Some(hidden) = cfg.hud_log_hidden {
3440            self.hud_log_hidden = hidden;
3441        }
3442        if let Some(compact) = cfg.workers_menu_compact {
3443            self.workers_menu_compact = compact;
3444        }
3445    }
3446
3447    pub fn player_position(&self) -> (f32, f32) {
3448        let (x, y, _) = self.player_position_with_z();
3449        (x, y)
3450    }
3451
3452    pub fn player_position_with_z(&self) -> (f32, f32, f32) {
3453        if let Some(p) = self.player_entity() {
3454            (
3455                p.transform.position.x,
3456                p.transform.position.y,
3457                p.transform.position.z,
3458            )
3459        } else {
3460            (0.0, 0.0, 0.0)
3461        }
3462    }
3463
3464    pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
3465        let mut rows: Vec<(String, u32, String)> = self
3466            .inventory
3467            .iter()
3468            .filter(|(_, q)| **q > 0)
3469            .map(|(id, qty)| {
3470                let label = self
3471                    .inventory_hints
3472                    .get(id)
3473                    .map(|h| h.display_name.clone())
3474                    .unwrap_or_else(|| id.clone());
3475                (id.clone(), *qty, label)
3476            })
3477            .collect();
3478        rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
3479        rows
3480    }
3481
3482    pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
3483        self.inventory_hints
3484            .get(template_id)
3485            .map(|h| h.category.as_str())
3486            .filter(|c| !c.is_empty())
3487    }
3488
3489    pub fn stack_is_serving(stack: &flatland_protocol::ItemStack) -> bool {
3490        stack.props.get("serving").is_some_and(|v| v == "1") || Self::stack_is_liquid_vessel(stack)
3491    }
3492
3493    pub fn stack_is_liquid_vessel(stack: &flatland_protocol::ItemStack) -> bool {
3494        stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
3495            || stack
3496                .props
3497                .get("serving_holds")
3498                .is_some_and(|v| v.split(',').any(|p| p.trim() == "liquid"))
3499    }
3500
3501    pub fn stack_is_food_serving(stack: &flatland_protocol::ItemStack) -> bool {
3502        stack
3503            .props
3504            .get("serving_holds")
3505            .is_some_and(|v| v.split(',').any(|p| p.trim() == "food"))
3506    }
3507
3508    pub fn stack_is_bulk_vessel(stack: &flatland_protocol::ItemStack) -> bool {
3509        stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
3510            || stack
3511                .props
3512                .get("serving_holds")
3513                .is_some_and(|v| v.split(',').any(|p| p.trim() == "bulk"))
3514    }
3515
3516    pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
3517        stack
3518            .props
3519            .get("grants_item_status_effect")
3520            .map(|s| !s.is_empty())
3521            .unwrap_or(false)
3522    }
3523
3524    pub fn stack_is_blueprint_scroll(stack: &flatland_protocol::ItemStack) -> bool {
3525        stack
3526            .props
3527            .get("teaches_blueprint")
3528            .map(|s| !s.trim().is_empty())
3529            .unwrap_or(false)
3530    }
3531
3532    pub fn stack_is_deconstructable(stack: &flatland_protocol::ItemStack) -> bool {
3533        stack
3534            .props
3535            .get("deconstructable")
3536            .map(|s| s == "1")
3537            .unwrap_or(false)
3538    }
3539
3540    pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
3541        stack
3542            .props
3543            .get("grants_item_status_effect")
3544            .map(String::as_str)
3545            .filter(|s| !s.is_empty())
3546    }
3547
3548    pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
3549        stack
3550            .props
3551            .get("grants_item_status_mode")
3552            .map(String::as_str)
3553            .unwrap_or("on_hit")
3554    }
3555
3556    /// Candidate gear for a grant consumable (inventory + worn).
3557    pub fn grant_target_options(
3558        &self,
3559        grant: &flatland_protocol::ItemStack,
3560    ) -> Vec<GrantTargetOption> {
3561        let mode = Self::grant_mode(grant);
3562        let grant_tags: Vec<&str> = grant
3563            .props
3564            .get("grants_item_status_tags")
3565            .map(|s| {
3566                s.split(',')
3567                    .map(str::trim)
3568                    .filter(|t| !t.is_empty())
3569                    .collect()
3570            })
3571            .unwrap_or_default();
3572        let grant_id = grant.item_instance_id;
3573        let mut out = Vec::new();
3574        let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
3575            let Some(iid) = stack.item_instance_id else {
3576                return;
3577            };
3578            if Some(iid) == grant_id {
3579                return;
3580            }
3581            if stack.props.get("enchantable").map(String::as_str) == Some("0") {
3582                return;
3583            }
3584            if !grant_target_matches_mode(stack, mode) {
3585                return;
3586            }
3587            if !grant_tags_match(stack, &grant_tags) {
3588                return;
3589            }
3590            let name = stack
3591                .display_name
3592                .clone()
3593                .unwrap_or_else(|| stack.template_id.clone());
3594            let bindings = if stack.status_bindings.is_empty() {
3595                String::new()
3596            } else {
3597                format!(
3598                    " · {}",
3599                    stack
3600                        .status_bindings
3601                        .iter()
3602                        .map(|b| b.effect_id.as_str())
3603                        .collect::<Vec<_>>()
3604                        .join(", ")
3605                )
3606            };
3607            out.push(GrantTargetOption {
3608                label: format!("{where_label}: {name}{bindings}"),
3609                target_instance_id: iid,
3610            });
3611        };
3612        fn walk(
3613            stacks: &[flatland_protocol::ItemStack],
3614            where_label: &str,
3615            push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
3616        ) {
3617            for s in stacks {
3618                push(s, where_label);
3619                if !s.contents.is_empty() {
3620                    let nested = format!(
3621                        "{where_label}/{}",
3622                        s.display_name.as_deref().unwrap_or(s.template_id.as_str())
3623                    );
3624                    walk(&s.contents, &nested, push);
3625                }
3626            }
3627        }
3628        walk(&self.inventory_stacks, "Bag", &mut push);
3629        for (slot, stack) in &self.worn {
3630            push(stack, body_slot_label(*slot));
3631            let nest = format!(
3632                "{}/{}",
3633                body_slot_label(*slot),
3634                stack
3635                    .display_name
3636                    .as_deref()
3637                    .unwrap_or(stack.template_id.as_str())
3638            );
3639            walk(&stack.contents, &nest, &mut push);
3640        }
3641        out
3642    }
3643
3644    pub fn item_base_mass(&self, template_id: &str) -> f32 {
3645        self.inventory_hints
3646            .get(template_id)
3647            .and_then(|h| h.base_mass)
3648            .unwrap_or(0.5)
3649    }
3650
3651    pub fn item_base_volume(&self, template_id: &str) -> f32 {
3652        self.inventory_hints
3653            .get(template_id)
3654            .and_then(|h| h.base_volume)
3655            .unwrap_or(1.0)
3656    }
3657
3658    pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
3659        let unit = stack
3660            .base_mass
3661            .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
3662        unit * stack.quantity as f32
3663    }
3664
3665    fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
3666        let unit = stack.base_volume.unwrap_or(1.0);
3667        unit * stack.quantity as f32
3668            + stack
3669                .contents
3670                .iter()
3671                .map(Self::stack_tree_volume)
3672                .sum::<f32>()
3673    }
3674
3675    fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
3676        contents.iter().map(Self::stack_tree_volume).sum()
3677    }
3678
3679    fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
3680        self.inventory_hints
3681            .get(template_id)
3682            .and_then(|h| h.capacity_volume)
3683            .filter(|c| *c > 0.0)
3684    }
3685
3686    fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
3687        stack
3688            .capacity_volume
3689            .filter(|c| *c > 0.0)
3690            .or_else(|| self.template_capacity_volume(&stack.template_id))
3691    }
3692
3693    /// Volume used / capacity / free space label for storage containers in the inventory UI.
3694    pub fn container_volume_label(&self, row: &InventoryRow) -> String {
3695        let Some((used, cap)) = self.container_volume_stats(row) else {
3696            return String::new();
3697        };
3698        format!("  {}", format_container_volume_usage(used, cap))
3699    }
3700
3701    fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
3702        if row.is_chest_shell {
3703            let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
3704                return None;
3705            };
3706            let chest = self
3707                .placed_containers
3708                .iter()
3709                .find(|c| c.id == *container_id)?;
3710            let cap = self
3711                .stack_capacity_volume(&row.stack)
3712                .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
3713            let used = if chest.accessible {
3714                Self::contents_used_volume(&chest.contents)
3715            } else {
3716                0.0
3717            };
3718            return Some((used, cap));
3719        }
3720
3721        let cap = self.stack_capacity_volume(&row.stack)?;
3722        let used = Self::contents_used_volume(&row.stack.contents);
3723        Some((used, cap))
3724    }
3725
3726    fn destination_volume_stats(
3727        &self,
3728        location: &flatland_protocol::InventoryLocation,
3729        parent_instance_id: Option<uuid::Uuid>,
3730    ) -> Option<(f32, f32)> {
3731        let parent = self.container_stack_for(location, parent_instance_id)?;
3732        let mut cap = self.stack_capacity_volume(&parent);
3733        if cap.is_none() {
3734            if let flatland_protocol::InventoryLocation::Placed { container_id } = location {
3735                if let Some(chest) = self
3736                    .placed_containers
3737                    .iter()
3738                    .find(|c| c.id == *container_id)
3739                {
3740                    let looking_at_shell =
3741                        parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id));
3742                    if looking_at_shell {
3743                        cap = chest.capacity_volume.filter(|v| *v > 0.0);
3744                    }
3745                }
3746            }
3747        }
3748        let cap = cap?;
3749        let used = Self::contents_used_volume(&parent.contents).max(0.0);
3750        Some((used, cap))
3751    }
3752
3753    pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
3754        if row.is_chest_shell {
3755            return true;
3756        }
3757        if row.is_equip_shell {
3758            return self.inventory_item_category(&row.stack.template_id) == Some("container");
3759        }
3760        self.inventory_item_category(&row.stack.template_id) == Some("container")
3761            || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
3762    }
3763
3764    fn container_stack_for(
3765        &self,
3766        location: &flatland_protocol::InventoryLocation,
3767        parent_instance_id: Option<uuid::Uuid>,
3768    ) -> Option<flatland_protocol::ItemStack> {
3769        match location {
3770            flatland_protocol::InventoryLocation::Root => {
3771                let pid = parent_instance_id?;
3772                self.find_stack_by_instance(&self.inventory_stacks, pid)
3773            }
3774            flatland_protocol::InventoryLocation::Worn { slot } => {
3775                let worn = self.worn.get(slot)?;
3776                if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
3777                    Some(worn.clone())
3778                } else {
3779                    self.find_stack_by_instance(&worn.contents, parent_instance_id?)
3780                }
3781            }
3782            flatland_protocol::InventoryLocation::Placed { container_id } => {
3783                let chest = self
3784                    .placed_containers
3785                    .iter()
3786                    .find(|c| c.id == *container_id)?;
3787                if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
3788                    Some(flatland_protocol::ItemStack {
3789                        template_id: chest.template_id.clone(),
3790                        quantity: 1,
3791                        item_instance_id: chest.item_instance_id,
3792                        props: Default::default(),
3793                        status_bindings: Vec::new(),
3794                        contents: chest.contents.clone(),
3795                        display_name: Some(chest.display_name.clone()),
3796                        category: Some("container".into()),
3797                        capacity_volume: self
3798                            .inventory_hints
3799                            .get(&chest.template_id)
3800                            .and_then(|h| h.capacity_volume),
3801                        worker_lodging_capacity: chest.worker_lodging_capacity,
3802                        ..Default::default()
3803                    })
3804                } else {
3805                    self.find_stack_by_instance(&chest.contents, parent_instance_id?)
3806                }
3807            }
3808            flatland_protocol::InventoryLocation::Keychain => None,
3809            flatland_protocol::InventoryLocation::WhisperPouch => None,
3810        }
3811    }
3812
3813    fn find_stack_by_instance(
3814        &self,
3815        stacks: &[flatland_protocol::ItemStack],
3816        instance_id: uuid::Uuid,
3817    ) -> Option<flatland_protocol::ItemStack> {
3818        for stack in stacks {
3819            if stack.item_instance_id == Some(instance_id) {
3820                return Some(stack.clone());
3821            }
3822            if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
3823                return Some(found);
3824            }
3825        }
3826        None
3827    }
3828
3829    /// Client-side estimate of how many units can move to `to` (server clamps authoritatively).
3830    pub fn max_movable_to(
3831        &self,
3832        template_id: &str,
3833        stack_qty: u32,
3834        from: &flatland_protocol::InventoryLocation,
3835        to: &flatland_protocol::InventoryLocation,
3836        parent_instance_id: Option<uuid::Uuid>,
3837    ) -> u32 {
3838        let unit_vol = self.item_base_volume(template_id);
3839        let mut limit = stack_qty;
3840
3841        if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
3842            let cap = parent
3843                .capacity_volume
3844                .or_else(|| {
3845                    self.inventory_hints
3846                        .get(&parent.template_id)
3847                        .and_then(|h| h.capacity_volume)
3848                })
3849                .unwrap_or(0.0);
3850            if cap > 0.0 && unit_vol > 0.0 {
3851                let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
3852                limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
3853            }
3854        }
3855
3856        let _ = from;
3857        limit.max(0).min(stack_qty)
3858    }
3859
3860    pub fn move_picker_max_at_selection(&self) -> u32 {
3861        let Some(picker) = &self.move_picker else {
3862            return 1;
3863        };
3864        let Some(opt) = picker.options.get(self.move_picker_index) else {
3865            return picker.stack_quantity;
3866        };
3867        match &opt.kind {
3868            MoveOptionKind::Cancel
3869            | MoveOptionKind::Drop
3870            | MoveOptionKind::Use
3871            | MoveOptionKind::GrantApply
3872            | MoveOptionKind::SellPlotToCrown { .. }
3873            | MoveOptionKind::PickupPlaced { .. }
3874            | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
3875            MoveOptionKind::Move {
3876                location,
3877                parent_instance_id,
3878            } => self.max_movable_to(
3879                &picker.template_id,
3880                picker.stack_quantity,
3881                &picker.from,
3882                location,
3883                *parent_instance_id,
3884            ),
3885        }
3886    }
3887
3888    pub fn clamp_move_picker_quantity(&mut self) {
3889        let max = self.move_picker_max_at_selection();
3890        if let Some(picker) = &mut self.move_picker {
3891            if max == 0 {
3892                picker.quantity = 1;
3893            } else {
3894                picker.quantity = picker.quantity.clamp(1, max);
3895            }
3896        }
3897    }
3898
3899    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
3900        let max = self.move_picker_max_at_selection().max(1);
3901        if let Some(picker) = &mut self.move_picker {
3902            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3903            picker.quantity = next as u32;
3904        }
3905    }
3906
3907    pub fn move_picker_set_quantity_max(&mut self) {
3908        let max = self.move_picker_max_at_selection();
3909        if let Some(picker) = &mut self.move_picker {
3910            picker.quantity = if max == 0 {
3911                1
3912            } else {
3913                max.min(picker.stack_quantity)
3914            };
3915        }
3916    }
3917
3918    pub fn move_picker_set_quantity_min(&mut self) {
3919        if let Some(picker) = &mut self.move_picker {
3920            picker.quantity = 1;
3921        }
3922    }
3923
3924    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
3925        if let Some(picker) = &mut self.destroy_picker {
3926            let max = picker.stack_quantity.max(1);
3927            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3928            picker.quantity = next as u32;
3929        }
3930    }
3931
3932    pub fn destroy_picker_set_quantity_max(&mut self) {
3933        if let Some(picker) = &mut self.destroy_picker {
3934            picker.quantity = picker.stack_quantity.max(1);
3935        }
3936    }
3937
3938    pub fn destroy_picker_set_quantity_min(&mut self) {
3939        if let Some(picker) = &mut self.destroy_picker {
3940            picker.quantity = 1;
3941        }
3942    }
3943
3944    pub fn deconstruct_picker_adjust_quantity(&mut self, delta: i32) {
3945        if let Some(picker) = &mut self.deconstruct_picker {
3946            let max = picker.stack_quantity.max(1);
3947            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3948            picker.quantity = next as u32;
3949        }
3950    }
3951
3952    pub fn deconstruct_picker_set_quantity_max(&mut self) {
3953        if let Some(picker) = &mut self.deconstruct_picker {
3954            picker.quantity = picker.stack_quantity.max(1);
3955        }
3956    }
3957
3958    pub fn deconstruct_picker_set_quantity_min(&mut self) {
3959        if let Some(picker) = &mut self.deconstruct_picker {
3960            picker.quantity = 1;
3961        }
3962    }
3963
3964    pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3965        let have = self.inventory.get(template_id).copied().unwrap_or(0);
3966        (have, have >= need)
3967    }
3968
3969    /// Have/need using plot-build storage pool (town vault / nearby chest + inventory).
3970    pub fn plot_build_stock_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3971        let have = self
3972            .plot_build_offer
3973            .as_ref()
3974            .and_then(|o| {
3975                o.available
3976                    .iter()
3977                    .find(|s| s.template_id == template_id)
3978                    .map(|s| s.quantity)
3979            })
3980            .unwrap_or_else(|| self.inventory.get(template_id).copied().unwrap_or(0));
3981        (have, have >= need)
3982    }
3983
3984    pub fn plot_build_wall_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3985        self.building_materials
3986            .iter()
3987            .filter(|m| m.can_wall)
3988            .collect()
3989    }
3990
3991    pub fn plot_build_roof_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3992        self.building_materials
3993            .iter()
3994            .filter(|m| m.can_roof)
3995            .collect()
3996    }
3997
3998    pub fn plot_build_selected_wall(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3999        self.plot_build_wall_options()
4000            .get(self.plot_build_wall_index)
4001            .copied()
4002    }
4003
4004    pub fn plot_build_selected_roof(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
4005        self.plot_build_roof_options()
4006            .get(self.plot_build_roof_index)
4007            .copied()
4008    }
4009
4010    /// BOM lines for the current wall/roof selection and tilled pad area.
4011    pub fn plot_build_bom_lines(&self) -> Vec<(String, String, u32)> {
4012        let Some(wall) = self.plot_build_selected_wall() else {
4013            return Vec::new();
4014        };
4015        let Some(roof) = self.plot_build_selected_roof() else {
4016            return Vec::new();
4017        };
4018        let area = self
4019            .plot_build_offer
4020            .as_ref()
4021            .filter(|o| o.pad_ok)
4022            .map(|o| o.pad_width_m * o.pad_depth_m)
4023            .unwrap_or(0.0);
4024        if area <= 0.0 {
4025            return Vec::new();
4026        }
4027        let mut map: std::collections::HashMap<String, (String, u32)> =
4028            std::collections::HashMap::new();
4029        for line in &wall.wall_bom {
4030            let qty = (area * line.per_m2).ceil().max(0.0) as u32;
4031            if qty == 0 {
4032                continue;
4033            }
4034            let name = if line.display_name.is_empty() {
4035                line.template_id.clone()
4036            } else {
4037                line.display_name.clone()
4038            };
4039            let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
4040            entry.1 = entry.1.saturating_add(qty);
4041        }
4042        for line in &roof.roof_bom {
4043            let qty = (area * line.per_m2).ceil().max(0.0) as u32;
4044            if qty == 0 {
4045                continue;
4046            }
4047            let name = if line.display_name.is_empty() {
4048                line.template_id.clone()
4049            } else {
4050                line.display_name.clone()
4051            };
4052            let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
4053            entry.1 = entry.1.saturating_add(qty);
4054        }
4055        let mut out: Vec<_> = map
4056            .into_iter()
4057            .map(|(id, (name, qty))| (id, name, qty))
4058            .collect();
4059        out.sort_by(|a, b| a.0.cmp(&b.0));
4060        out
4061    }
4062
4063    pub fn plot_build_duration_secs(&self) -> Option<f32> {
4064        let wall = self.plot_build_selected_wall()?;
4065        let roof = self.plot_build_selected_roof()?;
4066        let offer = self.plot_build_offer.as_ref()?;
4067        if !offer.pad_ok {
4068            return None;
4069        }
4070        let area = offer.pad_width_m * offer.pad_depth_m;
4071        let mult = wall.tick_mult.max(roof.tick_mult).max(0.1);
4072        let ticks = (offer.base_ticks as f32 + area * offer.tick_per_m2 as f32 * mult).ceil();
4073        Some(ticks.max(2.0) / 30.0)
4074    }
4075
4076    pub fn plot_build_can_afford(&self) -> bool {
4077        if self.plot_build_offer.as_ref().is_none_or(|o| !o.pad_ok) {
4078            return false;
4079        }
4080        self.plot_build_bom_lines()
4081            .iter()
4082            .all(|(id, _, need)| self.plot_build_stock_status(id, *need).1)
4083    }
4084
4085    pub fn currency_display(&self) -> String {
4086        crate::currency::currency_line(&self.inventory)
4087    }
4088
4089    /// True when standing in a shallow-water terrain zone from the segment snapshot.
4090    pub fn in_shallow_water(&self) -> bool {
4091        let (px, py) = self.player_position();
4092        self.terrain_at(px, py)
4093            .is_some_and(|k| k == TerrainKindView::ShallowWater)
4094    }
4095
4096    /// On or orthogonally next to fillable water, or next to a well-tagged building.
4097    pub fn near_liquid_fill_source(&self) -> bool {
4098        let (px, py) = self.player_position();
4099        const CELL: f32 = 1.0;
4100        let offsets = [
4101            (0.0, 0.0),
4102            (CELL, 0.0),
4103            (-CELL, 0.0),
4104            (0.0, CELL),
4105            (0.0, -CELL),
4106        ];
4107        for (dx, dy) in offsets {
4108            if matches!(
4109                self.terrain_at(px + dx, py + dy),
4110                Some(TerrainKindView::ShallowWater | TerrainKindView::DeepWater)
4111            ) {
4112                return true;
4113            }
4114        }
4115        self.buildings.iter().any(|b| {
4116            if !b.tags.iter().any(|t| t == "well") {
4117                return false;
4118            }
4119            let hw = b.width_m * 0.5;
4120            let hd = b.depth_m * 0.5;
4121            let nx = px.clamp(b.x - hw, b.x + hw);
4122            let ny = py.clamp(b.y - hd, b.y + hd);
4123            let dx = px - nx;
4124            let dy = py - ny;
4125            dx * dx + dy * dy <= INTERACTION_RADIUS_M * INTERACTION_RADIUS_M
4126        })
4127    }
4128
4129    pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
4130        self.terrain_zone_at(x, y).map(|z| z.kind)
4131    }
4132
4133    /// First terrain zone containing `(x, y)` — highest `z_order` wins.
4134    pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
4135        use std::cell::RefCell;
4136
4137        const CHUNK: i32 = 8;
4138        thread_local! {
4139            static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
4140                RefCell::new(None);
4141        }
4142
4143        let zones = &self.terrain_zones;
4144        if zones.is_empty() {
4145            return None;
4146        }
4147        if zones.len() <= 48 {
4148            return zones
4149                .iter()
4150                .enumerate()
4151                .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
4152                .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
4153                .map(|(_, z)| z);
4154        }
4155
4156        let ptr = zones.as_ptr();
4157        let len = zones.len();
4158        INDEX.with(|cell| {
4159            let mut slot = cell.borrow_mut();
4160            let stale = match slot.as_ref() {
4161                Some((p, l, _)) => *p != ptr || *l != len,
4162                None => true,
4163            };
4164            if stale {
4165                let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
4166                    std::collections::HashMap::new();
4167                for (zi, z) in zones.iter().enumerate() {
4168                    let x0 = z.x0.min(z.x1).floor() as i32;
4169                    let y0 = z.y0.min(z.y1).floor() as i32;
4170                    let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
4171                    let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
4172                    let cx0 = x0.div_euclid(CHUNK);
4173                    let cy0 = y0.div_euclid(CHUNK);
4174                    let cx1 = x1.div_euclid(CHUNK);
4175                    let cy1 = y1.div_euclid(CHUNK);
4176                    for cy in cy0..=cy1 {
4177                        for cx in cx0..=cx1 {
4178                            chunks.entry((cx, cy)).or_default().push(zi);
4179                        }
4180                    }
4181                }
4182                *slot = Some((ptr, len, chunks));
4183            }
4184            let chunks = &slot.as_ref().expect("index").2;
4185            let cx = (x.floor() as i32).div_euclid(CHUNK);
4186            let cy = (y.floor() as i32).div_euclid(CHUNK);
4187            let mut best: Option<(usize, &TerrainZoneView)> = None;
4188            if let Some(list) = chunks.get(&(cx, cy)) {
4189                for &zi in list {
4190                    let Some(z) = zones.get(zi) else { continue };
4191                    if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
4192                        continue;
4193                    }
4194                    best = match best {
4195                        None => Some((zi, z)),
4196                        Some((bi, bz)) => {
4197                            if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
4198                                Some((zi, z))
4199                            } else {
4200                                Some((bi, bz))
4201                            }
4202                        }
4203                    };
4204                }
4205            }
4206            best.map(|(_, z)| z)
4207        })
4208    }
4209
4210    /// Ground elevation from terrain zones (m).
4211    pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
4212        self.terrain_zone_at(x, y)
4213            .map(|z| z.elevation)
4214            .unwrap_or(0.0)
4215    }
4216
4217    /// Walkable z levels at a map column (terrain + platforms).
4218    pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
4219        const TOL: f32 = 0.35;
4220        let mut levels = vec![self.elevation_at(x, y)];
4221        for p in &self.z_platforms {
4222            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
4223                levels.push(p.z);
4224            }
4225        }
4226        levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
4227        levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
4228        levels
4229    }
4230
4231    pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
4232        const TOL: f32 = 0.35;
4233        self.walkable_levels_at(x, y)
4234            .iter()
4235            .any(|&l| (l - z).abs() <= TOL)
4236    }
4237
4238    pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
4239        let mut top = self.elevation_at(x, y);
4240        for p in &self.z_platforms {
4241            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
4242                top = top.max(p.z);
4243            }
4244        }
4245        top
4246    }
4247
4248    /// Authoritative interior context from the server (`inside_building` flag).
4249    pub fn effective_inside_building(&self) -> Option<String> {
4250        self.player_entity().and_then(|p| p.inside_building.clone())
4251    }
4252
4253    /// True when a placed chest shares the player's outdoor/interior space.
4254    /// Owned indoor chests may still arrive in AOI while outdoors (worker route
4255    /// labels); they must not be drawn or treated as nearby world props.
4256    pub fn placed_container_in_current_space(
4257        &self,
4258        c: &flatland_protocol::PlacedContainerView,
4259    ) -> bool {
4260        match (
4261            self.effective_inside_building().as_deref(),
4262            c.building_id.as_deref(),
4263        ) {
4264            (None, None) => true,
4265            (Some(a), Some(b)) => a == b,
4266            _ => false,
4267        }
4268    }
4269
4270    fn merge_stack_catalog_hints(&mut self, stacks: &[flatland_protocol::ItemStack]) {
4271        fn walk(
4272            stacks: &[flatland_protocol::ItemStack],
4273            hints: &mut std::collections::HashMap<String, InventoryHint>,
4274        ) {
4275            for stack in stacks {
4276                if stack.display_name.is_some()
4277                    || stack.category.is_some()
4278                    || stack.base_mass.is_some()
4279                    || stack.base_volume.is_some()
4280                    || stack.base_value_copper.is_some()
4281                {
4282                    hints.insert(
4283                        stack.template_id.clone(),
4284                        InventoryHint {
4285                            display_name: stack
4286                                .display_name
4287                                .clone()
4288                                .unwrap_or_else(|| stack.template_id.clone()),
4289                            category: stack.category.clone().unwrap_or_default(),
4290                            base_mass: stack.base_mass,
4291                            base_volume: stack.base_volume,
4292                            capacity_volume: stack.capacity_volume,
4293                            stackable: stack.stackable.unwrap_or(true),
4294                            listable: stack.listable.unwrap_or_else(|| {
4295                                category_default_listable(stack.category.as_deref().unwrap_or(""))
4296                            }),
4297                            base_value_copper: stack.base_value_copper,
4298                        },
4299                    );
4300                }
4301                walk(&stack.contents, hints);
4302            }
4303        }
4304        walk(stacks, &mut self.inventory_hints);
4305    }
4306
4307    pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
4308        self.inventory_stacks = stacks.to_vec();
4309        self.inventory.clear();
4310        self.inventory_hints.clear();
4311        fn walk(
4312            stacks: &[flatland_protocol::ItemStack],
4313            inventory: &mut std::collections::HashMap<String, u32>,
4314            hints: &mut std::collections::HashMap<String, InventoryHint>,
4315        ) {
4316            for stack in stacks {
4317                *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
4318                if stack.display_name.is_some()
4319                    || stack.category.is_some()
4320                    || stack.base_mass.is_some()
4321                    || stack.base_volume.is_some()
4322                    || stack.base_value_copper.is_some()
4323                {
4324                    hints.insert(
4325                        stack.template_id.clone(),
4326                        InventoryHint {
4327                            display_name: stack
4328                                .display_name
4329                                .clone()
4330                                .unwrap_or_else(|| stack.template_id.clone()),
4331                            category: stack.category.clone().unwrap_or_default(),
4332                            base_mass: stack.base_mass,
4333                            base_volume: stack.base_volume,
4334                            capacity_volume: stack.capacity_volume,
4335                            stackable: stack.stackable.unwrap_or(true),
4336                            listable: stack.listable.unwrap_or_else(|| {
4337                                category_default_listable(stack.category.as_deref().unwrap_or(""))
4338                            }),
4339                            base_value_copper: stack.base_value_copper,
4340                        },
4341                    );
4342                }
4343                walk(&stack.contents, inventory, hints);
4344            }
4345        }
4346        walk(stacks, &mut self.inventory, &mut self.inventory_hints);
4347        // Include worn items (and nested contents, e.g. belt-clipped pouches) in craft counts.
4348        for item in self.worn.values() {
4349            walk(
4350                std::slice::from_ref(item),
4351                &mut self.inventory,
4352                &mut self.inventory_hints,
4353            );
4354        }
4355    }
4356
4357    fn sync_item_catalog(&mut self, entries: &[ItemCatalogEntryView]) {
4358        if entries.is_empty() {
4359            return;
4360        }
4361        self.item_catalog.clear();
4362        self.item_catalog.reserve(entries.len());
4363        for entry in entries {
4364            if entry.template_id.is_empty() {
4365                continue;
4366            }
4367            self.item_catalog
4368                .insert(entry.template_id.clone(), entry.clone());
4369        }
4370    }
4371
4372    /// Remove a carried stack by instance id (root or nested in a worn bag).
4373    /// Used for optimistic give-to-worker so the bag UI does not keep the item
4374    /// until the next tick — and so a later "Gave …" interaction cannot add it back.
4375    fn remove_carried_instance(&mut self, instance_id: uuid::Uuid, quantity: Option<u32>) {
4376        fn take_from(
4377            stacks: &mut Vec<flatland_protocol::ItemStack>,
4378            instance_id: uuid::Uuid,
4379            qty: Option<u32>,
4380        ) -> bool {
4381            if let Some(i) = stacks
4382                .iter()
4383                .position(|s| s.item_instance_id == Some(instance_id))
4384            {
4385                let have = stacks[i].quantity;
4386                let take = qty.unwrap_or(have).min(have);
4387                if take >= have {
4388                    stacks.remove(i);
4389                } else {
4390                    stacks[i].quantity = have - take;
4391                }
4392                return true;
4393            }
4394            stacks
4395                .iter_mut()
4396                .any(|stack| take_from(&mut stack.contents, instance_id, qty))
4397        }
4398
4399        if take_from(&mut self.inventory_stacks, instance_id, quantity) {
4400            let stacks = self.inventory_stacks.clone();
4401            self.sync_inventory_from_stacks(&stacks);
4402            self.refresh_inventory_ui();
4403            return;
4404        }
4405        let slots: Vec<_> = self.worn.keys().copied().collect();
4406        for slot in slots {
4407            let Some(item) = self.worn.get_mut(&slot) else {
4408                continue;
4409            };
4410            if take_from(&mut item.contents, instance_id, quantity) {
4411                let stacks = self.inventory_stacks.clone();
4412                self.sync_inventory_from_stacks(&stacks);
4413                self.refresh_inventory_ui();
4414                return;
4415            }
4416        }
4417    }
4418
4419    /// Apply server interaction deltas immediately (quest rewards, shop, etc.) so the
4420    /// inventory UI updates before the next tick snapshot arrives.
4421    pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
4422        // Worker handoff notices reuse `inventory_delta` as a toast payload, not a
4423        // gain. The same tick already dropped the stack; applying it as an add put
4424        // the item back in the player bag.
4425        if notice.message.starts_with("Gave ") {
4426            if notice.coins_delta != 0 {
4427                crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
4428                let stacks = self.inventory_stacks.clone();
4429                self.sync_inventory_from_stacks(&stacks);
4430            }
4431            self.record_shop_trade_notice(notice);
4432            return;
4433        }
4434        let subtract_items =
4435            notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
4436        for stack in &notice.inventory_delta {
4437            if stack.quantity == 0 {
4438                continue;
4439            }
4440            if subtract_items {
4441                crate::currency::drain_template_stacks(
4442                    &mut self.inventory_stacks,
4443                    &stack.template_id,
4444                    stack.quantity,
4445                );
4446                continue;
4447            }
4448            let stackable = self
4449                .inventory_hints
4450                .get(&stack.template_id)
4451                .map(|h| h.stackable)
4452                .or(stack.stackable)
4453                .unwrap_or(true);
4454            if stackable {
4455                if let Some(existing) = self
4456                    .inventory_stacks
4457                    .iter_mut()
4458                    .find(|s| s.template_id == stack.template_id)
4459                {
4460                    existing.quantity = existing.quantity.saturating_add(stack.quantity);
4461                    if stack.display_name.is_some() {
4462                        existing.display_name = stack.display_name.clone();
4463                    }
4464                    if stack.category.is_some() {
4465                        existing.category = stack.category.clone();
4466                    }
4467                    continue;
4468                }
4469            }
4470            self.inventory_stacks.push(stack.clone());
4471        }
4472        if notice.coins_delta != 0 {
4473            crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
4474        }
4475        if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
4476            let stacks = self.inventory_stacks.clone();
4477            self.sync_inventory_from_stacks(&stacks);
4478        }
4479        self.record_shop_trade_notice(notice);
4480    }
4481
4482    /// Worn body-slot items — each shown as a shell row (unequip via Enter) followed by
4483    /// its nested contents (e.g. pouches clipped onto a worn belt). `BodySlot` derives
4484    /// `Ord` in display order (Head/Body/Arms/Legs/Feet/Back/Waist), so `BTreeMap`
4485    /// iteration alone gives a stable row order.
4486    pub fn worn_rows(&self) -> Vec<InventoryRow> {
4487        let mut rows = Vec::new();
4488        for (slot, item) in &self.worn {
4489            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4490            rows.push(InventoryRow {
4491                depth: 0,
4492                stack: item.clone(),
4493                from: from.clone(),
4494                from_parent_instance_id: None,
4495                is_equip_shell: true,
4496                is_chest_shell: false,
4497                section: InventorySection::Worn,
4498            });
4499            for child in &item.contents {
4500                push_inventory_rows(
4501                    &mut rows,
4502                    1,
4503                    child,
4504                    &from,
4505                    item.item_instance_id,
4506                    InventorySection::Worn,
4507                );
4508            }
4509        }
4510        rows
4511    }
4512
4513    /// Root stacks eligible to present in a player trade (excludes hand-equipped gear).
4514    pub fn trade_presentable_stacks(&self) -> Vec<&flatland_protocol::ItemStack> {
4515        let equipped = self.hand_equipped_instance_ids();
4516        self.inventory_stacks
4517            .iter()
4518            .filter(|s| s.item_instance_id.is_some_and(|id| !equipped.contains(&id)))
4519            .collect()
4520    }
4521
4522    /// Root on-person stacks that can be handed to a hired worker.
4523    pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
4524        let equipped = self.hand_equipped_instance_ids();
4525        self.inventory_stacks
4526            .iter()
4527            .filter_map(|stack| {
4528                let item_instance_id = stack.item_instance_id?;
4529                if equipped.contains(&item_instance_id) {
4530                    return None;
4531                }
4532                let label = stack
4533                    .display_name
4534                    .clone()
4535                    .unwrap_or_else(|| stack.template_id.clone());
4536                let label = if stack.quantity > 1 {
4537                    format!("{label} ×{}", stack.quantity)
4538                } else {
4539                    label
4540                };
4541                Some(WorkerGiveOption {
4542                    item_instance_id,
4543                    label,
4544                    quantity: stack.quantity,
4545                    template_id: stack.template_id.clone(),
4546                })
4547            })
4548            .collect()
4549    }
4550
4551    /// Employer-known blueprints the selected worker does not yet know.
4552    pub fn teachable_blueprint_options(
4553        &self,
4554        worker: &flatland_protocol::HiredWorkerView,
4555    ) -> Vec<WorkerTeachOption> {
4556        let copper = crate::currency::copper_from_counts(&self.inventory);
4557        let mut options: Vec<WorkerTeachOption> = self
4558            .blueprints
4559            .iter()
4560            .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
4561            .map(|bp| {
4562                let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
4563                let cost = bp.worker_train_copper;
4564                WorkerTeachOption {
4565                    blueprint_id: bp.id.clone(),
4566                    label: if bp.label.is_empty() {
4567                        bp.id.clone()
4568                    } else {
4569                        bp.label.clone()
4570                    },
4571                    cost_copper: cost,
4572                    min_level,
4573                    worker_level: worker.level,
4574                    can_afford: copper >= cost,
4575                    level_ok: worker.level >= min_level,
4576                }
4577            })
4578            .collect();
4579        options.sort_by(|a, b| a.label.cmp(&b.label));
4580        options
4581    }
4582
4583    /// Loose on-person inventory (not worn, not inside a placed chest).
4584    /// Root stacks are ordered by category group; nested contents stay under their parent.
4585    pub fn person_rows(&self) -> Vec<InventoryRow> {
4586        self.person_rows_filtered("")
4587    }
4588
4589    pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4590        let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
4591        roots.sort_by(|a, b| {
4592            let ca = a
4593                .category
4594                .as_deref()
4595                .or_else(|| self.inventory_item_category(&a.template_id))
4596                .unwrap_or("");
4597            let cb = b
4598                .category
4599                .as_deref()
4600                .or_else(|| self.inventory_item_category(&b.template_id))
4601                .unwrap_or("");
4602            let ga = inventory_category_group(ca).1;
4603            let gb = inventory_category_group(cb).1;
4604            ga.cmp(&gb).then_with(|| {
4605                let na = a.display_name.as_deref().unwrap_or(a.template_id.as_str());
4606                let nb = b.display_name.as_deref().unwrap_or(b.template_id.as_str());
4607                na.cmp(nb)
4608            })
4609        });
4610        let mut rows = Vec::new();
4611        for stack in roots {
4612            push_inventory_rows_filtered(
4613                &mut rows,
4614                0,
4615                stack,
4616                &flatland_protocol::InventoryLocation::Root,
4617                None,
4618                InventorySection::Person,
4619                filter,
4620            );
4621        }
4622        rows
4623    }
4624
4625    pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4626        if filter.is_empty() {
4627            return self.worn_rows();
4628        }
4629        let mut rows = Vec::new();
4630        for (slot, item) in &self.worn {
4631            if !stack_matches_filter(item, filter) {
4632                continue;
4633            }
4634            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4635            let self_hit = {
4636                let f = filter.to_ascii_lowercase();
4637                let name = item
4638                    .display_name
4639                    .as_deref()
4640                    .unwrap_or("")
4641                    .to_ascii_lowercase();
4642                let tid = item.template_id.to_ascii_lowercase();
4643                name.contains(&f) || tid.contains(&f)
4644            };
4645            rows.push(InventoryRow {
4646                depth: 0,
4647                stack: item.clone(),
4648                from: from.clone(),
4649                from_parent_instance_id: None,
4650                is_equip_shell: true,
4651                is_chest_shell: false,
4652                section: InventorySection::Worn,
4653            });
4654            for child in &item.contents {
4655                if self_hit || stack_matches_filter(child, filter) {
4656                    push_inventory_rows_filtered(
4657                        &mut rows,
4658                        1,
4659                        child,
4660                        &from,
4661                        item.item_instance_id,
4662                        InventorySection::Worn,
4663                        if self_hit { "" } else { filter },
4664                    );
4665                }
4666            }
4667        }
4668        rows
4669    }
4670
4671    /// Items carried inside equipped containers, without showing the equipped
4672    /// container shells themselves. Equipped gear belongs in the paperdoll;
4673    /// these rows are still movable because the contents are carried by the player.
4674    pub fn carried_worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4675        let mut rows = Vec::new();
4676        for (slot, item) in &self.worn {
4677            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4678            for child in &item.contents {
4679                push_inventory_rows_filtered(
4680                    &mut rows,
4681                    0,
4682                    child,
4683                    &from,
4684                    item.item_instance_id,
4685                    InventorySection::Person,
4686                    filter,
4687                );
4688            }
4689        }
4690        rows
4691    }
4692
4693    /// Legacy alias used by the HUD sidebar summary (worn + on-person, unchanged).
4694    pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
4695        let mut rows = self.worn_rows();
4696        rows.extend(self.person_rows());
4697        rows.into_iter().map(|r| (r.depth, r.stack)).collect()
4698    }
4699
4700    /// Placed chests within `CONTAINER_RANGE_M`, nearest first. Contents are only
4701    /// populated when `accessible` — this is what makes a chest's contents
4702    /// disappear the moment you walk away or it's locked without your key.
4703    pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
4704        let (px, py) = self.player_position();
4705        let mut list: Vec<NearbyContainer> = self
4706            .placed_containers
4707            .iter()
4708            .filter(|c| self.placed_container_in_current_space(c))
4709            .filter_map(|c| {
4710                let distance_m = (c.x - px).hypot(c.y - py);
4711                if distance_m > CONTAINER_RANGE_M {
4712                    return None;
4713                }
4714                let mut rows = Vec::new();
4715                let from = flatland_protocol::InventoryLocation::Placed {
4716                    container_id: c.id.clone(),
4717                };
4718                rows.push(InventoryRow {
4719                    depth: 0,
4720                    stack: flatland_protocol::ItemStack {
4721                        template_id: c.template_id.clone(),
4722                        quantity: 1,
4723                        item_instance_id: c.item_instance_id,
4724                        props: Default::default(),
4725                        status_bindings: Vec::new(),
4726                        contents: Vec::new(),
4727                        display_name: Some(c.display_name.clone()),
4728                        category: Some("container".into()),
4729                        capacity_volume: c.capacity_volume,
4730                        worker_lodging_capacity: c.worker_lodging_capacity,
4731                        ..Default::default()
4732                    },
4733                    from: from.clone(),
4734                    from_parent_instance_id: None,
4735                    is_equip_shell: false,
4736                    is_chest_shell: true,
4737                    section: InventorySection::Nearby,
4738                });
4739                if c.accessible {
4740                    for child in &c.contents {
4741                        push_inventory_rows(
4742                            &mut rows,
4743                            1,
4744                            child,
4745                            &from,
4746                            c.item_instance_id,
4747                            InventorySection::Nearby,
4748                        );
4749                    }
4750                }
4751                Some(NearbyContainer {
4752                    view: c.clone(),
4753                    distance_m,
4754                    rows,
4755                })
4756            })
4757            .collect();
4758        list.sort_by(|a, b| {
4759            a.distance_m
4760                .partial_cmp(&b.distance_m)
4761                .unwrap_or(std::cmp::Ordering::Equal)
4762        });
4763        list
4764    }
4765
4766    /// Nearest placed chest within `max_dist`, regardless of accessibility.
4767    pub fn nearest_placed_container(
4768        &self,
4769        max_dist: f32,
4770    ) -> Option<flatland_protocol::PlacedContainerView> {
4771        let (px, py) = self.player_position();
4772        self.placed_containers
4773            .iter()
4774            .filter(|c| self.placed_container_in_current_space(c))
4775            .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
4776            .min_by(|a, b| {
4777                let da = (a.x - px).hypot(a.y - py);
4778                let db = (b.x - px).hypot(b.y - py);
4779                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
4780            })
4781            .cloned()
4782    }
4783
4784    /// Selectable rows for the **active inventory tab** (and current filter).
4785    /// Index into this with `inventory_menu_index`; browser lines must use the same order.
4786    pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
4787        let filter = self.inventory_filter.as_str();
4788        match self.inventory_tab {
4789            InventoryTab::OnPerson => {
4790                let mut rows = self.carried_worn_rows_filtered(filter);
4791                rows.extend(self.person_rows_filtered(filter));
4792                rows
4793            }
4794            InventoryTab::Nearby => {
4795                let mut rows = Vec::new();
4796                for nc in self.nearby_containers() {
4797                    if filter.is_empty() {
4798                        rows.extend(nc.rows);
4799                        continue;
4800                    }
4801                    let shell = nc.rows.first().cloned();
4802                    let contents: Vec<_> = nc
4803                        .rows
4804                        .iter()
4805                        .skip(1)
4806                        .filter(|r| stack_matches_filter(&r.stack, filter))
4807                        .cloned()
4808                        .collect();
4809                    let shell_hit = shell
4810                        .as_ref()
4811                        .map(|s| stack_matches_filter(&s.stack, filter))
4812                        .unwrap_or(false);
4813                    if shell_hit || !contents.is_empty() {
4814                        if let Some(s) = shell {
4815                            rows.push(s);
4816                        }
4817                        if shell_hit {
4818                            rows.extend(nc.rows.into_iter().skip(1));
4819                        } else {
4820                            rows.extend(contents);
4821                        }
4822                    }
4823                }
4824                rows
4825            }
4826        }
4827    }
4828
4829    pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
4830        self.inventory_selectable_rows()
4831            .into_iter()
4832            .nth(self.inventory_menu_index)
4833    }
4834
4835    fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
4836        let cat = self
4837            .inventory_item_category(&row.stack.template_id)
4838            .unwrap_or("");
4839        if cat == "key" {
4840            self.key_inventory_label(&row.stack)
4841        } else {
4842            row.stack
4843                .display_name
4844                .clone()
4845                .unwrap_or_else(|| row.stack.template_id.clone())
4846        }
4847    }
4848
4849    /// Signature of everything already shown on the gfx row title (bindings, grants, qty, worn slot).
4850    fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
4851        let bindings =
4852            format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
4853        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
4854            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
4855            let mode = Self::grant_mode(&row.stack);
4856            format!(" [grant {effect} · {mode} — e apply]")
4857        } else {
4858            String::new()
4859        };
4860        let qty = if row.stack.quantity > 1 {
4861            format!(" ×{}", row.stack.quantity)
4862        } else {
4863            String::new()
4864        };
4865        let worn_slot = if row.is_equip_shell {
4866            match row.from {
4867                flatland_protocol::InventoryLocation::Worn { slot } => {
4868                    format!(" ({})", body_slot_label(slot))
4869                }
4870                _ => String::new(),
4871            }
4872        } else {
4873            String::new()
4874        };
4875        format!("{grant_hint}{bindings}{qty}{worn_slot}")
4876    }
4877
4878    fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
4879        (
4880            row.stack.template_id.clone(),
4881            self.inventory_row_base_label(row),
4882            self.inventory_row_visible_mod_signature(row),
4883        )
4884    }
4885
4886    /// Keys for which two or more instanced rows look the same in the browser (need hover disambiguation).
4887    fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
4888        let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
4889        for row in self.inventory_selectable_rows() {
4890            if row.stack.item_instance_id.is_none() {
4891                continue;
4892            }
4893            let key = self.inventory_row_instance_identity_key(&row);
4894            *counts.entry(key).or_default() += 1;
4895        }
4896        counts
4897            .into_iter()
4898            .filter(|(_, n)| *n > 1)
4899            .map(|(k, _)| k)
4900            .collect()
4901    }
4902
4903    fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
4904        let hex: String = id
4905            .as_simple()
4906            .to_string()
4907            .chars()
4908            .filter(|c| c.is_ascii_hexdigit())
4909            .collect();
4910        let short = if hex.len() >= 4 {
4911            &hex[hex.len() - 4..]
4912        } else {
4913            hex.as_str()
4914        };
4915        format!("Instance {id} (#{short})")
4916    }
4917
4918    /// Format one selectable inventory row for TUI/gfx (label + hints + mass/volume).
4919    pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
4920        let cat = self
4921            .inventory_item_category(&row.stack.template_id)
4922            .unwrap_or("");
4923        let label = self.inventory_row_base_label(row);
4924        let hint: String = if row.is_equip_shell {
4925            " [worn — Enter to unequip]".into()
4926        } else if row.is_chest_shell {
4927            let (locked, lodging_note) = match &row.from {
4928                flatland_protocol::InventoryLocation::Placed { container_id } => {
4929                    let locked = self
4930                        .placed_containers
4931                        .iter()
4932                        .find(|c| c.id == *container_id)
4933                        .map(|c| c.locked)
4934                        .unwrap_or(false);
4935                    let lodging_note = self
4936                        .lodging_occupancy_label(container_id)
4937                        .map(|who| format!(" [lodging: {who}]"))
4938                        .unwrap_or_default();
4939                    (locked, lodging_note)
4940                }
4941                _ => (false, String::new()),
4942            };
4943            if locked {
4944                format!(" [locked — Enter pick up · l unlock]{lodging_note}")
4945            } else {
4946                format!(" [Enter pick up · l lock]{lodging_note}")
4947            }
4948        } else if cat == "key" {
4949            self.key_inventory_hint(&row.stack)
4950        } else {
4951            match cat {
4952                "weapon" => " [weapon]".into(),
4953                "container" => " [bag/chest/belt]".into(),
4954                "lodging" => " [worker lodging]".into(),
4955                "armor" => " [armor]".into(),
4956                _ => String::new(),
4957            }
4958        };
4959        let qty = if row.stack.quantity > 1 {
4960            format!(" ×{}", row.stack.quantity)
4961        } else {
4962            String::new()
4963        };
4964        let bindings =
4965            format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
4966        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
4967            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
4968            let mode = Self::grant_mode(&row.stack);
4969            format!(" [grant {effect} · {mode} — e apply]")
4970        } else {
4971            String::new()
4972        };
4973        let mass = self.stack_mass(&row.stack);
4974        let mass_kg = (mass >= 0.05).then_some(mass);
4975        let mass_str = mass_kg.map(|m| format!("  {m:.1} kg")).unwrap_or_default();
4976        let volume = self.container_volume_stats(row);
4977        let vol_str = self.container_volume_label(row);
4978
4979        let mut title = label.clone();
4980        title.push_str(&qty);
4981        if row.is_equip_shell {
4982            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
4983                title.push_str(&format!(" ({})", body_slot_label(slot)));
4984            }
4985        }
4986
4987        InventoryRowView {
4988            depth: row.depth,
4989            text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
4990            title: format!("{title}{grant_hint}{bindings}"),
4991            mass_kg,
4992            volume,
4993            instance_tooltip: None,
4994        }
4995    }
4996
4997    fn push_browser_item(
4998        &self,
4999        lines: &mut Vec<InventoryBrowserLine>,
5000        row: &InventoryRow,
5001        global_idx: &mut usize,
5002        target: usize,
5003        highlight: bool,
5004        ambiguous_instance_keys: &HashSet<(String, String, String)>,
5005    ) {
5006        let mut view = self.format_inventory_row(row);
5007        if let Some(id) = row.stack.item_instance_id {
5008            let key = self.inventory_row_instance_identity_key(row);
5009            if ambiguous_instance_keys.contains(&key) {
5010                view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
5011            }
5012        }
5013        lines.push(InventoryBrowserLine::Item {
5014            selectable_index: *global_idx,
5015            selected: highlight && *global_idx == target,
5016            depth: view.depth,
5017            text: view.text,
5018            title: view.title,
5019            mass_kg: view.mass_kg,
5020            volume: view.volume,
5021            instance_tooltip: view.instance_tooltip,
5022        });
5023        *global_idx += 1;
5024    }
5025
5026    /// Sectioned inventory browser lines for the active tab. Selectable rows carry
5027    /// `selectable_index` matching `inventory_menu_index`.
5028    pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
5029        let mut lines = Vec::new();
5030        let target = self.inventory_menu_index;
5031        let highlight = !self.show_move_picker && !self.show_grant_picker;
5032        let filter = self.inventory_filter.as_str();
5033        let mut global_idx = 0usize;
5034        let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
5035
5036        match self.inventory_tab {
5037            InventoryTab::OnPerson => {
5038                lines.push(InventoryBrowserLine::Section("— In carried bags —".into()));
5039                let carried = self.carried_worn_rows_filtered(filter);
5040                if carried.is_empty() {
5041                    lines.push(InventoryBrowserLine::Hint(
5042                        "  (no items in carried bags)".into(),
5043                    ));
5044                } else {
5045                    for row in &carried {
5046                        self.push_browser_item(
5047                            &mut lines,
5048                            row,
5049                            &mut global_idx,
5050                            target,
5051                            highlight,
5052                            &ambiguous_instance_keys,
5053                        );
5054                    }
5055                }
5056
5057                lines.push(InventoryBrowserLine::Blank);
5058                lines.push(InventoryBrowserLine::Section(
5059                    "— On you (loose, not worn) —".into(),
5060                ));
5061                let person = self.person_rows_filtered(filter);
5062                if person.is_empty() {
5063                    lines.push(InventoryBrowserLine::Hint("  (empty)".into()));
5064                } else {
5065                    let mut last_group: Option<&'static str> = None;
5066                    for row in &person {
5067                        if row.depth == 0 {
5068                            let cat = row
5069                                .stack
5070                                .category
5071                                .as_deref()
5072                                .or_else(|| self.inventory_item_category(&row.stack.template_id))
5073                                .unwrap_or("");
5074                            let (group, _) = inventory_category_group(cat);
5075                            if last_group != Some(group) {
5076                                lines.push(InventoryBrowserLine::SlotLabel(format!("  {group}")));
5077                                last_group = Some(group);
5078                            }
5079                        }
5080                        self.push_browser_item(
5081                            &mut lines,
5082                            row,
5083                            &mut global_idx,
5084                            target,
5085                            highlight,
5086                            &ambiguous_instance_keys,
5087                        );
5088                    }
5089                }
5090            }
5091            InventoryTab::Nearby => {
5092                let nearby = self.nearby_containers();
5093                if nearby.is_empty() {
5094                    lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
5095                    lines.push(InventoryBrowserLine::Hint(
5096                        "  (none within reach — walk up to a chest)".into(),
5097                    ));
5098                    lines.push(InventoryBrowserLine::Hint(
5099                        "  Select an on-person item, then m / Enter → move into chest.".into(),
5100                    ));
5101                } else {
5102                    let mut any_visible = false;
5103                    for nc in &nearby {
5104                        let shell = nc.rows.first();
5105                        let contents: Vec<&InventoryRow> = if filter.is_empty() {
5106                            nc.rows.iter().skip(1).collect()
5107                        } else {
5108                            let shell_hit = shell
5109                                .map(|s| {
5110                                    let f = filter.to_ascii_lowercase();
5111                                    let name = s
5112                                        .stack
5113                                        .display_name
5114                                        .as_deref()
5115                                        .unwrap_or("")
5116                                        .to_ascii_lowercase();
5117                                    let tid = s.stack.template_id.to_ascii_lowercase();
5118                                    name.contains(&f) || tid.contains(&f)
5119                                })
5120                                .unwrap_or(false);
5121                            if shell_hit {
5122                                nc.rows.iter().skip(1).collect()
5123                            } else {
5124                                nc.rows
5125                                    .iter()
5126                                    .skip(1)
5127                                    .filter(|r| stack_matches_filter(&r.stack, filter))
5128                                    .collect()
5129                            }
5130                        };
5131                        let shell_visible = filter.is_empty()
5132                            || shell
5133                                .map(|s| stack_matches_filter(&s.stack, filter))
5134                                .unwrap_or(false)
5135                            || !contents.is_empty();
5136                        if !shell_visible && shell.is_some() {
5137                            continue;
5138                        }
5139                        any_visible = true;
5140                        lines.push(InventoryBrowserLine::Blank);
5141                        let lock_note = if nc.view.locked && nc.view.accessible {
5142                            "  unlocked with your key"
5143                        } else if nc.view.locked {
5144                            "  locked"
5145                        } else {
5146                            ""
5147                        };
5148                        lines.push(InventoryBrowserLine::Section(format!(
5149                            "— {} ({:.0}m away){lock_note} —",
5150                            nc.view.display_name, nc.distance_m
5151                        )));
5152                        if !nc.view.accessible {
5153                            lines.push(InventoryBrowserLine::Hint(
5154                                "  locked — need the matching key (l to try)".into(),
5155                            ));
5156                        } else if nc.rows.is_empty() {
5157                            lines.push(InventoryBrowserLine::Hint(
5158                                "  (empty — switch to On person, select an item, m to move in)"
5159                                    .into(),
5160                            ));
5161                        } else if let Some(shell_row) = shell {
5162                            self.push_browser_item(
5163                                &mut lines,
5164                                shell_row,
5165                                &mut global_idx,
5166                                target,
5167                                highlight,
5168                                &ambiguous_instance_keys,
5169                            );
5170                            for row in contents {
5171                                self.push_browser_item(
5172                                    &mut lines,
5173                                    row,
5174                                    &mut global_idx,
5175                                    target,
5176                                    highlight,
5177                                    &ambiguous_instance_keys,
5178                                );
5179                            }
5180                        }
5181                    }
5182                    if !any_visible {
5183                        lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
5184                        lines.push(InventoryBrowserLine::Hint(
5185                            "  (no matching items — clear filter with Esc)".into(),
5186                        ));
5187                    }
5188                }
5189            }
5190        }
5191        lines
5192    }
5193
5194    /// Destinations for picking up a placed chest/crate into inventory.
5195    pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
5196        let mut opts = Vec::new();
5197        opts.push(MoveOption::action(
5198            "Relocate…",
5199            MoveOptionKind::RelocatePlaced {
5200                container_id: container_id.to_string(),
5201            },
5202        ));
5203        opts.push(MoveOption::action(
5204            "On your person (loose)",
5205            MoveOptionKind::PickupPlaced {
5206                container_id: container_id.to_string(),
5207                nest_location: flatland_protocol::InventoryLocation::Root,
5208                nest_parent_instance_id: None,
5209            },
5210        ));
5211        for (slot, item) in &self.worn {
5212            if item.category.as_deref() != Some("container") {
5213                continue;
5214            }
5215            if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
5216                continue;
5217            }
5218            let Some(parent_id) = item.item_instance_id else {
5219                continue;
5220            };
5221            let shell_name = item
5222                .display_name
5223                .clone()
5224                .unwrap_or_else(|| item.template_id.clone());
5225            let nest_location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
5226            opts.push(MoveOption {
5227                label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
5228                kind: MoveOptionKind::PickupPlaced {
5229                    container_id: container_id.to_string(),
5230                    nest_location: nest_location.clone(),
5231                    nest_parent_instance_id: Some(parent_id),
5232                },
5233                volume: self.destination_volume_stats(&nest_location, Some(parent_id)),
5234            });
5235            // Nested pouches inside the worn bag.
5236            self.append_chest_pickup_nested(
5237                &mut opts,
5238                container_id,
5239                nest_location,
5240                item,
5241                &format!("in {shell_name}"),
5242            );
5243        }
5244        opts.push(MoveOption::action("Cancel", MoveOptionKind::Cancel));
5245        opts
5246    }
5247
5248    fn append_chest_pickup_nested(
5249        &self,
5250        opts: &mut Vec<MoveOption>,
5251        container_id: &str,
5252        location: flatland_protocol::InventoryLocation,
5253        parent: &flatland_protocol::ItemStack,
5254        context: &str,
5255    ) {
5256        for child in &parent.contents {
5257            if child.category.as_deref() != Some("container") {
5258                continue;
5259            }
5260            if !Self::is_volume_container_stack(child) {
5261                continue;
5262            }
5263            // Skip placeable nested chests — relocating into another chest is not useful.
5264            if child.world_placeable == Some(true) {
5265                continue;
5266            }
5267            let Some(child_id) = child.item_instance_id else {
5268                continue;
5269            };
5270            let name = child
5271                .display_name
5272                .clone()
5273                .unwrap_or_else(|| child.template_id.clone());
5274            opts.push(MoveOption {
5275                label: format!("{name} ({context})"),
5276                kind: MoveOptionKind::PickupPlaced {
5277                    container_id: container_id.to_string(),
5278                    nest_location: location.clone(),
5279                    nest_parent_instance_id: Some(child_id),
5280                },
5281                volume: self.destination_volume_stats(&location, Some(child_id)),
5282            });
5283            self.append_chest_pickup_nested(
5284                opts,
5285                container_id,
5286                location.clone(),
5287                child,
5288                &format!("in {name}"),
5289            );
5290        }
5291    }
5292
5293    /// Build the "move to…" destination list for an item currently at `from`.
5294    pub fn move_destinations_for(
5295        &self,
5296        from: &flatland_protocol::InventoryLocation,
5297        from_parent_instance_id: Option<uuid::Uuid>,
5298        moving_instance_id: Option<uuid::Uuid>,
5299        moving_template_id: &str,
5300    ) -> Vec<MoveOption> {
5301        let mut opts = Vec::new();
5302        if *from != flatland_protocol::InventoryLocation::Root {
5303            opts.push(MoveOption::action(
5304                "On your person (loose)",
5305                MoveOptionKind::Move {
5306                    location: flatland_protocol::InventoryLocation::Root,
5307                    parent_instance_id: None,
5308                },
5309            ));
5310        }
5311        for (slot, item) in &self.worn {
5312            if item.category.as_deref() != Some("container") {
5313                continue;
5314            }
5315            let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
5316            let shell_name = item
5317                .display_name
5318                .clone()
5319                .unwrap_or_else(|| item.template_id.clone());
5320
5321            // Backpack and other worn volume containers — store directly inside the shell.
5322            if *slot != BodySlot::Waist
5323                && item.item_instance_id != moving_instance_id
5324                && Self::is_volume_container_stack(item)
5325            {
5326                self.push_move_destination(
5327                    &mut opts,
5328                    format!("{shell_name} (worn {})", body_slot_label(*slot)),
5329                    location.clone(),
5330                    item.item_instance_id,
5331                    from,
5332                    from_parent_instance_id,
5333                );
5334            }
5335
5336            // Belt loops only accept pouch attachments — not loose materials.
5337            if *slot == BodySlot::Waist
5338                && Self::attaches_to_belt_loop(moving_template_id)
5339                && item.item_instance_id != moving_instance_id
5340            {
5341                self.push_move_destination(
5342                    &mut opts,
5343                    format!("{shell_name} (belt loop)"),
5344                    location.clone(),
5345                    item.item_instance_id,
5346                    from,
5347                    from_parent_instance_id,
5348                );
5349            }
5350
5351            let context = if *slot == BodySlot::Waist {
5352                format!("on {shell_name}")
5353            } else {
5354                format!("in {shell_name}")
5355            };
5356            self.append_nested_container_destinations(
5357                &mut opts,
5358                location,
5359                item,
5360                &context,
5361                from,
5362                from_parent_instance_id,
5363                moving_instance_id,
5364            );
5365        }
5366        for nc in self.nearby_containers() {
5367            if !nc.view.accessible {
5368                continue;
5369            }
5370            let location = flatland_protocol::InventoryLocation::Placed {
5371                container_id: nc.view.id.clone(),
5372            };
5373            self.push_move_destination(
5374                &mut opts,
5375                format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
5376                location,
5377                nc.view.item_instance_id,
5378                from,
5379                from_parent_instance_id,
5380            );
5381        }
5382        let allow_drop = moving_instance_id
5383            .map(|id| !self.hand_equipped_instance_ids().contains(&id))
5384            .unwrap_or(true)
5385            && moving_instance_id
5386                .and_then(|id| self.stack_for_instance(id))
5387                .map(|stack| {
5388                    !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
5389                })
5390                .unwrap_or(
5391                    moving_template_id != KEY_TEMPLATE
5392                        && moving_template_id != PROPERTY_DEED_TEMPLATE,
5393                );
5394        if allow_drop {
5395            opts.push(MoveOption::action(
5396                "Drop on the ground",
5397                MoveOptionKind::Drop,
5398            ));
5399        }
5400        opts.push(MoveOption::action("Cancel", MoveOptionKind::Cancel));
5401        opts
5402    }
5403
5404    fn is_same_container_dest(
5405        dest_location: &flatland_protocol::InventoryLocation,
5406        dest_parent: Option<uuid::Uuid>,
5407        from: &flatland_protocol::InventoryLocation,
5408        from_parent: Option<uuid::Uuid>,
5409    ) -> bool {
5410        dest_location == from && dest_parent == from_parent
5411    }
5412
5413    fn push_move_destination(
5414        &self,
5415        opts: &mut Vec<MoveOption>,
5416        label: String,
5417        location: flatland_protocol::InventoryLocation,
5418        parent_instance_id: Option<uuid::Uuid>,
5419        from: &flatland_protocol::InventoryLocation,
5420        from_parent_instance_id: Option<uuid::Uuid>,
5421    ) {
5422        if Self::is_same_container_dest(
5423            &location,
5424            parent_instance_id,
5425            from,
5426            from_parent_instance_id,
5427        ) {
5428            return;
5429        }
5430        let volume = self.destination_volume_stats(&location, parent_instance_id);
5431        opts.push(MoveOption {
5432            label,
5433            kind: MoveOptionKind::Move {
5434                location,
5435                parent_instance_id,
5436            },
5437            volume,
5438        });
5439    }
5440
5441    fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
5442        stack.capacity_volume.is_some_and(|c| c > 0.0)
5443    }
5444
5445    fn attaches_to_belt_loop(template_id: &str) -> bool {
5446        matches!(template_id, "leather_pouch" | "dimensional_pouch")
5447    }
5448
5449    fn append_nested_container_destinations(
5450        &self,
5451        opts: &mut Vec<MoveOption>,
5452        location: flatland_protocol::InventoryLocation,
5453        container: &flatland_protocol::ItemStack,
5454        context: &str,
5455        from: &flatland_protocol::InventoryLocation,
5456        from_parent_instance_id: Option<uuid::Uuid>,
5457        moving_instance_id: Option<uuid::Uuid>,
5458    ) {
5459        for child in &container.contents {
5460            if Self::is_volume_container_stack(child)
5461                && child.item_instance_id != moving_instance_id
5462            {
5463                let name = child
5464                    .display_name
5465                    .clone()
5466                    .unwrap_or_else(|| child.template_id.clone());
5467                self.push_move_destination(
5468                    opts,
5469                    format!("{name} ({context})"),
5470                    location.clone(),
5471                    child.item_instance_id,
5472                    from,
5473                    from_parent_instance_id,
5474                );
5475            }
5476            let nested_context = format!(
5477                "in {}",
5478                child.display_name.as_deref().unwrap_or(&child.template_id)
5479            );
5480            self.append_nested_container_destinations(
5481                opts,
5482                location.clone(),
5483                child,
5484                &nested_context,
5485                from,
5486                from_parent_instance_id,
5487                moving_instance_id,
5488            );
5489        }
5490    }
5491
5492    fn clamp_inventory_indices(&mut self) {
5493        let n = self.inventory_selectable_rows().len();
5494        self.inventory_menu_index = if n == 0 {
5495            0
5496        } else {
5497            self.inventory_menu_index.min(n - 1)
5498        };
5499        if let Some(picker) = &self.move_picker {
5500            let pn = picker.options.len();
5501            self.move_picker_index = if pn == 0 {
5502                0
5503            } else {
5504                self.move_picker_index.min(pn - 1)
5505            };
5506        }
5507    }
5508
5509    /// Drop interior map layers whenever the player is outdoors.
5510    /// Restores outdoor z-bands saved before [`Self::sync_interior_z_bands`] overwrote them.
5511    /// Tick deltas never refresh `z_platforms`; without restore, stale interior platforms
5512    /// force `build_grid` into a full-map z pass on every click until client restart.
5513    fn sync_interior_map_context(&mut self) {
5514        if self.effective_inside_building().is_none() {
5515            self.interior_map = None;
5516            if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
5517                self.z_platforms = platforms;
5518                self.z_transitions = transitions;
5519            }
5520            return;
5521        }
5522        self.sync_interior_z_bands();
5523    }
5524
5525    /// While indoors, z-bands come from the active interior blueprint (multi-floor stairs).
5526    fn sync_interior_z_bands(&mut self) {
5527        if self.effective_inside_building().is_some() {
5528            if let Some(map) = &self.interior_map {
5529                if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
5530                    if self.z_bands_outdoor_backup.is_none() {
5531                        self.z_bands_outdoor_backup = Some((
5532                            std::mem::take(&mut self.z_platforms),
5533                            std::mem::take(&mut self.z_transitions),
5534                        ));
5535                    }
5536                    self.z_platforms = map.z_platforms.clone();
5537                    self.z_transitions = map.z_transitions.clone();
5538                }
5539            }
5540        }
5541    }
5542
5543    fn apply_snapshot_fields(
5544        &mut self,
5545        snapshot: &flatland_protocol::Snapshot,
5546        entity_id: EntityId,
5547    ) {
5548        self.tick = snapshot.tick;
5549        self.chunk_rev = snapshot.chunk_rev;
5550        self.content_rev = snapshot.content_rev;
5551        self.publish_rev = snapshot.publish_rev;
5552        self.resource_nodes = snapshot.resource_nodes.clone();
5553        self.replace_harvest_route_nodes(&snapshot.resource_nodes);
5554        self.ground_drops = snapshot.ground_drops.clone();
5555        self.placed_containers = snapshot.placed_containers.clone();
5556        self.world_x0 = snapshot.world_x0;
5557        self.world_y0 = snapshot.world_y0;
5558        self.world_width_m = snapshot.world_width_m;
5559        self.world_height_m = snapshot.world_height_m;
5560        self.world_clock = snapshot.world_clock;
5561        self.terrain_zones = snapshot.terrain_zones.clone();
5562        self.z_platforms = snapshot.z_platforms.clone();
5563        self.z_transitions = snapshot.z_transitions.clone();
5564        // Snapshot is authoritative for the current context; drop any prior enter/leave stash.
5565        self.z_bands_outdoor_backup = None;
5566        self.buildings = snapshot.buildings.clone();
5567        self.doors = snapshot.doors.clone();
5568        self.interior_map = snapshot.interior_map.clone();
5569        self.npcs = snapshot.npcs.clone();
5570        self.blueprints = snapshot.blueprints.clone();
5571        self.building_materials = snapshot.building_materials.clone();
5572        self.sync_inventory_from_stacks(&snapshot.inventory);
5573        self.player = snapshot
5574            .entities
5575            .iter()
5576            .find(|e| e.id == entity_id)
5577            .cloned();
5578        self.entities = snapshot.entities.clone();
5579        self.quest_log = snapshot.quest_log.clone();
5580        self.apply_hired_workers(snapshot.hired_workers.clone());
5581        self.interactables = snapshot.interactables.clone();
5582        self.ledger = snapshot.ledger.clone();
5583        self.career = snapshot.career.clone();
5584        self.combat_fx = snapshot.combat_fx.clone();
5585        self.ground_hazards = snapshot.ground_hazards.clone();
5586        self.property_zones = snapshot.property_zones.clone();
5587        self.tax_zones = snapshot.tax_zones.clone();
5588        self.growth_zones = snapshot.growth_zones.clone();
5589        self.biome_zones = snapshot.biome_zones.clone();
5590        self.terrain_kind_nav = snapshot.terrain_kind_nav.clone();
5591        self.property_plots = snapshot.property_plots.clone();
5592        self.property_plot_settings = snapshot.property_plot_settings.clone();
5593        self.sync_item_catalog(&snapshot.item_catalog);
5594        // If welcome/reconnect lands indoors, seed an empty outdoor restore target so
5595        // sync_interior_z_bands does not stash interior snapshot bands as "outdoor".
5596        if self.effective_inside_building().is_some() {
5597            self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
5598        }
5599        self.sync_interior_map_context();
5600        self.refresh_whisper_range();
5601        self.sync_gameplay_audio();
5602    }
5603
5604    fn replace_harvest_route_nodes(&mut self, incoming: &[flatland_protocol::ResourceNodeView]) {
5605        self.harvest_route_nodes = incoming
5606            .iter()
5607            .filter(|n| crate::is_harvest_route_node(n))
5608            .cloned()
5609            .collect();
5610    }
5611
5612    fn upsert_harvest_route_nodes(&mut self, incoming: &[flatland_protocol::ResourceNodeView]) {
5613        for node in incoming.iter().filter(|n| crate::is_harvest_route_node(n)) {
5614            if let Some(existing) = self
5615                .harvest_route_nodes
5616                .iter_mut()
5617                .find(|n| n.id == node.id)
5618            {
5619                *existing = node.clone();
5620            } else {
5621                self.harvest_route_nodes.push(node.clone());
5622            }
5623        }
5624    }
5625
5626    /// Re-derive inventory selection state after a snapshot/tick — chests that
5627    /// went out of range or got locked simply vanish from the row list, and the
5628    /// move picker (if any) closes once its item is no longer reachable.
5629    fn refresh_inventory_ui(&mut self) {
5630        if let Some(picker) = &self.move_picker {
5631            let instance_id = picker.item_instance_id;
5632            let still_exists = self
5633                .inventory_selectable_rows()
5634                .iter()
5635                .any(|r| r.stack.item_instance_id == Some(instance_id));
5636            if !still_exists {
5637                self.move_picker = None;
5638                self.show_move_picker = false;
5639            }
5640        }
5641        if let Some(picker) = &self.destroy_picker {
5642            let instance_id = picker.item_instance_id;
5643            let still_exists = self
5644                .inventory_selectable_rows()
5645                .iter()
5646                .any(|r| r.stack.item_instance_id == Some(instance_id));
5647            if !still_exists {
5648                self.destroy_picker = None;
5649                self.show_destroy_picker = false;
5650                self.destroy_confirm_pending = false;
5651            }
5652        }
5653        if let Some(picker) = &self.deconstruct_picker {
5654            let instance_id = picker.item_instance_id;
5655            let still_exists = self
5656                .inventory_selectable_rows()
5657                .iter()
5658                .any(|r| r.stack.item_instance_id == Some(instance_id));
5659            if !still_exists {
5660                self.deconstruct_picker = None;
5661                self.show_deconstruct_picker = false;
5662                self.deconstruct_confirm_pending = false;
5663            }
5664        }
5665        self.clamp_inventory_indices();
5666    }
5667
5668    /// Replace the hired-worker list from a snapshot/delta.
5669    ///
5670    /// Keeps a stable sort and remaps `workers_menu_index` by instance id so the
5671    /// selected worker (and its step line) does not jump when the server rebuilds
5672    /// the list.
5673    fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
5674        let selected_id = self
5675            .hired_workers
5676            .get(self.workers_menu_index)
5677            .map(|w| w.instance_id.clone());
5678        let previous_worker_ids: HashSet<String> = self
5679            .hired_workers
5680            .iter()
5681            .map(|worker| worker.instance_id.clone())
5682            .collect();
5683        workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
5684        let now = Instant::now();
5685        let saw_new_worker = workers
5686            .iter()
5687            .any(|worker| !previous_worker_ids.contains(&worker.instance_id));
5688        for worker in &workers {
5689            let was_hit = self
5690                .hired_workers
5691                .iter()
5692                .find(|previous| previous.instance_id == worker.instance_id)
5693                .is_some_and(|previous| {
5694                    matches!(worker.mode, flatland_protocol::WorkerModeView::Defender)
5695                        && worker.vitals.health_pct + 0.01 < previous.vitals.health_pct
5696                });
5697            if was_hit {
5698                self.worker_health_ring_until
5699                    .insert(worker.entity_id, now + WORKER_HEALTH_RING_HOLD);
5700            }
5701        }
5702        let worker_entity_ids: HashSet<EntityId> =
5703            workers.iter().map(|worker| worker.entity_id).collect();
5704        self.worker_health_ring_until
5705            .retain(|entity_id, _| worker_entity_ids.contains(entity_id));
5706        for w in &workers {
5707            let prev_err = self
5708                .hired_workers
5709                .iter()
5710                .find(|p| p.instance_id == w.instance_id)
5711                .and_then(|p| p.last_error.as_deref());
5712            let new_err = w.last_error.as_deref();
5713            if new_err != prev_err {
5714                if let Some(err) = new_err {
5715                    if !worker_error_is_transient(err) {
5716                        self.push_log(format!("Worker {}: {err}", w.label));
5717                    }
5718                }
5719            }
5720        }
5721        let mut next_display = BTreeMap::new();
5722        let mut next_errors = BTreeMap::new();
5723        for w in &workers {
5724            let mut sticky = self
5725                .worker_step_display
5726                .remove(&w.instance_id)
5727                .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
5728            sticky.observe(&w.step_label, now);
5729            next_display.insert(w.instance_id.clone(), sticky);
5730
5731            let mut err_sticky = self
5732                .worker_error_display
5733                .remove(&w.instance_id)
5734                .unwrap_or_default();
5735            err_sticky.observe(w.last_error.as_deref(), now);
5736            if err_sticky.shown(now).is_some() {
5737                next_errors.insert(w.instance_id.clone(), err_sticky);
5738            }
5739        }
5740        self.worker_step_display = next_display;
5741        self.worker_error_display = next_errors;
5742        self.hired_workers = workers;
5743        if saw_new_worker {
5744            self.pending_worker_hire_since = None;
5745        }
5746        self.sync_worker_take_picker_from_hired();
5747        if let Some(id) = selected_id {
5748            if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
5749                self.workers_menu_index = idx;
5750                return;
5751            }
5752        }
5753        if self.workers_menu_index >= self.hired_workers.len() {
5754            self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
5755        }
5756    }
5757
5758    /// Keep the take-from-worker sheet in sync after hired-worker inventory updates.
5759    fn sync_worker_take_picker_from_hired(&mut self) {
5760        if !self.show_worker_take_picker {
5761            return;
5762        }
5763        let Some(picker) = self.worker_take_picker.clone() else {
5764            return;
5765        };
5766        let Some(worker) = self
5767            .hired_workers
5768            .iter()
5769            .find(|w| w.instance_id == picker.worker_instance_id)
5770            .cloned()
5771        else {
5772            self.show_worker_take_picker = false;
5773            self.worker_take_picker = None;
5774            self.worker_take_picker_index = 0;
5775            return;
5776        };
5777        let options: Vec<WorkerGiveOption> = worker
5778            .inventory
5779            .iter()
5780            .filter_map(|stack| {
5781                let item_instance_id = stack.item_instance_id?;
5782                let label = stack
5783                    .display_name
5784                    .clone()
5785                    .unwrap_or_else(|| stack.template_id.clone());
5786                let label = if stack.quantity > 1 {
5787                    format!("{label} ×{}", stack.quantity)
5788                } else {
5789                    label
5790                };
5791                Some(WorkerGiveOption {
5792                    item_instance_id,
5793                    label,
5794                    quantity: stack.quantity,
5795                    template_id: stack.template_id.clone(),
5796                })
5797            })
5798            .collect();
5799        if options.is_empty() {
5800            self.show_worker_take_picker = false;
5801            self.worker_take_picker = None;
5802            self.worker_take_picker_index = 0;
5803            return;
5804        }
5805        let prev_id = picker
5806            .options
5807            .get(self.worker_take_picker_index)
5808            .map(|o| o.item_instance_id);
5809        let idx = prev_id
5810            .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
5811            .unwrap_or(0)
5812            .min(options.len().saturating_sub(1));
5813        let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
5814        let quantity = picker.quantity.clamp(1, max_qty);
5815        self.worker_take_picker_index = idx;
5816        self.worker_take_picker = Some(WorkerTakePicker {
5817            worker_instance_id: picker.worker_instance_id,
5818            worker_label: picker.worker_label,
5819            options,
5820            quantity,
5821        });
5822    }
5823
5824    /// Held coarse step label for the workers menu (`step:` line).
5825    pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
5826        self.worker_step_display
5827            .get(worker_instance_id)
5828            .map(|s| s.shown.as_str())
5829            .or_else(|| {
5830                self.hired_workers
5831                    .iter()
5832                    .find(|w| w.instance_id == worker_instance_id)
5833                    .map(|w| w.step_label.as_str())
5834            })
5835            .unwrap_or("")
5836    }
5837
5838    /// Held error line for workers UI (detail + compact).
5839    pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
5840        let now = Instant::now();
5841        self.worker_error_display
5842            .get(worker_instance_id)
5843            .and_then(|s| s.shown(now))
5844            .or_else(|| {
5845                self.hired_workers
5846                    .iter()
5847                    .find(|w| w.instance_id == worker_instance_id)
5848                    .and_then(|w| w.last_error.as_deref())
5849                    .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
5850            })
5851            .filter(|e| !worker_error_is_hud_noise(e))
5852    }
5853
5854    fn apply_combat_hud(&mut self, combat: &CombatHud) {
5855        self.in_combat = combat.in_combat;
5856        self.auto_attack = combat.auto_attack;
5857        self.combat_has_los = combat.has_los;
5858        self.attack_cd_ticks = combat.attack_cd_ticks;
5859        self.gcd_ticks = combat.gcd_ticks;
5860        self.weapon_ability_id = combat.ability_id.clone();
5861        self.mainhand_template_id = combat.mainhand_template_id.clone();
5862        self.mainhand_label = combat.mainhand_label.clone();
5863        self.mainhand_instance_id = combat.mainhand_instance_id;
5864        self.offhand_template_id = combat.offhand_template_id.clone();
5865        self.offhand_label = combat.offhand_label.clone();
5866        self.offhand_instance_id = combat.offhand_instance_id;
5867        self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
5868            1
5869        } else {
5870            combat.mainhand_hand_slots
5871        };
5872        self.defense = combat.defense.clone();
5873        self.worn = combat.worn.iter().cloned().collect();
5874        self.carry_mass = combat.carry_mass;
5875        self.carry_mass_max = combat.carry_mass_max;
5876        self.encumbrance = combat.encumbrance;
5877        self.move_speed_mps = combat.move_speed_mps;
5878        self.move_speed_mult = combat.move_speed_mult;
5879        self.cast_progress = combat.cast.clone();
5880        self.timed_channel = combat.timed_channel.clone();
5881        self.sync_craft_ready_pin();
5882        self.plot_build_offer = combat.plot_build.clone();
5883        self.ability_cooldowns = combat.ability_cooldowns.clone();
5884        self.blocking_active = combat.blocking_active;
5885        self.max_target_slots = combat.max_target_slots.max(1);
5886        self.combat_slots = combat.slots.clone();
5887        self.rotation_presets = combat.rotation_presets.clone();
5888        self.known_abilities = combat.known_abilities.clone();
5889        self.ability_meta = combat
5890            .ability_meta
5891            .iter()
5892            .cloned()
5893            .map(|meta| (meta.id.clone(), meta))
5894            .collect();
5895        self.ability_mastery = combat
5896            .ability_mastery
5897            .iter()
5898            .cloned()
5899            .map(|row| (row.ability_id.clone(), row))
5900            .collect();
5901        self.hotbar = combat.hotbar.clone();
5902        self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
5903        self.keychain_stacks = combat.keychain.clone();
5904        self.whisper_pouch_stacks = combat.whisper_pouch.clone();
5905        self.combat_target_detail = combat.target.clone();
5906        self.statuses = combat.statuses.clone();
5907        self.combat_target = combat.target_entity_id;
5908        if combat.progression_xp_base > 0.0 {
5909            self.progression_curve = Some(flatland_protocol::ProgressionCurve {
5910                baseline_display: combat.progression_baseline,
5911                xp_base: combat.progression_xp_base,
5912                xp_growth: combat.progression_xp_growth,
5913            });
5914        }
5915        if let Some(xp) = &combat.progression_xp {
5916            if let Some(player) = &mut self.player {
5917                player.progression_xp = Some(xp.clone());
5918                if let Some(attrs) = combat.attributes {
5919                    player.attributes = Some(attrs);
5920                }
5921                if let Some(skills) = &combat.skills {
5922                    player.skills = Some(skills.clone());
5923                }
5924            }
5925        }
5926        if let Some(label) = &combat.target_label {
5927            self.combat_target_label = Some(label.clone());
5928        } else if let Some(id) = combat.target_entity_id {
5929            self.combat_target_label = self
5930                .entities
5931                .iter()
5932                .find(|e| e.id == id)
5933                .map(|e| e.label.clone())
5934                .or_else(|| self.combat_target_label.clone());
5935        }
5936        self.refresh_inventory_ui();
5937    }
5938
5939    /// Entity id assigned to a combat slot (from server HUD).
5940    pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
5941        self.combat_slots
5942            .iter()
5943            .find(|s| s.slot_index == slot)
5944            .and_then(|s| s.target_entity_id)
5945            .or_else(|| if slot == 1 { self.combat_target } else { None })
5946    }
5947
5948    /// True if `ability_id` may be cast at a free ground point (`aim_mode` `ground` or `either`).
5949    pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
5950        self.ability_meta
5951            .get(ability_id)
5952            .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
5953            // Until HUD meta syncs, still allow ground cast when an aim point is armed —
5954            // the server validates aim_mode.
5955            .unwrap_or(self.ground_target.is_some())
5956    }
5957
5958    /// True if `ability_id` requires a free ground point (`aim_mode` `ground`).
5959    pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
5960        self.ability_meta
5961            .get(ability_id)
5962            .map(|meta| meta.aim_mode == "ground")
5963            .unwrap_or(false)
5964    }
5965
5966    /// True when the ability may be placed in an auto-rotation preset.
5967    /// Missing meta (pre-sync) defaults to eligible; server still validates.
5968    pub fn ability_auto_rotation_eligible(&self, ability_id: &str) -> bool {
5969        self.ability_meta
5970            .get(ability_id)
5971            .map(|meta| meta.auto_rotation_eligible)
5972            .unwrap_or(true)
5973    }
5974
5975    /// Set the local free-aim ground target (world meters, Shift+click on open ground).
5976    pub fn set_ground_target(&mut self, x: f32, y: f32) {
5977        self.ground_target = Some((x, y, 0.0));
5978    }
5979
5980    /// Clear the local free-aim ground target.
5981    pub fn clear_ground_target(&mut self) {
5982        self.ground_target = None;
5983    }
5984
5985    /// Ability bound to hotbar key `1`–`9` from server-persisted state.
5986    /// May be an ability id or `item:<template_id>` consumable binding.
5987    pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
5988        if !(1..=9).contains(&slot_1_to_9) {
5989            return None;
5990        }
5991        self.hotbar
5992            .get((slot_1_to_9 - 1) as usize)
5993            .and_then(|a| a.as_deref())
5994            .filter(|id| !id.is_empty())
5995    }
5996
5997    /// Short label for a hotbar slot (ability id, or consumable name × qty).
5998    pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
5999        let binding = self.hotbar_ability(slot_1_to_9)?;
6000        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
6001            let name = self
6002                .inventory_hints
6003                .get(template_id)
6004                .map(|h| h.display_name.as_str())
6005                .unwrap_or(template_id);
6006            let qty = self.inventory.get(template_id).copied().unwrap_or(0);
6007            Some(format!("{name}×{qty}"))
6008        } else {
6009            Some(binding.to_string())
6010        }
6011    }
6012
6013    /// Known abilities plus current weapon ability (for loadout / rotation pickers).
6014    pub fn loadout_ability_choices(&self) -> Vec<String> {
6015        let mut out = self.known_abilities.clone();
6016        let weapon = self.weapon_ability_id.trim();
6017        if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
6018            out.push(weapon.to_string());
6019        }
6020        out
6021    }
6022
6023    /// Hotbar bind candidates: learned abilities, then on-person consumables.
6024    pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
6025        let mut out = Vec::new();
6026        for ability in self.loadout_ability_choices() {
6027            let meta = if ability == self.weapon_ability_id {
6028                Some("weapon".into())
6029            } else {
6030                None
6031            };
6032            out.push(LoadoutHotbarChoice {
6033                binding: ability.clone(),
6034                label: ability,
6035                meta,
6036            });
6037        }
6038        let mut consumables: Vec<(String, String, u32)> = Vec::new();
6039        for stack in &self.inventory_stacks {
6040            if Self::stack_is_item_grant(stack) {
6041                continue;
6042            }
6043            if Self::stack_is_blueprint_scroll(stack) {
6044                continue;
6045            }
6046            if self.inventory_item_category(&stack.template_id) != Some("consumable")
6047                && !Self::stack_is_serving(stack)
6048            {
6049                continue;
6050            }
6051            let qty = stack.quantity.max(1);
6052            if let Some((_, _, existing)) = consumables
6053                .iter_mut()
6054                .find(|(id, _, _)| id == &stack.template_id)
6055            {
6056                *existing = existing.saturating_add(qty);
6057            } else {
6058                let label = stack
6059                    .display_name
6060                    .clone()
6061                    .or_else(|| {
6062                        self.inventory_hints
6063                            .get(&stack.template_id)
6064                            .map(|h| h.display_name.clone())
6065                    })
6066                    .unwrap_or_else(|| stack.template_id.clone());
6067                consumables.push((stack.template_id.clone(), label, qty));
6068            }
6069        }
6070        consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
6071        for (template_id, label, qty) in consumables {
6072            out.push(LoadoutHotbarChoice {
6073                binding: flatland_protocol::hotbar_consumable_binding(&template_id),
6074                label: format!("{label} ×{qty}"),
6075                meta: Some("use".into()),
6076            });
6077        }
6078        out
6079    }
6080
6081    /// Hostile wildlife / monsters for T1 (`Tab`).
6082    pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
6083        self.combat_candidates()
6084    }
6085
6086    /// Allies first, then monsters, for T2 (`Shift+Tab`). Includes self for heals.
6087    pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
6088        let (px, py) = self.player_position();
6089        let dist = |id: EntityId| {
6090            self.entities
6091                .iter()
6092                .find(|e| e.id == id)
6093                .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
6094                .unwrap_or(f32::MAX)
6095        };
6096
6097        let mut allies = Vec::new();
6098        // Self first — heal_touch on T2.
6099        if let Some(me) = self.player.as_ref() {
6100            let alive = me
6101                .vitals
6102                .as_ref()
6103                .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
6104                .unwrap_or(true);
6105            if alive {
6106                allies.push((self.entity_id, "Yourself".into()));
6107            }
6108        }
6109        for entity in &self.entities {
6110            if entity.id == self.entity_id {
6111                continue;
6112            }
6113            if entity.vitals.is_some() {
6114                let alive = entity
6115                    .vitals
6116                    .as_ref()
6117                    .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
6118                    .unwrap_or(true);
6119                if alive {
6120                    allies.push((entity.id, entity.label.clone()));
6121                }
6122            }
6123        }
6124        allies.sort_by(|(a, _), (b, _)| {
6125            if *a == self.entity_id {
6126                return std::cmp::Ordering::Less;
6127            }
6128            if *b == self.entity_id {
6129                return std::cmp::Ordering::Greater;
6130            }
6131            dist(*a)
6132                .partial_cmp(&dist(*b))
6133                .unwrap_or(std::cmp::Ordering::Equal)
6134        });
6135
6136        let mut monsters = self.combat_candidates();
6137        monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
6138        allies.into_iter().chain(monsters).collect()
6139    }
6140
6141    fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
6142        match slot_index {
6143            2 => self.t2_candidates(),
6144            _ => self.t1_candidates(),
6145        }
6146    }
6147
6148    /// Nearest combat candidate within `radius_m` of world click (gfx click-to-target).
6149    pub fn pick_combat_target_at(
6150        &self,
6151        wx: f32,
6152        wy: f32,
6153        slot_index: u8,
6154        radius_m: f32,
6155    ) -> Option<(EntityId, String)> {
6156        let mut best: Option<(f32, EntityId, String)> = None;
6157        for (id, label) in self.candidates_for_slot(slot_index) {
6158            let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
6159                // NPCs may only appear in NpcView — fall back to npc list coords.
6160                if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
6161                    let d = distance(wx, wy, npc.x, npc.y);
6162                    if d <= radius_m {
6163                        best = match best {
6164                            Some((bd, _, _)) if bd <= d => best,
6165                            _ => Some((d, id, label)),
6166                        };
6167                    }
6168                }
6169                continue;
6170            };
6171            let d = distance(
6172                wx,
6173                wy,
6174                entity.transform.position.x,
6175                entity.transform.position.y,
6176            );
6177            if d <= radius_m {
6178                best = match best {
6179                    Some((bd, _, _)) if bd <= d => best,
6180                    _ => Some((d, id, label)),
6181                };
6182            }
6183        }
6184        best.map(|(_, id, label)| (id, label))
6185    }
6186
6187    /// Full client reset after Welcome (initial connect or reconnect).
6188    pub(crate) fn restore_from_welcome(
6189        &mut self,
6190        session_id: SessionId,
6191        entity_id: EntityId,
6192        snapshot: &flatland_protocol::Snapshot,
6193    ) {
6194        self.clear_harvest_state();
6195        self.disconnect_reason = None;
6196        self.show_stats = false;
6197        self.show_craft_menu = false;
6198        self.show_shop_menu = false;
6199        self.shop_catalog = None;
6200        self.show_inventory_menu = false;
6201        self.session_id = session_id;
6202        self.entity_id = entity_id;
6203        self.connected = true;
6204        self.apply_snapshot_fields(snapshot, entity_id);
6205        if let Some(combat) = &snapshot.combat {
6206            self.apply_combat_hud(combat);
6207            let stacks = self.inventory_stacks.clone();
6208            self.sync_inventory_from_stacks(&stacks);
6209        }
6210    }
6211
6212    fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
6213        self.tick = delta.tick;
6214        self.world_clock = delta.world_clock;
6215
6216        // Degenerate AOI tick (observer missing server-side): keep welcome snapshot layers.
6217        if delta.entities.is_empty() {
6218            self.ground_drops = delta.ground_drops.clone();
6219            self.combat_fx = delta.combat_fx.clone();
6220            self.ground_hazards = delta.ground_hazards.clone();
6221            self.property_plots = delta.property_plots.clone();
6222            self.apply_terrain_overlays(&delta.terrain_overlays);
6223            if let Some(combat) = &delta.combat {
6224                self.apply_combat_hud(combat);
6225                let stacks = self.inventory_stacks.clone();
6226                self.sync_inventory_from_stacks(&stacks);
6227            }
6228            self.clamp_craft_menu_index();
6229            // Peer vanished from AOI — drop directed whisper.
6230            self.refresh_whisper_range();
6231            self.sync_gameplay_audio();
6232            return;
6233        }
6234        if !delta.buildings.is_empty() {
6235            self.buildings = delta.buildings.clone();
6236        }
6237        if !delta.blueprints.is_empty() {
6238            self.blueprints = delta.blueprints.clone();
6239        }
6240        if !delta.building_materials.is_empty() {
6241            self.building_materials = delta.building_materials.clone();
6242        }
6243        self.sync_inventory_from_stacks(&delta.inventory);
6244
6245        if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
6246            self.player = Some(updated.clone());
6247        }
6248        self.entities = delta.entities.clone();
6249        if self.player.is_none() {
6250            self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
6251        }
6252
6253        self.sync_interior_map_context();
6254
6255        // Static world layers: ticks often omit these (indoors, or unchanged).
6256        // Never wipe the welcome snapshot with an empty vec — except when indoors,
6257        // where empty means "no nodes in this building" (clear outdoor leftovers).
6258        if !delta.resource_nodes.is_empty() {
6259            self.resource_nodes = delta.resource_nodes.clone();
6260            self.upsert_harvest_route_nodes(&delta.resource_nodes);
6261        } else if delta.interior_map.is_some() || self.effective_inside_building().is_some() {
6262            self.resource_nodes = delta.resource_nodes.clone();
6263        }
6264        self.ground_drops = delta.ground_drops.clone();
6265        // Always replace — empty indoors means no chests in this building (do not keep outdoor ghosts).
6266        self.placed_containers = delta.placed_containers.clone();
6267        if !delta.doors.is_empty() {
6268            self.doors = delta.doors.clone();
6269        }
6270        if self.effective_inside_building().is_some() {
6271            if let Some(map) = &delta.interior_map {
6272                self.interior_map = Some(map.clone());
6273            }
6274        } else {
6275            self.interior_map = None;
6276        }
6277        self.sync_interior_z_bands();
6278        // Always replace NPC AOI — empty means none in range (do not keep ghosts).
6279        self.npcs = delta.npcs.clone();
6280        if !delta.quest_log.is_empty() {
6281            self.quest_log = delta.quest_log.clone();
6282        }
6283        self.apply_hired_workers(delta.hired_workers.clone());
6284        if !delta.interactables.is_empty() {
6285            self.interactables = delta.interactables.clone();
6286        }
6287        if delta.ledger.is_some() {
6288            self.ledger = delta.ledger.clone();
6289        }
6290        if delta.career.is_some() {
6291            self.career = delta.career.clone();
6292        }
6293        self.combat_fx = delta.combat_fx.clone();
6294        self.ground_hazards = delta.ground_hazards.clone();
6295        // Empty = unchanged (server may stagger plot rebuilds); non-empty replaces.
6296        if !delta.property_plots.is_empty() {
6297            self.property_plots = delta.property_plots.clone();
6298        }
6299        self.apply_terrain_overlays(&delta.terrain_overlays);
6300        if let Some(combat) = &delta.combat {
6301            self.apply_combat_hud(combat);
6302            let stacks = self.inventory_stacks.clone();
6303            self.sync_inventory_from_stacks(&stacks);
6304        } else {
6305            self.refresh_inventory_ui();
6306        }
6307        self.clamp_craft_menu_index();
6308        self.refresh_whisper_range();
6309        self.sync_gameplay_audio();
6310    }
6311
6312    /// Merge runtime cultivate overlays into `terrain_zones` (ids prefixed `rt:`).
6313    /// Always replaces prior `rt:` entries with the server's near-set (empty = none nearby).
6314    fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
6315        self.terrain_zones.retain(|z| !z.id.starts_with("rt:"));
6316        self.terrain_zones.extend(overlays.iter().cloned());
6317    }
6318
6319    /// End directed whisper when the peer is farther than interact range (or missing).
6320    /// Each client runs this locally so both players drop whisper mode when either walks away.
6321    fn refresh_whisper_range(&mut self) {
6322        let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
6323            return;
6324        };
6325        let (px, py) = self.player_position();
6326        let in_range = self.entities.iter().any(|e| {
6327            e.id == peer
6328                && distance(px, py, e.transform.position.x, e.transform.position.y)
6329                    <= INTERACTION_RADIUS_M
6330        });
6331        if !in_range {
6332            self.social_chat.cancel_whisper_out_of_range();
6333        }
6334    }
6335
6336    /// Wildlife and other combat targets visible in AOI (`NpcView.entity_id`).
6337    pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
6338        let (px, py) = self.player_position();
6339        let mut out = Vec::new();
6340        for npc in &self.npcs {
6341            let Some(eid) = npc.entity_id else {
6342                continue;
6343            };
6344            let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
6345            let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
6346            if alive && has_hp {
6347                out.push((eid, npc.label.clone()));
6348            }
6349        }
6350        out.sort_by(|(a_id, a_label), (b_id, b_label)| {
6351            let dist = |id: EntityId| {
6352                self.entities
6353                    .iter()
6354                    .find(|e| e.id == id)
6355                    .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
6356                    .unwrap_or(f32::MAX)
6357            };
6358            dist(*a_id)
6359                .partial_cmp(&dist(*b_id))
6360                .unwrap_or(std::cmp::Ordering::Equal)
6361                .then_with(|| a_label.cmp(b_label))
6362                .then_with(|| a_id.cmp(b_id))
6363        });
6364        out
6365    }
6366
6367    pub fn refresh_combat_target_label(&mut self) {
6368        let Some(id) = self.combat_target else {
6369            return;
6370        };
6371        if let Some((_, label)) = self
6372            .combat_candidates()
6373            .into_iter()
6374            .find(|(eid, _)| *eid == id)
6375        {
6376            self.combat_target_label = Some(label);
6377        } else if let Some(label) = self
6378            .entities
6379            .iter()
6380            .find(|e| e.id == id)
6381            .map(|e| e.label.clone())
6382        {
6383            self.combat_target_label = Some(label);
6384        }
6385    }
6386
6387    pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
6388        self.quest_log
6389            .iter()
6390            .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
6391            .collect()
6392    }
6393
6394    /// True when the observer has at least one free lodging slot (max workers = beds).
6395    pub fn has_worker_lodging(&self) -> bool {
6396        self.free_worker_lodging_slots() > 0
6397    }
6398
6399    /// Owned lodging capacity minus currently hired workers.
6400    pub fn free_worker_lodging_slots(&self) -> i64 {
6401        let slots: u32 = self
6402            .placed_containers
6403            .iter()
6404            .filter(|c| match (self.character_id, c.owner_character_id) {
6405                (Some(me), Some(owner)) => me == owner,
6406                (Some(_), None) => false,
6407                (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
6408            })
6409            .map(|c| c.worker_lodging_capacity.unwrap_or(0))
6410            .sum();
6411        let used = self.hired_workers.len() as u32;
6412        slots as i64 - used as i64
6413    }
6414
6415    /// Display names of hired workers assigned to this lodging container.
6416    pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
6417        let mut names: Vec<String> = self
6418            .hired_workers
6419            .iter()
6420            .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
6421            .map(|w| w.label.clone())
6422            .collect();
6423        names.sort();
6424        names
6425    }
6426
6427    /// Compact lodging occupancy for labels: `"Elda, Ana"`, `"vacant"`, or `""` if not lodging.
6428    pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
6429        let is_lodging = self
6430            .placed_containers
6431            .iter()
6432            .find(|c| c.id == container_id)
6433            .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
6434        if !is_lodging {
6435            return None;
6436        }
6437        let names = self.lodging_occupant_labels(container_id);
6438        Some(if names.is_empty() {
6439            "vacant".into()
6440        } else {
6441            names.join(", ")
6442        })
6443    }
6444
6445    /// True when a lodging bed has at least one hired worker assigned.
6446    pub fn lodging_is_occupied(&self, container_id: &str) -> bool {
6447        matches!(
6448            self.lodging_occupancy_label(container_id),
6449            Some(label) if label != "vacant"
6450        )
6451    }
6452
6453    pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
6454        self.quest_log
6455            .iter()
6456            .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
6457            .or_else(|| {
6458                self.quest_log
6459                    .iter()
6460                    .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
6461            })
6462    }
6463
6464    /// True when a lockable exterior door is within interact range (prefer over loadout `l`).
6465    pub fn nearby_lockable_door(&self) -> bool {
6466        let (px, py) = self.player_position();
6467        self.doors
6468            .iter()
6469            .any(|d| d.lock_id.is_some() && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M)
6470    }
6471
6472    /// True when an open player-house door is in range for Enter-to-go-inside.
6473    pub fn nearby_open_player_door(&self) -> bool {
6474        if self.effective_inside_building().is_some() {
6475            return false;
6476        }
6477        let (px, py) = self.player_position();
6478        self.doors.iter().any(|d| {
6479            if !d.open || d.locked {
6480                return false;
6481            }
6482            let player_house = self
6483                .buildings
6484                .iter()
6485                .find(|b| b.id == d.building_id)
6486                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6487            player_house && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M
6488        })
6489    }
6490
6491    /// True when inside a player house near the exterior portal (Enter exits freely).
6492    pub fn nearby_player_exit_door(&self) -> bool {
6493        let Some(bid) = self.effective_inside_building() else {
6494            return false;
6495        };
6496        let (px, py) = self.player_position();
6497        self.doors.iter().any(|d| {
6498            if d.building_id != bid || d.portal.is_none() {
6499                return false;
6500            }
6501            let player_house = self
6502                .buildings
6503                .iter()
6504                .find(|b| b.id == d.building_id)
6505                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6506            player_house && (d.x - px).hypot(d.y - py) <= 1.5
6507        })
6508    }
6509
6510    /// Nearest interactable target for `f` (doors, NPCs, quest boards).
6511    pub fn nearest_interact_target(&self) -> Option<String> {
6512        let (px, py) = self.player_position();
6513        let inside = self.effective_inside_building();
6514
6515        #[derive(Clone, Copy, PartialEq, Eq)]
6516        enum Kind {
6517            Player,
6518            Npc,
6519            HiredWorker,
6520            QuestBoard,
6521            ExitDoor,
6522            EnterDoor,
6523        }
6524
6525        fn kind_class(kind: Kind) -> u8 {
6526            match kind {
6527                Kind::EnterDoor => 0,
6528                Kind::QuestBoard => 1,
6529                Kind::Player | Kind::Npc => 2,
6530                Kind::ExitDoor => 3,
6531                Kind::HiredWorker => 4,
6532            }
6533        }
6534
6535        fn kind_priority(kind: Kind) -> u8 {
6536            match kind {
6537                Kind::EnterDoor => 0,
6538                Kind::QuestBoard => 1,
6539                Kind::Player | Kind::Npc => 2,
6540                Kind::ExitDoor => 3,
6541                Kind::HiredWorker => 4,
6542            }
6543        }
6544
6545        let mut best: Option<(f32, Kind, String)> = None;
6546
6547        let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
6548            if dist > max {
6549                return;
6550            }
6551            let replace = match best {
6552                None => true,
6553                Some((_, bk, _)) if kind_class(kind) < kind_class(bk) => true,
6554                Some((bd, bk, _)) if kind_class(kind) == kind_class(bk) && dist < bd - 0.05 => true,
6555                Some((bd, bk, _))
6556                    if kind_class(kind) == kind_class(bk) && (dist - bd).abs() <= 0.05 =>
6557                {
6558                    kind_priority(kind) < kind_priority(bk)
6559                }
6560                _ => false,
6561            };
6562            if replace {
6563                best = Some((dist, kind, id));
6564            }
6565        };
6566
6567        for npc in &self.npcs {
6568            consider(
6569                distance(px, py, npc.x, npc.y),
6570                INTERACTION_RADIUS_M,
6571                Kind::Npc,
6572                npc.id.clone(),
6573            );
6574        }
6575
6576        for worker in &self.hired_workers {
6577            if crate::use_world::hired_worker_yields_to_door(self, px, py, worker.x, worker.y) {
6578                continue;
6579            }
6580            consider(
6581                distance(px, py, worker.x, worker.y),
6582                INTERACTION_RADIUS_M,
6583                Kind::HiredWorker,
6584                worker.instance_id.clone(),
6585            );
6586        }
6587
6588        for entity in &self.entities {
6589            if entity.id == self.entity_id
6590                || entity.vitals.is_none()
6591                || entity.label.trim().is_empty()
6592            {
6593                continue;
6594            }
6595            // Hired workers are NPCs with their own UI — not Whisper/Trade peers.
6596            if self.hired_workers.iter().any(|w| w.entity_id == entity.id) {
6597                continue;
6598            }
6599            consider(
6600                distance(
6601                    px,
6602                    py,
6603                    entity.transform.position.x,
6604                    entity.transform.position.y,
6605                ),
6606                INTERACTION_RADIUS_M,
6607                Kind::Player,
6608                entity.id.to_string(),
6609            );
6610        }
6611
6612        for door in &self.doors {
6613            if let Some(ref bid) = inside {
6614                if door.building_id != *bid {
6615                    continue;
6616                }
6617                let is_exit = door.portal.is_some();
6618                let base = if is_exit {
6619                    INTERACTION_RADIUS_M
6620                } else {
6621                    DOOR_INTERACTION_RADIUS_M
6622                };
6623                let kind = if is_exit {
6624                    Kind::ExitDoor
6625                } else {
6626                    Kind::EnterDoor
6627                };
6628                consider(
6629                    distance(px, py, door.x, door.y),
6630                    crate::use_world::interact_door_range_m(self, door, base),
6631                    kind,
6632                    door.id.clone(),
6633                );
6634                continue;
6635            }
6636            consider(
6637                distance(px, py, door.x, door.y),
6638                crate::use_world::interact_door_range_m(self, door, DOOR_INTERACTION_RADIUS_M),
6639                Kind::EnterDoor,
6640                door.id.clone(),
6641            );
6642        }
6643
6644        if inside.is_none() {
6645            for inter in &self.interactables {
6646                if inter.kind == "quest_board" {
6647                    consider(
6648                        distance(px, py, inter.x, inter.y),
6649                        QUEST_BOARD_INTERACTION_RADIUS_M,
6650                        Kind::QuestBoard,
6651                        inter.id.clone(),
6652                    );
6653                }
6654            }
6655        }
6656
6657        best.map(|(_, _, id)| id)
6658    }
6659
6660    /// Nearest quest board and distance (any distance), for out-of-range feedback.
6661    pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
6662        if self.effective_inside_building().is_some() {
6663            return None;
6664        }
6665        let (px, py) = self.player_position();
6666        self.interactables
6667            .iter()
6668            .filter(|i| i.kind == "quest_board")
6669            .map(|i| {
6670                let label = if i.label.is_empty() {
6671                    "Quest board".to_string()
6672                } else {
6673                    i.label.clone()
6674                };
6675                (label, distance(px, py, i.x, i.y))
6676            })
6677            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
6678    }
6679
6680    /// Human-readable template name from inventory hints, then the synced item catalog.
6681    pub fn template_display_name(&self, template_id: &str) -> String {
6682        if let Some(name) = self
6683            .inventory_hints
6684            .get(template_id)
6685            .map(|h| h.display_name.clone())
6686            .filter(|n| !n.is_empty())
6687        {
6688            return name;
6689        }
6690        if let Some(entry) = self.item_catalog.get(template_id) {
6691            if !entry.display_name.trim().is_empty() {
6692                return entry.display_name.clone();
6693            }
6694        }
6695        humanize_template_id(template_id)
6696    }
6697
6698    /// Player-facing worker-route stop line. Item names from the catalog, never raw template ids.
6699    pub fn worker_route_stop_summary(
6700        &self,
6701        stop: &crate::worker_route_editor::WorkerRouteStop,
6702    ) -> String {
6703        stop.summary_resolved(
6704            |id| {
6705                self.placed_containers
6706                    .iter()
6707                    .find(|c| c.id == id)
6708                    .map(|c| c.display_name.clone())
6709                    .filter(|n| !n.trim().is_empty())
6710                    .unwrap_or_else(|| "storage".to_string())
6711            },
6712            |id| {
6713                self.npcs
6714                    .iter()
6715                    .find(|n| n.id == id)
6716                    .map(|n| n.label.clone())
6717                    .filter(|s| !s.trim().is_empty())
6718                    .unwrap_or_else(|| "merchant".to_string())
6719            },
6720            |id| {
6721                self.harvest_route_nodes
6722                    .iter()
6723                    .chain(self.resource_nodes.iter())
6724                    .find(|n| n.id == id)
6725                    .map(resource_node_route_label)
6726                    .unwrap_or_else(|| resource_node_route_label_parts(id, "", ""))
6727            },
6728            |id| plot_stop_label(&self.property_plots, *id),
6729            |t| self.template_display_name(t),
6730        )
6731    }
6732
6733    pub fn catalog_entry(&self, template_id: &str) -> Option<&ItemCatalogEntryView> {
6734        self.item_catalog.get(template_id)
6735    }
6736
6737    /// Prefer blueprint-view display name, then inventory hints / humanized id.
6738    pub fn blueprint_item_label(&self, template_id: &str, display_name: &str) -> String {
6739        if !display_name.is_empty() {
6740            display_name.to_string()
6741        } else {
6742            self.template_display_name(template_id)
6743        }
6744    }
6745
6746    pub fn blueprint_output_label(&self, blueprint: &BlueprintView) -> String {
6747        self.blueprint_item_label(&blueprint.output, &blueprint.output_display_name)
6748    }
6749
6750    pub fn blueprint_ingredient_label(
6751        &self,
6752        input: &flatland_protocol::BlueprintIngredientView,
6753    ) -> String {
6754        self.blueprint_item_label(&input.template_id, &input.display_name)
6755    }
6756
6757    pub fn blueprint_tool_label(&self, tool: &flatland_protocol::ToolRequirementView) -> String {
6758        self.blueprint_item_label(&tool.item, &tool.display_name)
6759    }
6760
6761    /// Harvest picker rows for the worker route editor — distance/sort from rest bed, not player.
6762    pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
6763        use crate::worker_route_editor::{
6764            node_candidates, node_candidates_stable, route_editor_lodging_anchor,
6765        };
6766        let nodes = if self.harvest_route_nodes.is_empty() {
6767            &self.resource_nodes
6768        } else {
6769            &self.harvest_route_nodes
6770        };
6771        let lodging = self
6772            .worker_route_editor
6773            .as_ref()
6774            .and_then(|ed| ed.lodging_container_id.as_deref());
6775        match route_editor_lodging_anchor(lodging, &self.placed_containers) {
6776            Some((ax, ay)) => node_candidates(nodes, ax, ay),
6777            None => node_candidates_stable(nodes),
6778        }
6779    }
6780
6781    pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
6782        if dist_m.is_nan() {
6783            return "—".into();
6784        }
6785        let from_bed = self
6786            .worker_route_editor
6787            .as_ref()
6788            .and_then(|ed| ed.lodging_container_id.as_deref())
6789            .and_then(|id| {
6790                self.placed_containers
6791                    .iter()
6792                    .find(|c| c.id == id)
6793                    .map(|c| c.display_name.clone())
6794            });
6795        match from_bed {
6796            Some(bed) => format!("{dist_m:.0}m from {bed}"),
6797            None => format!("{dist_m:.0}m"),
6798        }
6799    }
6800
6801    /// Chest label for the location panel — generic type unless this player owns it.
6802    pub fn placed_container_public_label(
6803        &self,
6804        c: &flatland_protocol::PlacedContainerView,
6805    ) -> String {
6806        let is_owner = match (self.character_id, c.owner_character_id) {
6807            (Some(me), Some(owner)) => me == owner,
6808            _ => false,
6809        };
6810        if is_owner {
6811            c.display_name.clone()
6812        } else {
6813            self.template_display_name(&c.template_id)
6814        }
6815    }
6816
6817    /// Keys on person and stowed on the keychain for the keychain overlay.
6818    pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
6819        let mut out = Vec::new();
6820        for stack in &self.inventory_stacks {
6821            if stack.template_id == KEY_TEMPLATE {
6822                out.push(KeychainEntry {
6823                    stack: stack.clone(),
6824                    stowed: false,
6825                });
6826            }
6827        }
6828        for stack in &self.keychain_stacks {
6829            if stack.template_id == KEY_TEMPLATE {
6830                out.push(KeychainEntry {
6831                    stack: stack.clone(),
6832                    stowed: true,
6833                });
6834            }
6835        }
6836        out
6837    }
6838
6839    /// Display name of the chest a `container_key` opens, when known on the client.
6840    pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
6841        if stack.template_id != KEY_TEMPLATE {
6842            return None;
6843        }
6844        if let Some(name) = stack
6845            .props
6846            .get(PROP_OPENS_CONTAINER_NAME)
6847            .filter(|n| !n.is_empty())
6848        {
6849            return Some(name.clone());
6850        }
6851        let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
6852        self.container_name_for_lock_id(opens)
6853    }
6854
6855    /// Keys always show the catalog name — never a chest rename or stray `custom_name`.
6856    pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
6857        if stack.template_id == KEY_TEMPLATE {
6858            self.template_display_name(KEY_TEMPLATE)
6859        } else {
6860            stack
6861                .display_name
6862                .clone()
6863                .unwrap_or_else(|| stack.template_id.clone())
6864        }
6865    }
6866
6867    /// Hint suffix for a key row (`[key for …]` or `[key — unpaired]`).
6868    pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
6869        if stack.template_id != KEY_TEMPLATE {
6870            return String::new();
6871        }
6872        match self.key_pair_chest_label(stack) {
6873            Some(chest) if self.key_drop_blocked(stack) => {
6874                format!(" [key for {chest} — can't drop while locked]")
6875            }
6876            Some(chest) => format!(" [key for {chest}]"),
6877            None => " [key — unpaired]".into(),
6878        }
6879    }
6880
6881    /// Resolve a lock id to a container label (placed chest or one still on your person).
6882    pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
6883        for c in &self.placed_containers {
6884            if c.lock_id.as_deref() == Some(lock) {
6885                return Some(c.display_name.clone());
6886            }
6887        }
6888        Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
6889            self.worn
6890                .values()
6891                .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
6892        })
6893    }
6894
6895    /// Keys cannot be dropped while their paired chest is locked.
6896    pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
6897        if stack.template_id != KEY_TEMPLATE {
6898            return false;
6899        }
6900        let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
6901            return false;
6902        };
6903        for c in &self.placed_containers {
6904            if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
6905                return true;
6906            }
6907        }
6908        if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
6909            return true;
6910        }
6911        self.worn
6912            .values()
6913            .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
6914    }
6915
6916    /// Property deeds cannot be dropped or destroyed (store or trade only).
6917    pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
6918        stack.template_id == PROPERTY_DEED_TEMPLATE
6919    }
6920
6921    pub fn is_property_deed_template(template_id: &str) -> bool {
6922        template_id == PROPERTY_DEED_TEMPLATE
6923    }
6924
6925    pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
6926        stack
6927            .props
6928            .get("plot_id")
6929            .and_then(|s| uuid::Uuid::parse_str(s).ok())
6930    }
6931
6932    /// World position (cell center) of the plot cell the player is standing on, if tillable.
6933    pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
6934        let (px, py) = self.player_position();
6935        let (cx, cy) = self.farm_plot_cell_under_player()?;
6936        let tx = cx as f32 + 0.5;
6937        let ty = cy as f32 + 0.5;
6938        if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
6939            return None;
6940        }
6941        let kind = self.terrain_at(tx, ty).or_else(|| self.terrain_at(px, py));
6942        if kind == Some(TerrainKindView::Tilled) {
6943            return None;
6944        }
6945        if matches!(
6946            kind,
6947            Some(TerrainKindView::ShallowWater)
6948                | Some(TerrainKindView::DeepWater)
6949                | Some(TerrainKindView::Rock)
6950        ) {
6951            return None;
6952        }
6953        Some((tx, ty))
6954    }
6955
6956    fn container_name_in_stacks(
6957        stacks: &[flatland_protocol::ItemStack],
6958        lock: &str,
6959    ) -> Option<String> {
6960        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
6961            for s in stacks {
6962                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
6963                    return Some(GameState::stack_container_label(s));
6964                }
6965                if let Some(name) = walk(&s.contents, lock) {
6966                    return Some(name);
6967                }
6968            }
6969            None
6970        }
6971        walk(stacks, lock)
6972    }
6973
6974    fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
6975        stack
6976            .props
6977            .get(PROP_CUSTOM_NAME)
6978            .cloned()
6979            .or_else(|| stack.display_name.clone())
6980            .unwrap_or_else(|| stack.template_id.clone())
6981    }
6982
6983    fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6984        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6985            for s in stacks {
6986                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
6987                    return true;
6988                }
6989                if walk(&s.contents, lock) {
6990                    return true;
6991                }
6992            }
6993            false
6994        }
6995        walk(stacks, lock)
6996    }
6997
6998    fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
6999        if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
7000            return Some(stack.clone());
7001        }
7002        for worn in self.worn.values() {
7003            if worn.item_instance_id == Some(instance_id) {
7004                return Some(worn.clone());
7005            }
7006            if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
7007                return Some(stack.clone());
7008            }
7009        }
7010        None
7011    }
7012
7013    /// Highest-`z_order` property zone covering `(x, y)`.
7014    pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
7015        self.property_zones
7016            .iter()
7017            .enumerate()
7018            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
7019            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
7020            .map(|(_, z)| z)
7021    }
7022
7023    /// Highest-`z_order` tax zone covering `(x, y)`.
7024    pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
7025        self.tax_zones
7026            .iter()
7027            .enumerate()
7028            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
7029            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
7030            .map(|(_, z)| z)
7031    }
7032
7033    /// Max tax `rate_bps` across 1 m cell centers of the claim rect (matches server).
7034    pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
7035        let mut max_bps = 0u32;
7036        let mut y = y0 + 0.5;
7037        while y < y1 {
7038            let mut x = x0 + 0.5;
7039            while x < x1 {
7040                if let Some(tz) = self.tax_zone_at(x, y) {
7041                    max_bps = max_bps.max(tz.rate_bps);
7042                }
7043                x += 1.0;
7044            }
7045            y += 1.0;
7046        }
7047        max_bps
7048    }
7049
7050    /// Claim footprint as half-open world AABB `(x0, y0, x1, y1)`.
7051    pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
7052        let mode = self.claim_mode.as_ref()?;
7053        let w = mode.width_m.max(1) as f32;
7054        let h = mode.height_m.max(1) as f32;
7055        Some((
7056            mode.anchor_x,
7057            mode.anchor_y,
7058            mode.anchor_x + w,
7059            mode.anchor_y + h,
7060        ))
7061    }
7062
7063    /// Relocate ghost as half-open 1×1 world AABB `(x0, y0, x1, y1)`.
7064    pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
7065        let mode = self.relocate_mode.as_ref()?;
7066        let x0 = mode.cursor_x.floor();
7067        let y0 = mode.cursor_y.floor();
7068        Some((x0, y0, x0 + 1.0, y0 + 1.0))
7069    }
7070
7071    /// Client quote matching server `quote_plot`:
7072    /// `(purchase, upkeep, area, premium, can_afford, valid, reason)`.
7073    pub fn claim_quote(&self) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
7074        let mode = self.claim_mode.as_ref()?;
7075        let zone = self.property_zones.iter().find(|z| z.id == mode.zone_id)?;
7076        let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
7077        let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
7078        let zone_area = zone_view_area_m2(zone).max(1.0);
7079        let area_frac = (area / zone_area).clamp(0.0, 1.0);
7080        let weight = self
7081            .property_plot_settings
7082            .as_ref()
7083            .map(|s| s.tax_premium_weight)
7084            .unwrap_or(0.5)
7085            .max(0.0);
7086        let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
7087        let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
7088        let purchase = ((zone.crown_price_copper as f64) * (area_frac as f64) * (premium as f64))
7089            .ceil()
7090            .max(0.0) as u64;
7091        let upkeep = if zone.upkeep_copper_per_day == 0 {
7092            0
7093        } else {
7094            ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
7095                .ceil()
7096                .max(1.0) as u64
7097        };
7098        let copper = crate::currency::copper_from_counts(&self.inventory);
7099        let can_afford = copper >= purchase;
7100        let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
7101        Some((purchase, upkeep, area, premium, can_afford, valid, reason))
7102    }
7103
7104    fn validate_claim_footprint(
7105        &self,
7106        zone: &flatland_protocol::PropertyZoneView,
7107        x0: f32,
7108        y0: f32,
7109        x1: f32,
7110        y1: f32,
7111        area: f32,
7112    ) -> (bool, String) {
7113        let min_area = self
7114            .property_plot_settings
7115            .as_ref()
7116            .map(|s| s.min_plot_area_m2)
7117            .unwrap_or(4.0);
7118        if area + f32::EPSILON < min_area {
7119            return (false, "plot too small".into());
7120        }
7121        if zone.max_area_m2.is_some_and(|m| area > m) {
7122            return (false, "plot exceeds max area".into());
7123        }
7124        if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
7125            return (false, "plot must lie inside the property zone".into());
7126        }
7127        if self
7128            .property_plots
7129            .iter()
7130            .any(|p| rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1))
7131        {
7132            return (false, "plot overlaps an existing claim".into());
7133        }
7134        (true, String::new())
7135    }
7136
7137    /// Standing in a property zone on a free cell (not already claimed).
7138    pub fn free_property_zone_under_player(&self) -> Option<&flatland_protocol::PropertyZoneView> {
7139        let (px, py) = self.player_position();
7140        let zone = self.property_zone_at(px, py)?;
7141        if self.property_plots.iter().any(|p| point_in_plot(px, py, p)) {
7142            return None;
7143        }
7144        Some(zone)
7145    }
7146
7147    /// Own plot covering the player (`is_mine`).
7148    pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
7149        let (px, py) = self.player_position();
7150        self.property_plots
7151            .iter()
7152            .find(|p| p.is_mine && point_in_plot(px, py, p))
7153    }
7154
7155    /// Plot under the player that they may farm (deed, grant, or public).
7156    pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
7157        let (px, py) = self.player_position();
7158        self.property_plots
7159            .iter()
7160            .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
7161    }
7162
7163    /// Grid cell under the player on a farmable plot (`[cx,cx+1)` × `[cy,cy+1)` contains feet).
7164    pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
7165        if self.farmable_plot_under_player().is_none() {
7166            return None;
7167        }
7168        let (px, py) = self.player_position();
7169        Some((px.floor() as i32, py.floor() as i32))
7170    }
7171
7172    fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
7173        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
7174        self.resource_nodes.iter().any(|n| {
7175            let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
7176            ncx == cx && ncy == cy || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
7177        })
7178    }
7179
7180    fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
7181        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
7182        let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
7183            || self
7184                .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
7185                .is_some_and(|z| z.kind == TerrainKindView::Tilled);
7186        if !tilled {
7187            return false;
7188        }
7189        !self.resource_node_occupies_farm_cell(cx, cy)
7190    }
7191
7192    /// Empty tilled soil on the cell the player is standing on (no crop node).
7193    pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
7194        let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
7195            return false;
7196        };
7197        self.free_tilled_plant_slot_at(cx, cy)
7198    }
7199
7200    /// True when a free tilled cell (no resource node) is within interact range.
7201    pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
7202        let (px, py) = self.player_position();
7203        for dy in -2..=2 {
7204            for dx in -2..=2 {
7205                let cx = px.floor() as i32 + dx;
7206                let cy = py.floor() as i32 + dy;
7207                let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
7208                if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
7209                    continue;
7210                }
7211                if self.free_tilled_plant_slot_at(cx, cy) {
7212                    return true;
7213                }
7214            }
7215        }
7216        false
7217    }
7218
7219    fn stack_is_farm_seed(&self, stack: &flatland_protocol::ItemStack) -> bool {
7220        if stack.quantity == 0 {
7221            return false;
7222        }
7223        if stack.props.contains_key("seed_for") {
7224            return true;
7225        }
7226        if let Some(entry) = self.item_catalog.get(&stack.template_id) {
7227            if entry.is_farm_seed() {
7228                return true;
7229            }
7230        }
7231        stack.template_id.ends_with("_seed")
7232    }
7233
7234    /// Farm seed stacks in inventory (template_id, qty, display label), sorted by label.
7235    pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
7236        let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
7237        fn walk(
7238            stacks: &[flatland_protocol::ItemStack],
7239            state: &GameState,
7240            counts: &mut std::collections::HashMap<String, u32>,
7241        ) {
7242            for s in stacks {
7243                if state.stack_is_farm_seed(s) {
7244                    *counts.entry(s.template_id.clone()).or_default() += s.quantity;
7245                }
7246                walk(&s.contents, state, counts);
7247            }
7248        }
7249        walk(&self.inventory_stacks, self, &mut counts);
7250        for worn in self.worn.values() {
7251            walk(std::slice::from_ref(worn), self, &mut counts);
7252        }
7253        let mut out: Vec<_> = counts
7254            .into_iter()
7255            .map(|(template_id, quantity)| {
7256                let label = self.template_display_name(&template_id);
7257                (template_id, quantity, label)
7258            })
7259            .collect();
7260        out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
7261        out
7262    }
7263
7264    /// First farm seed template in inventory (root + nested bags).
7265    pub fn first_farm_seed_template(&self) -> Option<String> {
7266        self.farm_seed_entries()
7267            .into_iter()
7268            .next()
7269            .map(|(id, _, _)| id)
7270    }
7271
7272    pub fn clamp_plant_menu(&mut self) {
7273        let n = self.farm_seed_entries().len();
7274        if n == 0 {
7275            self.plant_menu_index = 0;
7276            self.plant_quantity = 1;
7277            return;
7278        }
7279        self.plant_menu_index = self.plant_menu_index.min(n - 1);
7280        let max_qty = self
7281            .farm_seed_entries()
7282            .get(self.plant_menu_index)
7283            .map(|(_, q, _)| *q)
7284            .unwrap_or(1)
7285            .max(1);
7286        self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
7287    }
7288
7289    pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
7290        let entries = self.farm_seed_entries();
7291        let (id, max, label) = entries.get(self.plant_menu_index)?;
7292        let qty = self.plant_quantity.min(*max).max(1);
7293        Some((id.clone(), qty, label.clone()))
7294    }
7295
7296    /// Terrain, interactables, and map objects near the player for the HUD location panel.
7297    pub fn location_context_lines(&self) -> Vec<ContextLine> {
7298        let (px, py) = self.player_position();
7299        let inside = self.effective_inside_building();
7300        let mut lines = Vec::new();
7301
7302        if let Some(kind) = self.terrain_at(px, py) {
7303            lines.push(ContextLine {
7304                on_top: true,
7305                text: format!("Terrain: {}", terrain_kind_label(kind)),
7306            });
7307        }
7308
7309        if let Some(id) = inside.as_ref() {
7310            if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
7311                lines.push(ContextLine {
7312                    on_top: true,
7313                    text: format!("Inside: {}", b.label),
7314                });
7315            }
7316        }
7317
7318        let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
7319
7320        for node in &self.resource_nodes {
7321            if node.id.starts_with("preview:") {
7322                continue;
7323            }
7324            let dist = distance(px, py, node.x, node.y);
7325            if dist > NEARBY_SCAN_M {
7326                continue;
7327            }
7328            let on_top = dist <= ON_TOP_RADIUS_M;
7329            let prefix = if on_top { "On" } else { "Near" };
7330            let name = resource_node_near_display_label(&node.label);
7331            let action = resource_node_near_action_suffix(node);
7332            nearby.push((
7333                dist,
7334                ContextLine {
7335                    on_top,
7336                    text: format!("{prefix}: {name} ({dist:.1}m){action}"),
7337                },
7338            ));
7339        }
7340
7341        for drop in &self.ground_drops {
7342            let dist = distance(px, py, drop.x, drop.y);
7343            if dist > INTERACTION_RADIUS_M {
7344                continue;
7345            }
7346            let on_top = dist <= ON_TOP_RADIUS_M;
7347            let name = self.template_display_name(&drop.template_id);
7348            let prefix = if on_top { "On" } else { "Near" };
7349            let qty = if drop.quantity > 1 {
7350                format!(" ×{}", drop.quantity)
7351            } else {
7352                String::new()
7353            };
7354            nearby.push((
7355                dist,
7356                ContextLine {
7357                    on_top,
7358                    text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
7359                },
7360            ));
7361        }
7362
7363        for c in &self.placed_containers {
7364            if !self.placed_container_in_current_space(c) {
7365                continue;
7366            }
7367            let dist = distance(px, py, c.x, c.y);
7368            if dist > CONTAINER_RANGE_M {
7369                continue;
7370            }
7371            let on_top = dist <= ON_TOP_RADIUS_M;
7372            let name = self.placed_container_public_label(c);
7373            let lock = if c.locked { " [locked]" } else { "" };
7374            let prefix = if on_top { "On" } else { "Near" };
7375            nearby.push((
7376                dist,
7377                ContextLine {
7378                    on_top,
7379                    text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
7380                },
7381            ));
7382        }
7383
7384        for npc in &self.npcs {
7385            let dist = distance(px, py, npc.x, npc.y);
7386            if dist > NEARBY_SCAN_M {
7387                continue;
7388            }
7389            let on_top = dist <= ON_TOP_RADIUS_M;
7390            let prefix = if on_top { "On" } else { "Near" };
7391            nearby.push((
7392                dist,
7393                ContextLine {
7394                    on_top,
7395                    text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
7396                },
7397            ));
7398        }
7399
7400        for door in &self.doors {
7401            let dist = distance(px, py, door.x, door.y);
7402            if dist > DOOR_INTERACTION_RADIUS_M {
7403                continue;
7404            }
7405            let building = self
7406                .buildings
7407                .iter()
7408                .find(|b| b.id == door.building_id)
7409                .map(|b| b.label.as_str())
7410                .unwrap_or(door.building_id.as_str());
7411            let player_house = self
7412                .buildings
7413                .iter()
7414                .find(|b| b.id == door.building_id)
7415                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
7416            let action = if inside.is_some() && door.portal.is_some() {
7417                if player_house {
7418                    if door.locked {
7419                        "locked — l unlock · Enter exit".to_string()
7420                    } else if door.open {
7421                        "close · Enter exit · l lock".to_string()
7422                    } else {
7423                        "open · Enter exit · l lock".to_string()
7424                    }
7425                } else {
7426                    "exit".to_string()
7427                }
7428            } else if player_house {
7429                if door.locked {
7430                    "locked — l unlock".to_string()
7431                } else if door.open {
7432                    "close · Enter go inside · l lock".to_string()
7433                } else {
7434                    "open · l lock".to_string()
7435                }
7436            } else {
7437                "enter".to_string()
7438            };
7439            nearby.push((
7440                dist,
7441                ContextLine {
7442                    on_top: dist <= ON_TOP_RADIUS_M,
7443                    text: format!("{building} door ({dist:.1}m) — f {action}"),
7444                },
7445            ));
7446        }
7447
7448        if inside.is_none() {
7449            for inter in &self.interactables {
7450                if inter.kind != "quest_board" {
7451                    continue;
7452                }
7453                let dist = distance(px, py, inter.x, inter.y);
7454                if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
7455                    continue;
7456                }
7457                let on_top = dist <= ON_TOP_RADIUS_M;
7458                let prefix = if on_top { "On" } else { "Near" };
7459                let label = if inter.label.is_empty() {
7460                    "Quest board".to_string()
7461                } else {
7462                    inter.label.clone()
7463                };
7464                nearby.push((
7465                    dist,
7466                    ContextLine {
7467                        on_top,
7468                        text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
7469                    },
7470                ));
7471            }
7472        }
7473
7474        if self.near_liquid_fill_source() {
7475            let on_water = matches!(
7476                self.terrain_at(px, py),
7477                Some(TerrainKindView::ShallowWater | TerrainKindView::DeepWater)
7478            );
7479            let well = self.buildings.iter().find(|b| {
7480                b.tags.iter().any(|t| t == "well") && {
7481                    let hw = b.width_m * 0.5;
7482                    let hd = b.depth_m * 0.5;
7483                    let nx = px.clamp(b.x - hw, b.x + hw);
7484                    let ny = py.clamp(b.y - hd, b.y + hd);
7485                    let dx = px - nx;
7486                    let dy = py - ny;
7487                    dx * dx + dy * dy <= INTERACTION_RADIUS_M * INTERACTION_RADIUS_M
7488                }
7489            });
7490            if let Some(well) = well {
7491                let name = if well.label.trim().is_empty() {
7492                    "Well"
7493                } else {
7494                    well.label.as_str()
7495                };
7496                nearby.push((
7497                    0.0,
7498                    ContextLine {
7499                        on_top: true,
7500                        text: format!("{name} — Use a vessel from inventory to fill"),
7501                    },
7502                ));
7503            } else if on_water {
7504                if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
7505                    line.text.push_str(" — Use a vessel from inventory to fill");
7506                }
7507            } else {
7508                nearby.push((
7509                    0.0,
7510                    ContextLine {
7511                        on_top: true,
7512                        text: "Water nearby — Use a vessel from inventory to fill".into(),
7513                    },
7514                ));
7515            }
7516        }
7517
7518        if self.claim_mode.is_some() {
7519            nearby.push((
7520                0.0,
7521                ContextLine {
7522                    on_top: true,
7523                    text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
7524                        .into(),
7525                },
7526            ));
7527        } else if let Some(plot) = self.my_plot_under_player() {
7528            let name = plot_public_label(plot);
7529            let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
7530                format!("{name} — f again to sell to crown")
7531            } else {
7532                format!(
7533                    "{name} — Shift+c till · p plant · f harvest · B build · l door lock · o farm access · Shift+n rename"
7534                )
7535            };
7536            nearby.push((
7537                0.0,
7538                ContextLine {
7539                    on_top: true,
7540                    text: prompt,
7541                },
7542            ));
7543        } else if let Some(plot) = self.farmable_plot_under_player() {
7544            let name = plot_public_label(plot);
7545            let disc = if plot.farm_public {
7546                plot.public_tax_discount_bps / 100
7547            } else {
7548                plot.farm_allow
7549                    .iter()
7550                    .find(|g| Some(g.character_id) == self.character_id)
7551                    .map(|g| g.tax_discount_bps / 100)
7552                    .unwrap_or(0)
7553            };
7554            nearby.push((
7555                0.0,
7556                ContextLine {
7557                    on_top: true,
7558                    text: format!(
7559                        "{name} (farming · tax −{disc}%) — Shift+c till · p plant · f harvest"
7560                    ),
7561                },
7562            ));
7563        } else if let Some(zone) = self.free_property_zone_under_player() {
7564            let label = zone
7565                .label
7566                .as_deref()
7567                .filter(|s| !s.trim().is_empty())
7568                .unwrap_or(zone.id.as_str());
7569            nearby.push((
7570                0.0,
7571                ContextLine {
7572                    on_top: true,
7573                    text: format!("Claimable land: {label} — k buy plot"),
7574                },
7575            ));
7576        }
7577
7578        for entity in &self.entities {
7579            if entity.id == self.entity_id {
7580                continue;
7581            }
7582            let dist = distance(
7583                px,
7584                py,
7585                entity.transform.position.x,
7586                entity.transform.position.y,
7587            );
7588            if dist > NEARBY_SCAN_M {
7589                continue;
7590            }
7591            let label = if entity.label.is_empty() {
7592                format!("entity {}", entity.id)
7593            } else {
7594                entity.label.clone()
7595            };
7596            nearby.push((
7597                dist,
7598                ContextLine {
7599                    on_top: dist <= ON_TOP_RADIUS_M,
7600                    text: format!("Near: {label} ({dist:.1}m)"),
7601                },
7602            ));
7603        }
7604
7605        nearby.sort_by(|a, b| {
7606            a.0.partial_cmp(&b.0)
7607                .unwrap_or(std::cmp::Ordering::Equal)
7608                .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
7609        });
7610        lines.extend(nearby.into_iter().map(|(_, l)| l));
7611
7612        if lines.is_empty() {
7613            lines.push(ContextLine {
7614                on_top: false,
7615                text: "(nothing notable nearby)".into(),
7616            });
7617        }
7618
7619        lines
7620    }
7621}
7622
7623/// HUD line for the location / nearby panel.
7624#[derive(Debug, Clone)]
7625pub struct ContextLine {
7626    pub on_top: bool,
7627    pub text: String,
7628}
7629
7630const ON_TOP_RADIUS_M: f32 = 0.65;
7631const NEARBY_SCAN_M: f32 = 5.0;
7632
7633/// Display name for a resource node in location/nearby HUD (strips redundant growing tag).
7634pub fn resource_node_near_display_label(label: &str) -> String {
7635    label
7636        .strip_suffix(" (growing)")
7637        .unwrap_or(label)
7638        .to_string()
7639}
7640
7641fn resource_label_looks_like_raw_id(label: &str, id: &str) -> bool {
7642    let t = label.trim();
7643    if t.is_empty() || t == id {
7644        return true;
7645    }
7646    let lower = t.to_ascii_lowercase();
7647    if lower.contains("_copy") {
7648        return true;
7649    }
7650    false
7651}
7652
7653fn humanize_item_template_label(template: &str) -> String {
7654    let base = template.rsplit('/').next().unwrap_or(template).trim();
7655    if base.is_empty() {
7656        return "Resource".into();
7657    }
7658    let stripped = base
7659        .strip_prefix("crop-")
7660        .or_else(|| base.strip_prefix("crop_"))
7661        .unwrap_or(base);
7662    stripped
7663        .split(|c: char| c == '-' || c == '_')
7664        .filter(|p| !p.is_empty())
7665        .map(|p| {
7666            let mut chars = p.chars();
7667            match chars.next() {
7668                Some(c) => format!("{}{}", c.to_ascii_uppercase(), chars.as_str()),
7669                None => String::new(),
7670            }
7671        })
7672        .collect::<Vec<_>>()
7673        .join(" ")
7674}
7675
7676/// Last 4 alphanumeric chars of an id (for disambiguation in route UI).
7677pub fn resource_node_id_suffix(id: &str) -> String {
7678    let chars: Vec<char> = id
7679        .chars()
7680        .rev()
7681        .filter(|c| c.is_ascii_alphanumeric())
7682        .take(4)
7683        .collect();
7684    chars.into_iter().rev().collect()
7685}
7686
7687/// Friendly harvest/route label: prefer human label, else template; always append `(xxxx)`.
7688pub fn resource_node_route_label(node: &flatland_protocol::ResourceNodeView) -> String {
7689    resource_node_route_label_parts(&node.id, &node.label, &node.item_template)
7690}
7691
7692pub fn resource_node_route_label_parts(id: &str, label: &str, item_template: &str) -> String {
7693    let cleaned = resource_node_near_display_label(label);
7694    let friendly = if !resource_label_looks_like_raw_id(&cleaned, id) {
7695        cleaned
7696    } else if !item_template.trim().is_empty() {
7697        humanize_item_template_label(item_template)
7698    } else {
7699        id.to_string()
7700    };
7701    let suffix = resource_node_id_suffix(id);
7702    if suffix.is_empty() {
7703        friendly
7704    } else {
7705        format!("{friendly} ({suffix})")
7706    }
7707}
7708
7709/// Action / state suffix for a resource node line (`growth_progress` wins for farm crops).
7710pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
7711    use flatland_protocol::ResourceNodeState;
7712    if node.harvest_off {
7713        return " (decorative)".to_string();
7714    }
7715    if let Some(p) = node.growth_progress {
7716        if p < 1.0 - f32::EPSILON {
7717            let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
7718            return format!(" (growing, {pct}%)");
7719        }
7720        return " — f harvest".to_string();
7721    }
7722    match node.state {
7723        ResourceNodeState::Available => " — f harvest".to_string(),
7724        ResourceNodeState::Harvesting => " (being harvested)".to_string(),
7725        ResourceNodeState::Cooldown => " (depleted)".to_string(),
7726    }
7727}
7728
7729fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
7730    use flatland_protocol::TerrainKindView;
7731    match kind {
7732        TerrainKindView::Grass => "Grass",
7733        TerrainKindView::Dirt => "Dirt",
7734        TerrainKindView::Tilled => "Tilled",
7735        TerrainKindView::Desert => "Desert",
7736        TerrainKindView::Hill => "Hills",
7737        TerrainKindView::Bog => "Bog",
7738        TerrainKindView::Beach => "Beach",
7739        TerrainKindView::ShallowWater => "Shallow water",
7740        TerrainKindView::DeepWater => "Deep water",
7741        TerrainKindView::Trail => "Trail",
7742        TerrainKindView::Road => "Road",
7743        TerrainKindView::Rock => "Rock",
7744    }
7745}
7746
7747fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
7748    crate::world_zones::zone_rects_contain(rects, x, y)
7749}
7750
7751fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
7752    zone.rects
7753        .iter()
7754        .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
7755        .sum()
7756}
7757
7758fn claim_rect_fully_inside_zone(
7759    zone: &flatland_protocol::PropertyZoneView,
7760    x0: f32,
7761    y0: f32,
7762    x1: f32,
7763    y1: f32,
7764) -> bool {
7765    let mut y = y0 + 0.5;
7766    while y < y1 {
7767        let mut x = x0 + 0.5;
7768        while x < x1 {
7769            if !zone_rects_contain(&zone.rects, x, y) {
7770                return false;
7771            }
7772            x += 1.0;
7773        }
7774        y += 1.0;
7775    }
7776    true
7777}
7778
7779fn rects_overlap_half_open(
7780    ax0: f32,
7781    ay0: f32,
7782    ax1: f32,
7783    ay1: f32,
7784    bx0: f32,
7785    by0: f32,
7786    bx1: f32,
7787    by1: f32,
7788) -> bool {
7789    ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
7790}
7791
7792fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
7793    x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
7794}
7795
7796fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
7797    plot_public_label(p)
7798}
7799
7800fn plot_size_fallback_label(p: &flatland_protocol::PropertyPlotView) -> String {
7801    let w = (p.x1 - p.x0).abs();
7802    let d = (p.y1 - p.y0).abs();
7803    format!("Plot ({w:.0}×{d:.0} m)")
7804}
7805
7806/// Canonical plot name: `{owner} — {zone} — {label}`.
7807pub fn plot_public_label(p: &flatland_protocol::PropertyPlotView) -> String {
7808    let zone = p
7809        .zone_label
7810        .as_deref()
7811        .filter(|s| !s.trim().is_empty())
7812        .unwrap_or_else(|| {
7813            if p.property_zone_id.is_empty() {
7814                "Homestead"
7815            } else {
7816                p.property_zone_id.as_str()
7817            }
7818        });
7819    let label = if !p.label.trim().is_empty() {
7820        p.label.clone()
7821    } else if !p.plot_code.trim().is_empty() {
7822        p.plot_code.clone()
7823    } else {
7824        plot_size_fallback_label(p)
7825    };
7826    match p
7827        .owner_label
7828        .as_deref()
7829        .map(str::trim)
7830        .filter(|s| !s.is_empty())
7831    {
7832        Some(owner) => format!("{owner} — {zone} — {label}"),
7833        None => format!("{zone} — {label}"),
7834    }
7835}
7836
7837/// Resolve a farm-plot stop target for player-facing route UI.
7838///
7839/// Uses [`plot_public_label`] when the plot is in the observer's view; otherwise a
7840/// short constructed fallback (never the full UUID).
7841pub fn plot_stop_label(
7842    plots: &[flatland_protocol::PropertyPlotView],
7843    plot_id: uuid::Uuid,
7844) -> String {
7845    plots
7846        .iter()
7847        .find(|p| p.plot_id == plot_id)
7848        .map(plot_public_label)
7849        .unwrap_or_else(|| {
7850            let s = plot_id.to_string();
7851            format!("plot {}", s.get(..8).unwrap_or(s.as_str()))
7852        })
7853}
7854
7855/// Match `flatland_sim::economy_zones::snap_claim_rect`.
7856fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
7857    let a = x0.min(x1).floor();
7858    let b = y0.min(y1).floor();
7859    let mut c = x0.max(x1).ceil();
7860    let mut d = y0.max(y1).ceil();
7861    if (c - a) < 1.0 {
7862        c = a + 1.0;
7863    }
7864    if (d - b) < 1.0 {
7865        d = b + 1.0;
7866    }
7867    (a, b, c, d)
7868}
7869
7870fn humanize_template_id(template_id: &str) -> String {
7871    // UUID-style ids have no friendly words to title-case; avoid dumping them in UI.
7872    if looks_like_template_uuid(template_id) {
7873        return "Unknown item".into();
7874    }
7875    template_id
7876        .split('_')
7877        .map(|word| {
7878            let mut chars = word.chars();
7879            match chars.next() {
7880                None => String::new(),
7881                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
7882            }
7883        })
7884        .collect::<Vec<_>>()
7885        .join(" ")
7886}
7887
7888fn looks_like_template_uuid(template_id: &str) -> bool {
7889    let bytes = template_id.as_bytes();
7890    if bytes.len() != 36 {
7891        return false;
7892    }
7893    let is_hex = |b: u8| b.is_ascii_hexdigit();
7894    let groups = [8usize, 4, 4, 4, 12];
7895    let mut i = 0;
7896    for (gi, &len) in groups.iter().enumerate() {
7897        if gi > 0 {
7898            if bytes.get(i) != Some(&b'-') {
7899                return false;
7900            }
7901            i += 1;
7902        }
7903        for _ in 0..len {
7904            if !bytes.get(i).copied().is_some_and(is_hex) {
7905                return false;
7906            }
7907            i += 1;
7908        }
7909    }
7910    true
7911}
7912
7913/// Keep in sync with `flatland_sim::INTERACTION_RADIUS_M`.
7914const HARVEST_RANGE_M: f32 = 1.5;
7915
7916pub struct GameClient<S: PlayConnection> {
7917    session: S,
7918    seq: Seq,
7919    pub state: GameState,
7920    last_move_forward: f32,
7921    last_move_strafe: f32,
7922}
7923
7924impl<S: PlayConnection> GameClient<S> {
7925    pub fn new(session: S) -> Self {
7926        let session_id = session.session_id();
7927        let entity_id = session.entity_id();
7928        let mut client = Self {
7929            session,
7930            seq: 0,
7931            last_move_forward: 0.0,
7932            last_move_strafe: 0.0,
7933            state: GameState {
7934                session_id,
7935                entity_id,
7936                character_id: None,
7937                tick: 0,
7938                chunk_rev: 0,
7939                content_rev: 0,
7940                publish_rev: 0,
7941                entities: Vec::new(),
7942                player: None,
7943                resource_nodes: Vec::new(),
7944                harvest_route_nodes: Vec::new(),
7945                ground_drops: Vec::new(),
7946                placed_containers: Vec::new(),
7947                buildings: Vec::new(),
7948                doors: Vec::new(),
7949                interior_map: None,
7950                npcs: Vec::new(),
7951                blueprints: Vec::new(),
7952                building_materials: Vec::new(),
7953                world_x0: 0.0,
7954                world_y0: 0.0,
7955                world_width_m: 0.0,
7956                world_height_m: 0.0,
7957                terrain_zones: Vec::new(),
7958                z_platforms: Vec::new(),
7959                z_transitions: Vec::new(),
7960                z_bands_outdoor_backup: None,
7961                world_clock: flatland_protocol::WorldClock::default(),
7962                inventory: std::collections::HashMap::new(),
7963                inventory_hints: std::collections::HashMap::new(),
7964                item_catalog: std::collections::HashMap::new(),
7965                logs: VecDeque::new(),
7966                intents_sent: 0,
7967                ticks_received: 0,
7968                connected: false,
7969                disconnect_reason: None,
7970                show_stats: false,
7971                hud_log_hidden: false,
7972                show_equip_menu: false,
7973                equip_menu_index: 0,
7974                show_craft_menu: false,
7975                show_plot_build_menu: false,
7976                plot_build_focus_wall: true,
7977                plot_build_wall_index: 0,
7978                plot_build_roof_index: 0,
7979                craft_menu_index: 0,
7980                craft_batch_quantity: 1,
7981                craft_tab: CraftTab::Ready,
7982                craft_filter: String::new(),
7983                craft_filter_focused: false,
7984                craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
7985                show_shop_menu: false,
7986                shop_catalog: None,
7987                bank_panel: None,
7988                bank_menu_index: 0,
7989                bank_ui_mode: BankUiMode::Menu,
7990                storage_panel: None,
7991                market_panel: None,
7992                market_menu_index: 0,
7993                market_filter: String::new(),
7994                market_filter_focused: false,
7995                market_category_filter: None,
7996                market_buy_confirm: None,
7997                market_ui_mode: MarketUiMode::Browse,
7998                storage_menu_index: 0,
7999                storage_ui_mode: StorageUiMode::Menu,
8000                shop_tab: ShopTab::default(),
8001                shop_menu_index: 0,
8002                shop_quantity: 1,
8003                shop_trade_log: VecDeque::new(),
8004                show_npc_verb_menu: false,
8005                npc_verb_target: None,
8006                npc_verb_index: 0,
8007                npc_verb_notice: None,
8008                player_verbs: crate::social::PlayerVerbState::default(),
8009                social_chat: crate::social::SocialChatState::default(),
8010                trade_ui: crate::social::TradeUiState::default(),
8011                whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
8012                show_npc_chat: false,
8013                npc_chat: None,
8014                show_inventory_menu: false,
8015                inventory_menu_index: 0,
8016                inventory_tab: InventoryTab::OnPerson,
8017                inventory_filter: String::new(),
8018                inventory_filter_focused: false,
8019                show_move_picker: false,
8020                show_rename_prompt: false,
8021                rename_plot_id: None,
8022                highlighted_plot_id: None,
8023                show_worker_rename: false,
8024                rename_buffer: String::new(),
8025                move_picker_index: 0,
8026                move_picker: None,
8027                show_grant_picker: false,
8028                grant_picker_index: 0,
8029                grant_picker: None,
8030                show_destroy_picker: false,
8031                destroy_confirm_pending: false,
8032                destroy_picker: None,
8033                show_deconstruct_picker: false,
8034                deconstruct_confirm_pending: false,
8035                deconstruct_picker: None,
8036                combat_target: None,
8037                combat_target_label: None,
8038                ground_target: None,
8039                combat_fx: Vec::new(),
8040                ground_hazards: Vec::new(),
8041                property_zones: Vec::new(),
8042                tax_zones: Vec::new(),
8043                growth_zones: Vec::new(),
8044                biome_zones: Vec::new(),
8045                terrain_kind_nav: Vec::new(),
8046                property_plots: Vec::new(),
8047                property_plot_settings: None,
8048                claim_mode: None,
8049                relocate_mode: None,
8050                sell_plot_confirm: None,
8051                sell_plot_armed_at: None,
8052                show_plant_menu: false,
8053                plant_menu_index: 0,
8054                show_farm_access: false,
8055                farm_access_name_draft: String::new(),
8056                farm_access_discount_bps: 0,
8057                farm_access_index: 0,
8058                plant_quantity: 1,
8059                in_combat: false,
8060                auto_attack: true,
8061                combat_has_los: false,
8062                attack_cd_ticks: 0,
8063                gcd_ticks: 0,
8064                weapon_ability_id: "unarmed".into(),
8065                mainhand_template_id: None,
8066                mainhand_label: None,
8067                mainhand_instance_id: None,
8068                offhand_template_id: None,
8069                offhand_label: None,
8070                offhand_instance_id: None,
8071                mainhand_hand_slots: 1,
8072                defense: None,
8073                worn: BTreeMap::new(),
8074                carry_mass: 0.0,
8075                carry_mass_max: 0.0,
8076                encumbrance: flatland_protocol::EncumbranceState::Light,
8077                move_speed_mps: 0.0,
8078                move_speed_mult: 0.0,
8079                inventory_stacks: Vec::new(),
8080                keychain_stacks: Vec::new(),
8081                whisper_pouch_stacks: Vec::new(),
8082                combat_target_detail: None,
8083                statuses: Vec::new(),
8084                cast_progress: None,
8085                timed_channel: None,
8086                plot_build_offer: None,
8087                ability_cooldowns: Vec::new(),
8088                blocking_active: false,
8089                max_target_slots: 1,
8090                combat_slots: Vec::new(),
8091                rotation_presets: Vec::new(),
8092                known_abilities: Vec::new(),
8093                ability_meta: std::collections::HashMap::new(),
8094                ability_mastery: std::collections::HashMap::new(),
8095                hotbar: vec![None; 9],
8096                max_abilities_per_rotation: 0,
8097                show_loadout_menu: false,
8098                show_keychain_menu: false,
8099                keychain_menu_index: 0,
8100                show_rotation_editor: false,
8101                loadout_menu_index: 0,
8102                loadout_hotbar_slot: 1,
8103                loadout_ability_index: 0,
8104                loadout_focus_presets: false,
8105                rotation_editor: RotationEditorState::default(),
8106                harvest_in_progress: false,
8107                harvest_started_at: None,
8108                pending_craft_ack: None,
8109                craft_channel_blueprint_id: None,
8110                craft_channel_seen: false,
8111                pending_worker_job_ack: None,
8112                attending_worker_instance_id: None,
8113                quest_log: Vec::new(),
8114                interactables: Vec::new(),
8115                ledger: None,
8116                career: None,
8117                character_sheet_tab: CharacterSheetTab::Character,
8118                ledger_period: LedgerPeriod::Day,
8119                show_quest_offer: false,
8120                pending_quest_offers: Vec::new(),
8121                quest_offer_index: 0,
8122                show_quest_menu: false,
8123                quest_menu_index: 0,
8124                quest_withdraw_confirm: false,
8125                hired_workers: Vec::new(),
8126                show_workers_menu: false,
8127                workers_menu_index: 0,
8128                worker_dismiss_confirmation: None,
8129                workers_menu_compact: false,
8130                worker_step_display: BTreeMap::new(),
8131                worker_error_display: BTreeMap::new(),
8132                worker_health_ring_until: BTreeMap::new(),
8133                pending_worker_hire_since: None,
8134                show_worker_give_picker: false,
8135                worker_give_picker_index: 0,
8136                worker_give_picker: None,
8137                show_worker_give_target_picker: false,
8138                worker_give_target_picker_index: 0,
8139                worker_give_target_picker: None,
8140                show_worker_take_picker: false,
8141                worker_take_picker_index: 0,
8142                worker_take_picker: None,
8143                show_worker_teach_picker: false,
8144                worker_teach_picker_index: 0,
8145                worker_teach_picker: None,
8146                worker_route_editor: None,
8147                progression_curve: None,
8148            },
8149        };
8150        client.state.apply_client_ui_prefs();
8151        client
8152    }
8153
8154    pub fn entity_id(&self) -> EntityId {
8155        self.state.entity_id
8156    }
8157
8158    pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
8159        if self.state.connected {
8160            return Ok(());
8161        }
8162
8163        loop {
8164            match self.session.next_event().await {
8165                Some(SessionEvent::Welcome {
8166                    session_id,
8167                    entity_id,
8168                    snapshot,
8169                }) => {
8170                    self.state
8171                        .restore_from_welcome(session_id, entity_id, &snapshot);
8172                    self.state.apply_client_ui_prefs();
8173                    self.state.push_log(format!(
8174                        "Connected — session {session_id}, entity {entity_id}"
8175                    ));
8176                    return Ok(());
8177                }
8178                Some(SessionEvent::Disconnected { .. }) => {
8179                    anyhow::bail!("disconnected before welcome");
8180                }
8181                Some(_) => continue,
8182                None => anyhow::bail!("session closed before welcome"),
8183            }
8184        }
8185    }
8186
8187    /// Drain all pending server events (non-blocking).
8188    pub fn drain_events(&mut self) {
8189        while let Some(event) = self.session.try_next_event() {
8190            if self.handle_event_sync(event).is_err() {
8191                break;
8192            }
8193        }
8194    }
8195
8196    /// Wait for the next server event.
8197    pub async fn next_event(&mut self) -> Option<SessionEvent> {
8198        self.session.next_event().await
8199    }
8200
8201    pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
8202        self.handle_event_sync(event)
8203    }
8204
8205    fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
8206        match event {
8207            SessionEvent::Welcome {
8208                session_id,
8209                entity_id,
8210                snapshot,
8211            } => {
8212                let resumed = self.state.connected;
8213                self.state
8214                    .restore_from_welcome(session_id, entity_id, &snapshot);
8215                if resumed {
8216                    self.state.push_log(format!(
8217                        "Session restored — session {session_id}, entity {entity_id}"
8218                    ));
8219                }
8220            }
8221            SessionEvent::ContentUpdated { snapshot } => {
8222                self.state
8223                    .apply_snapshot_fields(&snapshot, self.state.entity_id);
8224                self.state.push_log(format!(
8225                    "World updated (content rev {})",
8226                    snapshot.content_rev
8227                ));
8228            }
8229            SessionEvent::QuestCatalogUpdated(update) => {
8230                self.state.push_log(format!(
8231                    "Quest board updated (revision {}, {} new, {} retired)",
8232                    update.revision,
8233                    update.accepted.len(),
8234                    update.retired.len()
8235                ));
8236            }
8237            SessionEvent::Tick(delta) => {
8238                self.state.apply_tick_fields(&delta, self.state.entity_id);
8239                self.state.ticks_received += 1;
8240            }
8241            SessionEvent::IntentAck {
8242                entity_id,
8243                seq,
8244                tick,
8245            } => {
8246                crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
8247                if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
8248                    if *craft_seq == seq {
8249                        let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
8250                        if batches > 1 {
8251                            self.state.push_log(format!("Crafting {label} ×{batches}…"));
8252                        } else {
8253                            self.state.push_log(format!("Crafting {label}…"));
8254                        }
8255                    }
8256                }
8257                if self
8258                    .state
8259                    .pending_worker_job_ack
8260                    .as_ref()
8261                    .is_some_and(|p| p.seq == seq)
8262                {
8263                    let pending = self.state.pending_worker_job_ack.take().unwrap();
8264                    if pending.idle {
8265                        self.state.push_log(format!(
8266                            "Route cleared for {} — worker idle",
8267                            pending.worker_label
8268                        ));
8269                    } else {
8270                        self.state.push_log(format!(
8271                            "Route saved for {} — {} stop(s), job loop active",
8272                            pending.worker_label, pending.stop_count
8273                        ));
8274                    }
8275                    if self
8276                        .state
8277                        .worker_route_editor
8278                        .as_ref()
8279                        .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
8280                    {
8281                        self.close_worker_route_editor();
8282                    }
8283                }
8284            }
8285            SessionEvent::Chat(msg) => {
8286                let label = match msg.channel {
8287                    flatland_protocol::ChatChannel::Nearby => "nearby",
8288                    flatland_protocol::ChatChannel::Direct => "speak",
8289                    flatland_protocol::ChatChannel::Whisper => "whisper",
8290                    flatland_protocol::ChatChannel::WhisperStone => "stone",
8291                };
8292                let clarity = match msg.clarity {
8293                    flatland_protocol::ChatClarity::Clear => "",
8294                    flatland_protocol::ChatClarity::Partial => "~",
8295                    flatland_protocol::ChatClarity::Heavy => "…",
8296                };
8297                self.state.push_log(format!(
8298                    "[{label}{clarity}] {}: {}",
8299                    msg.from_name, msg.text
8300                ));
8301                let now_ms = std::time::SystemTime::now()
8302                    .duration_since(std::time::UNIX_EPOCH)
8303                    .map(|d| d.as_millis() as u64)
8304                    .unwrap_or(0);
8305                self.state
8306                    .social_chat
8307                    .note_speech(&msg, self.state.entity_id, now_ms);
8308                self.state
8309                    .social_chat
8310                    .push(crate::social::ChatLogEntry::from_message(
8311                        msg,
8312                        self.state.entity_id,
8313                    ));
8314            }
8315            SessionEvent::TradeOpened(panel) => {
8316                self.state.social_chat.pending_trade = None;
8317                let peer = panel.peer_name.clone();
8318                self.state.trade_ui.open(panel);
8319                self.state.social_chat.push_system(format!(
8320                    "Trade open with {peer} — p present · r ready · Esc cancel"
8321                ));
8322                self.state
8323                    .social_chat
8324                    .push_cue(crate::social::AudioCue::TradeOpened);
8325            }
8326            SessionEvent::TradeClosed { reason } => {
8327                self.state.push_log(reason.clone());
8328                self.state.social_chat.push_system(reason);
8329                self.state.trade_ui.close();
8330            }
8331            SessionEvent::HarvestResult(result) => {
8332                self.state.clear_harvest_state();
8333                crate::harvest_trace!(
8334                    entity_id = self.state.entity_id,
8335                    node_id = %result.node_id,
8336                    template = %result.item_template,
8337                    quantity = result.quantity,
8338                    client_tick = self.state.tick,
8339                    "client applied harvest result"
8340                );
8341                let msg = if result.quantity == 0 {
8342                    format!(
8343                        "Harvested {} x0 — nothing dropped (loot table rolled empty)",
8344                        result.item_template
8345                    )
8346                } else {
8347                    format!(
8348                        "Harvested {} x{} (on the ground — press P to pick up)",
8349                        result.item_template, result.quantity
8350                    )
8351                };
8352                self.state.push_log(msg);
8353            }
8354            SessionEvent::CraftResult(result) => {
8355                for stack in &result.consumed {
8356                    if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
8357                        *qty = qty.saturating_sub(stack.quantity);
8358                        if *qty == 0 {
8359                            self.state.inventory.remove(&stack.template_id);
8360                        }
8361                    }
8362                }
8363                for stack in &result.outputs {
8364                    *self
8365                        .state
8366                        .inventory
8367                        .entry(stack.template_id.clone())
8368                        .or_insert(0) += stack.quantity;
8369                }
8370                self.state.craft_record_completed(&result.blueprint_id);
8371                if let Some(output) = result.outputs.first() {
8372                    if result.batch_total > 1 {
8373                        self.state.push_log(format!(
8374                            "Crafted {} x{} ({}/{})",
8375                            output.template_id,
8376                            output.quantity,
8377                            result.batch_index,
8378                            result.batch_total
8379                        ));
8380                    } else {
8381                        self.state.push_log(format!(
8382                            "Crafted {} x{}",
8383                            output.template_id, output.quantity
8384                        ));
8385                    }
8386                } else {
8387                    self.state
8388                        .push_log(format!("Craft finished: {}", result.blueprint_id));
8389                }
8390            }
8391            SessionEvent::Death(notice) => {
8392                self.state.clear_harvest_state();
8393                self.state.push_log(notice.message.clone());
8394                self.state.push_log(format!(
8395                    "Respawned at ({:.1}, {:.1})",
8396                    notice.respawn_x, notice.respawn_y
8397                ));
8398            }
8399            SessionEvent::Interaction(notice) => {
8400                if notice.message.starts_with("Harvest failed:") {
8401                    self.state.clear_harvest_state();
8402                }
8403                if notice.message.starts_with("Can't do that:") {
8404                    self.state.pending_worker_hire_since = None;
8405                    self.state.pending_craft_ack = None;
8406                    self.state.clear_craft_ready_pin();
8407                    if let Some(pending) = self.state.pending_worker_job_ack.take() {
8408                        if let Some(w) = self
8409                            .state
8410                            .hired_workers
8411                            .iter_mut()
8412                            .find(|w| w.instance_id == pending.worker_instance_id)
8413                        {
8414                            w.route = pending.prev_route;
8415                            w.mode = pending.prev_mode;
8416                            w.step_label = pending.prev_step_label;
8417                            w.last_error = pending.prev_last_error;
8418                        }
8419                        let reason = notice
8420                            .message
8421                            .strip_prefix("Can't do that:")
8422                            .unwrap_or(&notice.message)
8423                            .trim();
8424                        self.state.push_log(format!(
8425                            "Route save failed for {}: {reason}",
8426                            pending.worker_label
8427                        ));
8428                    }
8429                    let reason = notice
8430                        .message
8431                        .strip_prefix("Can't do that:")
8432                        .unwrap_or(&notice.message)
8433                        .trim();
8434                    if reason.contains("already tilled") {
8435                        if let Some(plot) = self.state.my_plot_under_player() {
8436                            self.state.sell_plot_confirm = Some(plot.plot_id);
8437                            self.state.sell_plot_armed_at = Some(Instant::now());
8438                        }
8439                    }
8440                }
8441                if notice.message.starts_with("Cast failed:") {
8442                    self.state.cast_progress = None;
8443                }
8444                if notice.message.contains("slain the") {
8445                    self.state.combat_target = None;
8446                    self.state.combat_target_label = None;
8447                }
8448                // Inbound trade request → inline Y/N in the CHAT column (no popup).
8449                if notice.message.contains("wants to trade") {
8450                    if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
8451                        let from_name = notice
8452                            .message
8453                            .split(" wants to trade")
8454                            .next()
8455                            .unwrap_or("Player")
8456                            .to_string();
8457                        self.state.social_chat.pending_trade =
8458                            Some(crate::social::PendingTradeRequest {
8459                                from_entity,
8460                                from_name: from_name.clone(),
8461                            });
8462                        self.state.social_chat.push_system(format!(
8463                            "{from_name} wants to trade — [Y] accept · [N] decline"
8464                        ));
8465                        self.state
8466                            .social_chat
8467                            .push_cue(crate::social::AudioCue::TradeOffer);
8468                    }
8469                }
8470                if notice.message.starts_with("trade request declined") {
8471                    self.state.social_chat.push_system(notice.message.clone());
8472                    self.state
8473                        .social_chat
8474                        .push_cue(crate::social::AudioCue::TradeDeclined);
8475                }
8476                // Verb dock hides the floating LOG — keep refuse lines on the panel + toast.
8477                if self.state.show_npc_verb_menu && !notice.message.trim().is_empty() {
8478                    self.state.npc_verb_notice = Some(notice.message.clone());
8479                    self.state
8480                        .social_chat
8481                        .push_cue(crate::social::AudioCue::UiError);
8482                }
8483                self.state.apply_interaction_notice(&notice);
8484                self.state.push_log(notice.message.clone());
8485            }
8486            SessionEvent::ShopOpened(catalog) => {
8487                self.state.apply_shop_catalog(catalog);
8488            }
8489            SessionEvent::BankOpened(panel) => {
8490                self.state.apply_bank_panel(panel);
8491            }
8492            SessionEvent::StorageOpened(panel) => {
8493                self.state.apply_storage_panel(panel);
8494            }
8495            SessionEvent::MarketOpened(panel) => {
8496                self.state.apply_market_panel(panel);
8497            }
8498            SessionEvent::NpcTalkOpened(opened) => {
8499                self.state.show_npc_verb_menu = false;
8500                if self.state.npc_verb_target.is_none() {
8501                    self.state.npc_verb_target = Some(opened.npc_id.clone());
8502                }
8503                let label = opened.npc_label.clone();
8504                let banner = if !opened.trade_allowed {
8505                    Some("Trade is unavailable right now.".to_string())
8506                } else {
8507                    None
8508                };
8509                self.state.show_npc_chat = true;
8510                self.state.npc_chat = Some(NpcChatState {
8511                    npc_id: opened.npc_id,
8512                    npc_label: opened.npc_label,
8513                    lines: if opened.greeting.is_empty() {
8514                        vec![]
8515                    } else {
8516                        vec![format!("{label}: {}", opened.greeting)]
8517                    },
8518                    input: String::new(),
8519                    pending: opened.greeting.is_empty(),
8520                    talk_depth: opened.talk_depth,
8521                    trade_allowed: opened.trade_allowed,
8522                    banner,
8523                    suggested_topics: opened.suggested_topics,
8524                });
8525            }
8526            SessionEvent::NpcTalkPending(_) => {
8527                if let Some(chat) = self.state.npc_chat.as_mut() {
8528                    chat.pending = true;
8529                }
8530            }
8531            SessionEvent::NpcTalkReply(reply) => {
8532                if let Some(chat) = self.state.npc_chat.as_mut() {
8533                    if chat.npc_id == reply.npc_id {
8534                        chat.pending = false;
8535                        if reply.trade_disabled {
8536                            chat.trade_allowed = false;
8537                            chat.banner = Some("Trade is unavailable right now.".to_string());
8538                        }
8539                        if reply.wind_down {
8540                            chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
8541                            if chat.banner.is_none() {
8542                                chat.banner =
8543                                    Some("They're wrapping up — keep it brief.".to_string());
8544                            }
8545                        }
8546                        chat.lines
8547                            .push(format!("{}: {}", chat.npc_label, reply.line));
8548                    }
8549                }
8550            }
8551            SessionEvent::NpcTalkClosed(closed) => {
8552                if self
8553                    .state
8554                    .npc_chat
8555                    .as_ref()
8556                    .is_some_and(|c| c.npc_id == closed.npc_id)
8557                {
8558                    self.state.show_npc_chat = false;
8559                    self.state.npc_chat = None;
8560                }
8561            }
8562            SessionEvent::NpcTalkError(err) => {
8563                self.state.push_log(format!("Talk failed: {}", err.reason));
8564                if let Some(chat) = self.state.npc_chat.as_mut() {
8565                    chat.pending = false;
8566                }
8567            }
8568            SessionEvent::UseResult(result) => {
8569                // Inventory count hint only — the Interaction notice already logs
8570                // "Consumed …" (and drives the gfx toast). Logging again here doubled toasts.
8571                if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
8572                    *qty = qty.saturating_sub(1);
8573                    if *qty == 0 {
8574                        self.state.inventory.remove(&result.template_id);
8575                    }
8576                }
8577            }
8578            SessionEvent::QuestOffer(offer) => {
8579                let title = offer.title.clone();
8580                self.state.push_quest_offer(offer);
8581                self.state.push_log(format!("Quest offered: {title}"));
8582            }
8583            SessionEvent::QuestAccepted(notice) => {
8584                self.state.remove_quest_offer(&notice.quest_id);
8585                self.state.push_log(notice.message);
8586            }
8587            SessionEvent::QuestWithdrawn(notice) => {
8588                self.state.show_quest_menu = false;
8589                self.state.quest_withdraw_confirm = false;
8590                self.state.push_log(notice.message);
8591            }
8592            SessionEvent::QuestStepCompleted(notice) => {
8593                self.state.push_log(notice.message);
8594            }
8595            SessionEvent::QuestCompleted(notice) => {
8596                self.state.push_log(notice.message);
8597            }
8598            SessionEvent::Disconnected { reason } => {
8599                self.state.clear_harvest_state();
8600                self.state.connected = false;
8601                self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
8602                if let Some(r) = &self.state.disconnect_reason {
8603                    self.state.push_log(format!("Disconnected: {r}"));
8604                } else {
8605                    self.state.push_log("Disconnected from server");
8606                }
8607            }
8608        }
8609        Ok(())
8610    }
8611
8612    pub fn is_connected(&self) -> bool {
8613        self.state.connected
8614    }
8615
8616    pub fn close_overlays(&mut self) {
8617        self.state.show_stats = false;
8618        self.state.show_craft_menu = false;
8619        self.state.show_plot_build_menu = false;
8620        self.state.show_shop_menu = false;
8621        self.state.shop_catalog = None;
8622        self.state.show_npc_verb_menu = false;
8623        self.state.npc_verb_target = None;
8624        self.state.show_npc_chat = false;
8625        self.state.npc_chat = None;
8626        self.state.show_inventory_menu = false;
8627        self.state.show_loadout_menu = false;
8628        self.state.show_rotation_editor = false;
8629        self.state.rotation_editor.reset();
8630        self.state.show_rename_prompt = false;
8631        self.state.show_worker_rename = false;
8632        self.state.rename_buffer.clear();
8633        self.state.show_move_picker = false;
8634        self.state.move_picker = None;
8635        self.state.show_destroy_picker = false;
8636        self.state.destroy_confirm_pending = false;
8637        self.state.destroy_picker = None;
8638        self.state.show_deconstruct_picker = false;
8639        self.state.deconstruct_confirm_pending = false;
8640        self.state.deconstruct_picker = None;
8641        self.state.show_quest_offer = false;
8642        self.state.clear_quest_offers();
8643        self.state.show_quest_menu = false;
8644        self.state.quest_withdraw_confirm = false;
8645        self.state.show_workers_menu = false;
8646        self.close_worker_give_picker();
8647        self.close_worker_give_target_picker();
8648        self.close_worker_take_picker();
8649        self.close_worker_teach_picker();
8650        self.state.worker_route_editor = None;
8651        self.state.claim_mode = None;
8652        self.state.relocate_mode = None;
8653        self.state.sell_plot_confirm = None;
8654        self.state.sell_plot_armed_at = None;
8655        self.close_farm_access_panel();
8656        if self.state.show_plant_menu {
8657            self.close_plant_menu();
8658        }
8659    }
8660
8661    /// Esc / back — pop one UI layer instead of closing every overlay at once.
8662    pub fn back_on_esc(&mut self) -> bool {
8663        if self.state.social_chat.composer_open() {
8664            self.state.social_chat.close_composer();
8665            return true;
8666        }
8667        if self.state.player_verbs.open {
8668            self.state.player_verbs.close();
8669            return true;
8670        }
8671        if self.state.whisper_pouch_ui.open {
8672            self.state.whisper_pouch_ui.open = false;
8673            return true;
8674        }
8675        if self.state.trade_ui.panel.is_some() {
8676            // async cancel — caller should prefer trade_cancel; close UI optimistically
8677            self.state.trade_ui.close();
8678            return true;
8679        }
8680        if self.state.show_rename_prompt {
8681            self.cancel_rename_prompt();
8682            return true;
8683        }
8684        if self.state.show_worker_rename {
8685            self.cancel_worker_rename();
8686            return true;
8687        }
8688        if self.state.show_destroy_picker {
8689            if self.state.destroy_confirm_pending {
8690                self.cancel_destroy_confirm();
8691            } else {
8692                self.close_destroy_picker();
8693            }
8694            return true;
8695        }
8696        if self.state.show_deconstruct_picker {
8697            if self.state.deconstruct_confirm_pending {
8698                self.cancel_deconstruct_confirm();
8699            } else {
8700                self.close_deconstruct_picker();
8701            }
8702            return true;
8703        }
8704        if self.state.claim_mode.is_some() {
8705            self.cancel_claim_mode();
8706            return true;
8707        }
8708        if self.state.relocate_mode.is_some() {
8709            self.cancel_relocate_mode();
8710            return true;
8711        }
8712        if self.state.show_plant_menu {
8713            self.close_plant_menu();
8714            return true;
8715        }
8716        if self.state.show_farm_access {
8717            self.close_farm_access_panel();
8718            return true;
8719        }
8720        if self.state.sell_plot_confirm.is_some() {
8721            self.state.sell_plot_confirm = None;
8722            self.state.sell_plot_armed_at = None;
8723            self.state.push_log("Sell cancelled");
8724            return true;
8725        }
8726        if self.state.show_move_picker {
8727            self.close_move_picker();
8728            return true;
8729        }
8730        if self.state.show_rotation_editor {
8731            match self.state.rotation_editor.mode {
8732                RotationEditorMode::List => {
8733                    self.state.show_rotation_editor = false;
8734                    self.state.rotation_editor.reset();
8735                }
8736                RotationEditorMode::EditLabel => {
8737                    self.state.rotation_editor.label_buffer.clear();
8738                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8739                }
8740                RotationEditorMode::PickAbility => {
8741                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8742                }
8743                RotationEditorMode::EditSequence => {
8744                    self.state.rotation_editor.draft = None;
8745                    self.state.rotation_editor.mode = RotationEditorMode::List;
8746                }
8747            }
8748            return true;
8749        }
8750        if self.state.show_inventory_menu {
8751            self.close_inventory_menu();
8752            return true;
8753        }
8754        if self.state.show_craft_menu {
8755            self.close_craft_menu();
8756            return true;
8757        }
8758        if self.state.show_plot_build_menu {
8759            self.close_plot_build_menu();
8760            return true;
8761        }
8762        if self.state.show_keychain_menu {
8763            self.close_keychain_menu();
8764            return true;
8765        }
8766        if self.state.show_quest_offer {
8767            self.quest_offer_decline();
8768            return true;
8769        }
8770        if self.state.show_shop_menu {
8771            // Caller must await shop close (npc_interaction_back / play-loop).
8772            return false;
8773        }
8774        if self.state.bank_panel.is_some() {
8775            return false;
8776        }
8777        if self.state.storage_panel.is_some() {
8778            return false;
8779        }
8780        if self.state.market_panel.is_some() {
8781            return false;
8782        }
8783        if self.state.show_npc_chat {
8784            // play.rs calls npc_interaction_back().await on Esc
8785            return false;
8786        }
8787        if self.state.show_npc_verb_menu {
8788            self.state.show_npc_verb_menu = false;
8789            self.state.npc_verb_target = None;
8790            self.state.npc_verb_notice = None;
8791            return true;
8792        }
8793        if self.state.show_quest_menu {
8794            if self.state.quest_withdraw_confirm {
8795                self.state.quest_withdraw_confirm = false;
8796            } else {
8797                self.state.show_quest_menu = false;
8798            }
8799            return true;
8800        }
8801        if self.state.worker_route_editor.is_some() {
8802            // Pop one sheet level; only close the editor at the root stop list.
8803            if self.re_at_root_sheet() {
8804                let reopen = self.state.attending_worker_instance_id.clone();
8805                self.close_worker_route_editor();
8806                if let Some(id) = reopen {
8807                    if let Some(idx) = self
8808                        .state
8809                        .hired_workers
8810                        .iter()
8811                        .position(|w| w.instance_id == id)
8812                    {
8813                        self.state.workers_menu_index = idx;
8814                        self.state.show_workers_menu = true;
8815                    }
8816                }
8817            } else {
8818                self.re_sheet_back();
8819            }
8820            return true;
8821        }
8822        if self.state.show_worker_give_picker {
8823            self.close_worker_give_picker();
8824            return true;
8825        }
8826        if self.state.show_worker_give_target_picker {
8827            self.close_worker_give_target_picker();
8828            return true;
8829        }
8830        if self.state.show_worker_take_picker {
8831            self.close_worker_take_picker();
8832            return true;
8833        }
8834        if self.state.show_worker_teach_picker {
8835            self.close_worker_teach_picker();
8836            return true;
8837        }
8838        if self.state.show_workers_menu {
8839            self.close_workers_menu_ui();
8840            return true;
8841        }
8842        if self.state.show_loadout_menu {
8843            self.state.show_loadout_menu = false;
8844            return true;
8845        }
8846        if self.state.show_stats {
8847            self.state.show_stats = false;
8848            return true;
8849        }
8850        if self.state.show_equip_menu {
8851            self.state.show_equip_menu = false;
8852            return true;
8853        }
8854        false
8855    }
8856
8857    pub fn toggle_stats(&mut self) {
8858        self.state.show_stats = !self.state.show_stats;
8859        if self.state.show_stats {
8860            self.state.character_sheet_tab = CharacterSheetTab::Character;
8861            self.state.show_craft_menu = false;
8862            self.state.show_shop_menu = false;
8863            self.state.shop_catalog = None;
8864            self.state.show_inventory_menu = false;
8865            self.state.show_equip_menu = false;
8866        }
8867    }
8868
8869    pub fn toggle_equip_menu(&mut self) {
8870        self.state.show_equip_menu = !self.state.show_equip_menu;
8871        if self.state.show_equip_menu {
8872            self.state.show_stats = false;
8873            self.state.show_craft_menu = false;
8874            self.state.show_shop_menu = false;
8875            self.state.shop_catalog = None;
8876            self.state.show_inventory_menu = false;
8877            self.state.show_loadout_menu = false;
8878        }
8879    }
8880
8881    pub fn cycle_character_sheet_tab(&mut self) {
8882        if self.state.show_stats {
8883            self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
8884        }
8885    }
8886
8887    pub fn set_ledger_period_digit(&mut self, c: char) {
8888        if self.state.show_stats {
8889            if let Some(p) = LedgerPeriod::from_digit(c) {
8890                self.state.ledger_period = p;
8891                self.state.character_sheet_tab = CharacterSheetTab::Ledger;
8892            }
8893        }
8894    }
8895
8896    pub fn cycle_ledger_period(&mut self) {
8897        if self.state.show_stats && self.state.character_sheet_tab == CharacterSheetTab::Ledger {
8898            self.state.ledger_period = self.state.ledger_period.cycle();
8899        }
8900    }
8901
8902    pub fn open_inventory_menu(&mut self) {
8903        self.state.social_chat.picking_stone = false;
8904        self.state.show_inventory_menu = true;
8905        self.state.show_craft_menu = false;
8906        self.state.show_shop_menu = false;
8907        self.state.shop_catalog = None;
8908        self.state.show_stats = false;
8909        self.state.show_move_picker = false;
8910        self.state.move_picker = None;
8911        self.state.show_destroy_picker = false;
8912        self.state.destroy_confirm_pending = false;
8913        self.state.destroy_picker = None;
8914        self.state.show_deconstruct_picker = false;
8915        self.state.deconstruct_confirm_pending = false;
8916        self.state.deconstruct_picker = None;
8917        self.state.show_rename_prompt = false;
8918        self.state.rename_plot_id = None;
8919        self.state.rename_buffer.clear();
8920        self.state.inventory_filter_focused = false;
8921        self.state.clamp_inventory_indices();
8922    }
8923
8924    pub fn close_inventory_menu(&mut self) {
8925        self.state.show_inventory_menu = false;
8926        self.state.show_move_picker = false;
8927        self.state.move_picker = None;
8928        self.close_grant_picker();
8929        self.state.show_destroy_picker = false;
8930        self.state.destroy_confirm_pending = false;
8931        self.state.destroy_picker = None;
8932        self.state.show_deconstruct_picker = false;
8933        self.state.deconstruct_confirm_pending = false;
8934        self.state.deconstruct_picker = None;
8935        self.state.show_rename_prompt = false;
8936        self.state.rename_plot_id = None;
8937        self.state.rename_buffer.clear();
8938        self.state.inventory_filter_focused = false;
8939    }
8940
8941    pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
8942        let Some(row) = self.state.inventory_selected_row() else {
8943            anyhow::bail!("inventory empty");
8944        };
8945        if GameState::is_property_deed_template(&row.stack.template_id) {
8946            let Some(plot_id) = GameState::deed_plot_id(&row.stack) else {
8947                anyhow::bail!("deed has no plot id");
8948            };
8949            let label = self
8950                .state
8951                .property_plots
8952                .iter()
8953                .find(|p| p.plot_id == plot_id)
8954                .map(|p| {
8955                    if p.label.trim().is_empty() {
8956                        p.plot_code.clone()
8957                    } else {
8958                        p.label.clone()
8959                    }
8960                })
8961                .unwrap_or_else(|| {
8962                    row.stack
8963                        .display_name
8964                        .clone()
8965                        .unwrap_or_else(|| "plot".into())
8966                });
8967            self.state.rename_buffer = label;
8968            self.state.rename_plot_id = Some(plot_id);
8969            self.state.highlighted_plot_id = Some(plot_id);
8970            self.state.show_rename_prompt = true;
8971            self.state.show_worker_rename = false;
8972            self.state.show_move_picker = false;
8973            self.state.show_destroy_picker = false;
8974            self.state.destroy_confirm_pending = false;
8975            self.close_deconstruct_picker();
8976            return Ok(());
8977        }
8978        if !self.state.row_is_renameable_container(&row) {
8979            anyhow::bail!("only storage containers or deeds can be renamed");
8980        }
8981        let current = row
8982            .stack
8983            .display_name
8984            .clone()
8985            .unwrap_or_else(|| row.stack.template_id.clone());
8986        self.state.rename_buffer = current;
8987        self.state.rename_plot_id = None;
8988        self.state.show_rename_prompt = true;
8989        self.state.show_worker_rename = false;
8990        self.state.show_move_picker = false;
8991        self.state.show_destroy_picker = false;
8992        self.state.destroy_confirm_pending = false;
8993        self.close_deconstruct_picker();
8994        Ok(())
8995    }
8996
8997    /// Rename the owned plot under the player's feet (`n` in world).
8998    pub fn open_plot_rename_under_player(&mut self) -> anyhow::Result<()> {
8999        let Some(plot) = self.state.my_plot_under_player().cloned() else {
9000            anyhow::bail!("stand on your plot to rename it");
9001        };
9002        let label = if plot.label.trim().is_empty() {
9003            plot.plot_code.clone()
9004        } else {
9005            plot.label.clone()
9006        };
9007        self.state.rename_buffer = label;
9008        self.state.rename_plot_id = Some(plot.plot_id);
9009        self.state.highlighted_plot_id = Some(plot.plot_id);
9010        self.state.show_rename_prompt = true;
9011        self.state.show_worker_rename = false;
9012        Ok(())
9013    }
9014
9015    pub fn cancel_rename_prompt(&mut self) {
9016        self.state.show_rename_prompt = false;
9017        self.state.rename_plot_id = None;
9018        self.state.rename_buffer.clear();
9019    }
9020
9021    pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
9022        let name = self.state.rename_buffer.trim().to_string();
9023        if name.is_empty() {
9024            anyhow::bail!("name cannot be empty");
9025        }
9026        if let Some(plot_id) = self.state.rename_plot_id {
9027            if name.chars().count() > 48 {
9028                anyhow::bail!("label must be 1–48 characters");
9029            }
9030            self.seq += 1;
9031            self.session
9032                .submit_intent(Intent::RenamePropertyPlot {
9033                    entity_id: self.state.entity_id,
9034                    plot_id,
9035                    label: name,
9036                    seq: self.seq,
9037                })
9038                .await?;
9039            self.state.intents_sent += 1;
9040            self.state.show_rename_prompt = false;
9041            self.state.rename_plot_id = None;
9042            self.state.rename_buffer.clear();
9043            return Ok(());
9044        }
9045        if name.chars().count() > 32 {
9046            anyhow::bail!("name must be 1–32 characters");
9047        }
9048        let Some(row) = self.state.inventory_selected_row() else {
9049            anyhow::bail!("inventory empty");
9050        };
9051        let Some(instance_id) = row.stack.item_instance_id else {
9052            anyhow::bail!("item has no instance id");
9053        };
9054        self.seq += 1;
9055        self.session
9056            .submit_intent(Intent::RenameContainer {
9057                entity_id: self.state.entity_id,
9058                item_instance_id: instance_id,
9059                location: row.from.clone(),
9060                name,
9061                seq: self.seq,
9062            })
9063            .await?;
9064        self.state.intents_sent += 1;
9065        self.state.show_rename_prompt = false;
9066        self.state.rename_buffer.clear();
9067        Ok(())
9068    }
9069
9070    pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
9071        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
9072            anyhow::bail!("no worker selected");
9073        };
9074        self.state.rename_buffer = worker.label.clone();
9075        self.state.show_worker_rename = true;
9076        self.state.show_rename_prompt = false;
9077        Ok(())
9078    }
9079
9080    pub fn cancel_worker_rename(&mut self) {
9081        self.state.show_worker_rename = false;
9082        self.state.rename_buffer.clear();
9083    }
9084
9085    pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
9086        let name = self.state.rename_buffer.trim().to_string();
9087        if name.is_empty() {
9088            anyhow::bail!("name cannot be empty");
9089        }
9090        if name.chars().count() > 32 {
9091            anyhow::bail!("name must be 1–32 characters");
9092        }
9093        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
9094            anyhow::bail!("no worker selected");
9095        };
9096        let worker_instance_id = worker.instance_id.clone();
9097        self.seq += 1;
9098        self.session
9099            .submit_intent(Intent::RenameHiredWorker {
9100                entity_id: self.state.entity_id,
9101                worker_instance_id: worker_instance_id.clone(),
9102                name: name.clone(),
9103                seq: self.seq,
9104            })
9105            .await?;
9106        self.state.intents_sent += 1;
9107        if let Some(w) = self
9108            .state
9109            .hired_workers
9110            .iter_mut()
9111            .find(|w| w.instance_id == worker_instance_id)
9112        {
9113            w.label = name.clone();
9114        }
9115        if let Some(ed) = self.state.worker_route_editor.as_mut() {
9116            if ed.worker_instance_id == worker_instance_id {
9117                ed.worker_label = name.clone();
9118            }
9119        }
9120        self.state.show_worker_rename = false;
9121        self.state.rename_buffer.clear();
9122        self.state.push_log(format!("Renamed worker to \"{name}\""));
9123        Ok(())
9124    }
9125
9126    pub fn toggle_inventory_menu(&mut self) {
9127        if self.state.show_inventory_menu {
9128            self.close_inventory_menu();
9129        } else {
9130            self.open_inventory_menu();
9131        }
9132    }
9133
9134    /// ↑/↓ in the inventory browser, or within the "move to…" / grant picker when open.
9135    pub fn inventory_menu_move(&mut self, delta: i32) {
9136        if self.state.show_grant_picker {
9137            let Some(picker) = self.state.grant_picker.as_ref() else {
9138                return;
9139            };
9140            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
9141            let filter = picker.filter.clone();
9142            let n = labels.len();
9143            if n == 0 {
9144                return;
9145            }
9146            self.state.grant_picker_index =
9147                step_filtered_index(self.state.grant_picker_index, delta, n, |i| {
9148                    list_label_matches(&labels[i], &filter)
9149                });
9150            return;
9151        }
9152        if self.state.show_move_picker {
9153            let Some(picker) = self.state.move_picker.as_ref() else {
9154                return;
9155            };
9156            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
9157            let filter = picker.filter.clone();
9158            let n = labels.len();
9159            if n == 0 {
9160                return;
9161            }
9162            self.state.move_picker_index =
9163                step_filtered_index(self.state.move_picker_index, delta, n, |i| {
9164                    list_label_matches(&labels[i], &filter)
9165                });
9166            self.state.clamp_move_picker_quantity();
9167            return;
9168        }
9169        let n = self.state.inventory_selectable_rows().len();
9170        if n == 0 {
9171            return;
9172        }
9173        let idx = self.state.inventory_menu_index as i32;
9174        self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9175    }
9176
9177    /// PageUp / PageDown (±1 page) for inventory / move / grant lists.
9178    pub fn inventory_menu_page(&mut self, pages: i32) {
9179        if self.state.show_grant_picker {
9180            let Some(picker) = self.state.grant_picker.as_ref() else {
9181                return;
9182            };
9183            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
9184            let filter = picker.filter.clone();
9185            let n = labels.len();
9186            self.state.grant_picker_index =
9187                page_filtered_index(self.state.grant_picker_index, pages, n, |i| {
9188                    list_label_matches(&labels[i], &filter)
9189                });
9190            return;
9191        }
9192        if self.state.show_move_picker {
9193            let Some(picker) = self.state.move_picker.as_ref() else {
9194                return;
9195            };
9196            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
9197            let filter = picker.filter.clone();
9198            let n = labels.len();
9199            self.state.move_picker_index =
9200                page_filtered_index(self.state.move_picker_index, pages, n, |i| {
9201                    list_label_matches(&labels[i], &filter)
9202                });
9203            self.state.clamp_move_picker_quantity();
9204            return;
9205        }
9206        let n = self.state.inventory_selectable_rows().len();
9207        self.state.inventory_menu_index =
9208            page_list_index(self.state.inventory_menu_index, pages, n);
9209    }
9210
9211    pub fn cycle_inventory_tab(&mut self, forward: bool) {
9212        if self.state.show_move_picker
9213            || self.state.show_grant_picker
9214            || self.state.show_destroy_picker
9215            || self.state.show_deconstruct_picker
9216            || self.state.show_rename_prompt
9217            || self.state.inventory_filter_focused
9218        {
9219            return;
9220        }
9221        self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
9222        self.state.inventory_menu_index = 0;
9223        self.state.clamp_inventory_indices();
9224    }
9225
9226    pub fn focus_inventory_filter(&mut self) {
9227        if self.state.show_grant_picker {
9228            if let Some(p) = self.state.grant_picker.as_mut() {
9229                p.filter_focused = true;
9230            }
9231            return;
9232        }
9233        if self.state.show_move_picker {
9234            if let Some(p) = self.state.move_picker.as_mut() {
9235                p.filter_focused = true;
9236            }
9237            return;
9238        }
9239        self.state.inventory_filter_focused = true;
9240    }
9241
9242    pub fn set_inventory_filter(&mut self, filter: String) {
9243        self.state.inventory_filter = filter;
9244        self.state.inventory_menu_index = 0;
9245        self.state.clamp_inventory_indices();
9246    }
9247
9248    pub fn append_inventory_filter_char(&mut self, ch: char) {
9249        if !is_list_filter_char(ch) {
9250            return;
9251        }
9252        if self.state.show_grant_picker {
9253            if let Some(p) = self.state.grant_picker.as_mut() {
9254                if p.filter_focused {
9255                    p.filter.push(ch);
9256                    self.state.grant_picker_index = 0;
9257                }
9258            }
9259            return;
9260        }
9261        if self.state.show_move_picker {
9262            if let Some(p) = self.state.move_picker.as_mut() {
9263                if p.filter_focused {
9264                    p.filter.push(ch);
9265                    self.state.move_picker_index = 0;
9266                    self.state.clamp_move_picker_quantity();
9267                }
9268            }
9269            return;
9270        }
9271        if !self.state.inventory_filter_focused {
9272            return;
9273        }
9274        self.state.inventory_filter.push(ch);
9275        self.state.inventory_menu_index = 0;
9276        self.state.clamp_inventory_indices();
9277    }
9278
9279    pub fn inventory_filter_backspace(&mut self) {
9280        if self.state.show_grant_picker {
9281            if let Some(p) = self.state.grant_picker.as_mut() {
9282                if p.filter_focused {
9283                    p.filter.pop();
9284                    self.state.grant_picker_index = 0;
9285                }
9286            }
9287            return;
9288        }
9289        if self.state.show_move_picker {
9290            if let Some(p) = self.state.move_picker.as_mut() {
9291                if p.filter_focused {
9292                    p.filter.pop();
9293                    self.state.move_picker_index = 0;
9294                    self.state.clamp_move_picker_quantity();
9295                }
9296            }
9297            return;
9298        }
9299        if !self.state.inventory_filter_focused {
9300            return;
9301        }
9302        self.state.inventory_filter.pop();
9303        self.state.inventory_menu_index = 0;
9304        self.state.clamp_inventory_indices();
9305    }
9306
9307    /// Esc while filter focused: clear filter then blur. Returns true if handled.
9308    pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
9309        if self.state.show_grant_picker {
9310            if let Some(p) = self.state.grant_picker.as_mut() {
9311                if p.filter_focused {
9312                    if !p.filter.is_empty() {
9313                        p.filter.clear();
9314                        self.state.grant_picker_index = 0;
9315                    } else {
9316                        p.filter_focused = false;
9317                    }
9318                    return true;
9319                }
9320                if !p.filter.is_empty() {
9321                    p.filter.clear();
9322                    self.state.grant_picker_index = 0;
9323                    return true;
9324                }
9325            }
9326            return false;
9327        }
9328        if self.state.show_move_picker {
9329            if let Some(p) = self.state.move_picker.as_mut() {
9330                if p.filter_focused {
9331                    if !p.filter.is_empty() {
9332                        p.filter.clear();
9333                        self.state.move_picker_index = 0;
9334                        self.state.clamp_move_picker_quantity();
9335                    } else {
9336                        p.filter_focused = false;
9337                    }
9338                    return true;
9339                }
9340                if !p.filter.is_empty() {
9341                    p.filter.clear();
9342                    self.state.move_picker_index = 0;
9343                    self.state.clamp_move_picker_quantity();
9344                    return true;
9345                }
9346            }
9347            return false;
9348        }
9349        if self.state.inventory_filter_focused {
9350            if !self.state.inventory_filter.is_empty() {
9351                self.state.inventory_filter.clear();
9352                self.state.inventory_menu_index = 0;
9353                self.state.clamp_inventory_indices();
9354            } else {
9355                self.state.inventory_filter_focused = false;
9356            }
9357            return true;
9358        }
9359        if !self.state.inventory_filter.is_empty() {
9360            self.state.inventory_filter.clear();
9361            self.state.inventory_menu_index = 0;
9362            self.state.clamp_inventory_indices();
9363            return true;
9364        }
9365        false
9366    }
9367
9368    pub fn craft_menu_page(&mut self, pages: i32) {
9369        let n = self.state.craft_filtered_indices().len();
9370        self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
9371        self.state.clamp_craft_batch_quantity();
9372    }
9373
9374    pub fn shop_menu_page(&mut self, pages: i32) {
9375        let n = self.state.shop_list_len();
9376        self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
9377        self.state.clamp_shop_quantity();
9378    }
9379
9380    pub fn workers_menu_page(&mut self, pages: i32) {
9381        let n = self.state.hired_workers.len();
9382        self.state.workers_menu_index = page_list_index(self.state.workers_menu_index, pages, n);
9383    }
9384
9385    /// Enter: unequip a worn bag, equip a weapon, wear/place a loose bag or
9386    /// chest — or fall back to the "move to…" destination picker (including
9387    /// loose consumables; pick "Use" in that list or press `e` to eat/drink).
9388    /// Placed chest shells open a pick-up destination picker (`l` still locks).
9389    pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
9390        if self.state.show_destroy_picker {
9391            if self.state.destroy_confirm_pending {
9392                return self.confirm_destroy_item().await;
9393            }
9394            return self.request_destroy_confirm();
9395        }
9396        if self.state.show_deconstruct_picker {
9397            if self.state.deconstruct_confirm_pending {
9398                return self.confirm_deconstruct_item().await;
9399            }
9400            return self.request_deconstruct_confirm();
9401        }
9402        if self.state.show_grant_picker {
9403            return self.confirm_grant_picker().await;
9404        }
9405        if self.state.show_move_picker {
9406            return self.confirm_move_picker().await;
9407        }
9408        let Some(row) = self.state.inventory_selected_row() else {
9409            anyhow::bail!("inventory empty");
9410        };
9411        if row.is_equip_shell {
9412            let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
9413                anyhow::bail!("not a worn item");
9414            };
9415            return self.equip_worn(slot, None).await;
9416        }
9417        if row.is_chest_shell {
9418            return self.open_chest_pickup_picker();
9419        }
9420        let template_id = row.stack.template_id.clone();
9421        let instance_id = row.stack.item_instance_id;
9422        let category = self.state.inventory_item_category(&template_id);
9423        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9424
9425        if category == Some("weapon") {
9426            return self.equip_mainhand(Some(template_id)).await;
9427        }
9428        if category == Some("lodging") && on_person {
9429            if let Some(inst) = instance_id {
9430                return self.place_container(inst).await;
9431            }
9432        }
9433        // Placeable workshop tools (kiln, furnace) — Enter places them like chests.
9434        if on_person {
9435            if let Some(inst) = instance_id {
9436                if row.stack.world_placeable == Some(true) {
9437                    return self.place_container(inst).await;
9438                }
9439            }
9440        }
9441        if (category == Some("container") || category == Some("armor")) && on_person {
9442            if let Some(inst) = instance_id {
9443                let world_placeable =
9444                    row.stack.world_placeable == Some(true) || template_id.contains("chest");
9445                if world_placeable {
9446                    return self.place_container(inst).await;
9447                }
9448                // Pouches no longer equip directly — they clip onto a worn belt's loops
9449                // instead, so fall through to the move picker (offers "belt loop" when a
9450                // belt is worn). Backpacks/belts/armor equip straight to their body slot.
9451                if let Some(slot) = guess_body_slot(&template_id) {
9452                    return self.equip_worn(slot, Some(inst)).await;
9453                }
9454            }
9455        }
9456        // Anything else (materials, consumables, pouches, items nested in a bag/chest,
9457        // weapons you'd rather stash than wield, ...) — offer explicit places to move it
9458        // instead of guessing.
9459        self.open_move_picker()
9460    }
9461
9462    /// `e`: eat/drink a consumable, or open grant-target picker for grant oils/scrolls.
9463    pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
9464        let Some(row) = self.state.inventory_selected_row() else {
9465            anyhow::bail!("inventory empty");
9466        };
9467        if row.from != flatland_protocol::InventoryLocation::Root {
9468            anyhow::bail!("select a consumable on your person");
9469        }
9470        if GameState::stack_is_item_grant(&row.stack) {
9471            return self.open_grant_target_picker();
9472        }
9473        if GameState::is_property_deed_template(&row.stack.template_id) {
9474            return self.open_move_picker();
9475        }
9476        let category = self.state.inventory_item_category(&row.stack.template_id);
9477        if category != Some("consumable") && !GameState::stack_is_serving(&row.stack) {
9478            anyhow::bail!("selected item is not usable");
9479        }
9480        self.use_item(&row.stack.template_id).await
9481    }
9482
9483    /// Open picker: apply selected grant item onto inventory / worn gear.
9484    pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
9485        let Some(row) = self.state.inventory_selected_row() else {
9486            anyhow::bail!("inventory empty");
9487        };
9488        if row.from != flatland_protocol::InventoryLocation::Root {
9489            anyhow::bail!("select a grant item on your person");
9490        }
9491        if !GameState::stack_is_item_grant(&row.stack) {
9492            anyhow::bail!("selected item does not grant onto gear");
9493        }
9494        let Some(grant_instance_id) = row.stack.item_instance_id else {
9495            anyhow::bail!("grant has no instance id");
9496        };
9497        let effect_id = GameState::grant_effect_id(&row.stack)
9498            .unwrap_or("?")
9499            .to_string();
9500        let mode = GameState::grant_mode(&row.stack).to_string();
9501        let options = self.state.grant_target_options(&row.stack);
9502        if options.is_empty() {
9503            anyhow::bail!("no valid gear to apply {effect_id} to");
9504        }
9505        let grant_label = row
9506            .stack
9507            .display_name
9508            .clone()
9509            .unwrap_or_else(|| row.stack.template_id.clone());
9510        self.state.show_grant_picker = true;
9511        self.state.grant_picker_index = 0;
9512        self.state.grant_picker = Some(GrantTargetPicker {
9513            grant_instance_id,
9514            grant_label,
9515            effect_id,
9516            mode,
9517            options,
9518            filter: String::new(),
9519            filter_focused: false,
9520        });
9521        Ok(())
9522    }
9523
9524    pub fn close_grant_picker(&mut self) {
9525        self.state.show_grant_picker = false;
9526        self.state.grant_picker = None;
9527        self.state.grant_picker_index = 0;
9528    }
9529
9530    pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
9531        let Some(picker) = self.state.grant_picker.clone() else {
9532            self.close_grant_picker();
9533            return Ok(());
9534        };
9535        let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
9536            self.close_grant_picker();
9537            return Ok(());
9538        };
9539        self.close_grant_picker();
9540        self.use_grant(picker.grant_instance_id, opt.target_instance_id)
9541            .await?;
9542        self.state
9543            .push_log(format!("Applying {} onto {}…", picker.effect_id, opt.label));
9544        Ok(())
9545    }
9546
9547    /// `m`: always open the "move to…" picker for the selected item, even for
9548    /// weapons/wearables that Enter would otherwise equip/wear directly.
9549    /// Placed chests open the pick-up destination picker instead.
9550    pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
9551        let Some(row) = self.state.inventory_selected_row() else {
9552            anyhow::bail!("inventory empty");
9553        };
9554        if row.is_equip_shell {
9555            anyhow::bail!("this is a worn bag — press Enter to unequip it");
9556        }
9557        if row.is_chest_shell {
9558            return self.open_chest_pickup_picker();
9559        }
9560        let Some(instance_id) = row.stack.item_instance_id else {
9561            anyhow::bail!("item has no instance id");
9562        };
9563        let mut options = self.state.move_destinations_for(
9564            &row.from,
9565            row.from_parent_instance_id,
9566            row.stack.item_instance_id,
9567            &row.stack.template_id,
9568        );
9569        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9570        let category = self.state.inventory_item_category(&row.stack.template_id);
9571        if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
9572            if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
9573                options.insert(
9574                    0,
9575                    MoveOption::action(
9576                        "Sell plot to crown…",
9577                        MoveOptionKind::SellPlotToCrown { plot_id },
9578                    ),
9579                );
9580            }
9581        }
9582        if on_person && category == Some("consumable") {
9583            if GameState::stack_is_item_grant(&row.stack) {
9584                options.insert(
9585                    0,
9586                    MoveOption::action("Apply onto gear…", MoveOptionKind::GrantApply),
9587                );
9588            } else {
9589                let study = GameState::stack_is_blueprint_scroll(&row.stack);
9590                options.insert(
9591                    0,
9592                    MoveOption::action(
9593                        if study { "Study" } else { "Use (eat / drink)" },
9594                        MoveOptionKind::Use,
9595                    ),
9596                );
9597            }
9598        } else if on_person && GameState::stack_is_serving(&row.stack) {
9599            let label = if GameState::stack_is_food_serving(&row.stack) {
9600                "Use (eat)"
9601            } else {
9602                "Use (fill / drink)"
9603            };
9604            options.insert(0, MoveOption::action(label, MoveOptionKind::Use));
9605        }
9606        let item_label = row
9607            .stack
9608            .display_name
9609            .clone()
9610            .unwrap_or_else(|| row.stack.template_id.clone());
9611        // Default to 1 so withdrawing from storage is partial unless the player
9612        // presses `a` for max fit (full stack when the destination allows it).
9613        let initial_qty = if row.stack.quantity > 1 {
9614            1
9615        } else {
9616            row.stack.quantity
9617        };
9618        self.state.move_picker = Some(MovePicker {
9619            item_instance_id: instance_id,
9620            from: row.from,
9621            item_label,
9622            template_id: row.stack.template_id.clone(),
9623            stack_quantity: row.stack.quantity,
9624            quantity: initial_qty.max(1),
9625            options,
9626            filter: String::new(),
9627            filter_focused: false,
9628        });
9629        self.state.move_picker_index = 0;
9630        self.state.show_move_picker = true;
9631        self.state.show_destroy_picker = false;
9632        self.state.destroy_confirm_pending = false;
9633        self.state.destroy_picker = None;
9634        self.state.show_deconstruct_picker = false;
9635        self.state.deconstruct_confirm_pending = false;
9636        self.state.deconstruct_picker = None;
9637        self.state.clamp_move_picker_quantity();
9638        Ok(())
9639    }
9640
9641    /// Enter/`m` on a placed chest shell: pick destinations to take it into inventory.
9642    pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
9643        let Some(row) = self.state.inventory_selected_row() else {
9644            anyhow::bail!("inventory empty");
9645        };
9646        if !row.is_chest_shell {
9647            anyhow::bail!("not a placed chest");
9648        }
9649        let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
9650            anyhow::bail!("not a placed chest");
9651        };
9652        let Some(instance_id) = row.stack.item_instance_id else {
9653            anyhow::bail!("chest has no instance id");
9654        };
9655        let chest = self
9656            .state
9657            .placed_containers
9658            .iter()
9659            .find(|c| c.id == *container_id)
9660            .cloned()
9661            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
9662        let (px, py) = self.state.player_position();
9663        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
9664            anyhow::bail!("too far from {}", chest.display_name);
9665        }
9666        if chest.locked && !chest.accessible {
9667            anyhow::bail!(
9668                "need the matching key for {} before picking it up",
9669                chest.display_name
9670            );
9671        }
9672        let options = self.state.chest_pickup_destinations(container_id);
9673        let item_label = row
9674            .stack
9675            .display_name
9676            .clone()
9677            .unwrap_or_else(|| row.stack.template_id.clone());
9678        self.state.move_picker = Some(MovePicker {
9679            item_instance_id: instance_id,
9680            from: row.from.clone(),
9681            item_label,
9682            template_id: row.stack.template_id.clone(),
9683            stack_quantity: 1,
9684            quantity: 1,
9685            options,
9686            filter: String::new(),
9687            filter_focused: false,
9688        });
9689        self.state.move_picker_index = 0;
9690        self.state.show_move_picker = true;
9691        self.state.show_destroy_picker = false;
9692        self.state.destroy_confirm_pending = false;
9693        self.state.destroy_picker = None;
9694        self.state.show_deconstruct_picker = false;
9695        self.state.deconstruct_confirm_pending = false;
9696        self.state.deconstruct_picker = None;
9697        Ok(())
9698    }
9699
9700    pub fn close_move_picker(&mut self) {
9701        self.state.show_move_picker = false;
9702        self.state.move_picker = None;
9703    }
9704
9705    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
9706        self.state.move_picker_adjust_quantity(delta);
9707    }
9708
9709    pub fn move_picker_set_quantity_max(&mut self) {
9710        self.state.move_picker_set_quantity_max();
9711    }
9712
9713    pub fn move_picker_set_quantity_min(&mut self) {
9714        self.state.move_picker_set_quantity_min();
9715    }
9716
9717    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
9718        self.state.destroy_picker_adjust_quantity(delta);
9719    }
9720
9721    pub fn destroy_picker_set_quantity_max(&mut self) {
9722        self.state.destroy_picker_set_quantity_max();
9723    }
9724
9725    pub fn destroy_picker_set_quantity_min(&mut self) {
9726        self.state.destroy_picker_set_quantity_min();
9727    }
9728
9729    async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
9730        let Some(picker) = self.state.move_picker.clone() else {
9731            self.close_move_picker();
9732            return Ok(());
9733        };
9734        let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
9735            self.close_move_picker();
9736            return Ok(());
9737        };
9738        match option.kind {
9739            MoveOptionKind::Cancel => {
9740                self.close_move_picker();
9741            }
9742            MoveOptionKind::Use => {
9743                self.close_move_picker();
9744                self.use_item(&picker.template_id).await?;
9745            }
9746            MoveOptionKind::GrantApply => {
9747                self.close_move_picker();
9748                self.open_grant_target_picker()?;
9749            }
9750            MoveOptionKind::SellPlotToCrown { plot_id } => {
9751                self.close_move_picker();
9752                self.confirm_sell_plot_to_crown(plot_id).await?;
9753            }
9754            MoveOptionKind::RelocatePlaced { container_id } => {
9755                self.close_move_picker();
9756                self.state.show_inventory_menu = false;
9757                self.begin_relocate_container(&container_id)?;
9758            }
9759            MoveOptionKind::Drop => {
9760                self.close_move_picker();
9761                if self
9762                    .state
9763                    .hand_equipped_instance_ids()
9764                    .contains(&picker.item_instance_id)
9765                {
9766                    anyhow::bail!("unequip that item first");
9767                }
9768                if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
9769                    if self.state.deed_bound(&stack) {
9770                        anyhow::bail!(
9771                            "cannot drop a property deed — store it or trade it to another player"
9772                        );
9773                    }
9774                    if self.state.key_drop_blocked(&stack) {
9775                        anyhow::bail!("cannot drop the key while its chest is locked");
9776                    }
9777                }
9778                self.drop_item(picker.item_instance_id, picker.from).await?;
9779                self.state
9780                    .push_log(format!("Dropped {}", picker.item_label));
9781            }
9782            MoveOptionKind::PickupPlaced {
9783                container_id,
9784                nest_location,
9785                nest_parent_instance_id,
9786            } => {
9787                self.close_move_picker();
9788                self.pickup_container(container_id.clone()).await?;
9789                let nest_into_bag = nest_parent_instance_id.is_some()
9790                    || !matches!(nest_location, flatland_protocol::InventoryLocation::Root);
9791                if nest_into_bag {
9792                    self.move_item(
9793                        picker.item_instance_id,
9794                        flatland_protocol::InventoryLocation::Root,
9795                        nest_location,
9796                        nest_parent_instance_id,
9797                        None,
9798                    )
9799                    .await?;
9800                    self.state
9801                        .push_log(format!("Picked up {} into bag", picker.item_label));
9802                } else {
9803                    self.state
9804                        .push_log(format!("Picked up {}", picker.item_label));
9805                }
9806            }
9807            MoveOptionKind::Move {
9808                location,
9809                parent_instance_id,
9810            } => {
9811                self.close_move_picker();
9812                let qty = if picker.quantity >= picker.stack_quantity {
9813                    None
9814                } else {
9815                    Some(picker.quantity)
9816                };
9817                self.move_item(
9818                    picker.item_instance_id,
9819                    picker.from,
9820                    location,
9821                    parent_instance_id,
9822                    qty,
9823                )
9824                .await?;
9825                let moved = qty.unwrap_or(picker.stack_quantity);
9826                if moved >= picker.stack_quantity {
9827                    self.state.push_log(format!("Moved {}", picker.item_label));
9828                } else {
9829                    self.state.push_log(format!(
9830                        "Moved {} ×{} of {}",
9831                        picker.item_label, moved, picker.stack_quantity
9832                    ));
9833                }
9834            }
9835        }
9836        Ok(())
9837    }
9838
9839    /// `d`: place furniture (kiln, chest, bed) at your feet, or drop other items
9840    /// as ground loot. Placeable tools must not become loot piles — companions
9841    /// would vacuum them and they'd look "gone".
9842    pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
9843        let Some(row) = self.state.inventory_selected_row() else {
9844            anyhow::bail!("inventory empty");
9845        };
9846        if row.is_equip_shell {
9847            anyhow::bail!("unequip the bag first (Enter), then drop from your person");
9848        }
9849        if row.is_chest_shell {
9850            anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
9851        }
9852        let Some(inst) = row.stack.item_instance_id else {
9853            anyhow::bail!("item has no instance id");
9854        };
9855        if self.state.hand_equipped_instance_ids().contains(&inst) {
9856            anyhow::bail!("unequip that item first");
9857        }
9858        if self.state.deed_bound(&row.stack) {
9859            anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
9860        }
9861        if self.state.key_drop_blocked(&row.stack) {
9862            anyhow::bail!("cannot drop the key while its chest is locked");
9863        }
9864        let label = row
9865            .stack
9866            .display_name
9867            .clone()
9868            .unwrap_or_else(|| row.stack.template_id.clone());
9869        let placeable = row.stack.world_placeable == Some(true)
9870            || row.from == flatland_protocol::InventoryLocation::Root
9871                && matches!(
9872                    self.state
9873                        .inventory_item_category(&row.stack.template_id)
9874                        .as_deref(),
9875                    Some("lodging")
9876                );
9877        if placeable && row.from == flatland_protocol::InventoryLocation::Root {
9878            self.place_container(inst).await?;
9879            self.state.push_log(format!("Placed {label}"));
9880            return Ok(());
9881        }
9882        self.drop_item(inst, row.from).await?;
9883        self.state.push_log(format!("Dropped {label}"));
9884        Ok(())
9885    }
9886
9887    pub async fn drop_item(
9888        &mut self,
9889        item_instance_id: uuid::Uuid,
9890        from: flatland_protocol::InventoryLocation,
9891    ) -> anyhow::Result<()> {
9892        self.seq += 1;
9893        self.session
9894            .submit_intent(Intent::DropItem {
9895                entity_id: self.state.entity_id,
9896                item_instance_id,
9897                from,
9898                seq: self.seq,
9899            })
9900            .await?;
9901        self.state.intents_sent += 1;
9902        Ok(())
9903    }
9904
9905    /// `x`: open permanent-delete picker for the selected item (quantity + confirm).
9906    pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
9907        let Some(row) = self.state.inventory_selected_row() else {
9908            anyhow::bail!("inventory empty");
9909        };
9910        if row.is_equip_shell {
9911            anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
9912        }
9913        if row.is_chest_shell {
9914            anyhow::bail!("can't destroy a placed chest from the inventory list");
9915        }
9916        let Some(instance_id) = row.stack.item_instance_id else {
9917            anyhow::bail!("item has no instance id");
9918        };
9919        if self
9920            .state
9921            .hand_equipped_instance_ids()
9922            .contains(&instance_id)
9923        {
9924            anyhow::bail!("unequip that item first");
9925        }
9926        if self.state.deed_bound(&row.stack) {
9927            anyhow::bail!(
9928                "cannot destroy a property deed — store it or trade it to another player"
9929            );
9930        }
9931        if self.state.key_drop_blocked(&row.stack) {
9932            anyhow::bail!("cannot destroy the key while its chest is locked");
9933        }
9934        let item_label = row
9935            .stack
9936            .display_name
9937            .clone()
9938            .unwrap_or_else(|| row.stack.template_id.clone());
9939        self.state.destroy_picker = Some(DestroyPicker {
9940            item_instance_id: instance_id,
9941            from: row.from,
9942            item_label,
9943            stack_quantity: row.stack.quantity,
9944            quantity: row.stack.quantity,
9945        });
9946        self.state.destroy_confirm_pending = false;
9947        self.state.show_destroy_picker = true;
9948        self.state.show_move_picker = false;
9949        self.state.move_picker = None;
9950        self.close_deconstruct_picker();
9951        Ok(())
9952    }
9953
9954    pub fn close_destroy_picker(&mut self) {
9955        self.state.show_destroy_picker = false;
9956        self.state.destroy_confirm_pending = false;
9957        self.state.destroy_picker = None;
9958    }
9959
9960    pub fn cancel_destroy_confirm(&mut self) {
9961        self.state.destroy_confirm_pending = false;
9962    }
9963
9964    pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
9965        if self.state.destroy_picker.is_none() {
9966            self.close_destroy_picker();
9967            return Ok(());
9968        }
9969        self.state.destroy_confirm_pending = true;
9970        Ok(())
9971    }
9972
9973    pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
9974        let Some(picker) = self.state.destroy_picker.clone() else {
9975            self.close_destroy_picker();
9976            return Ok(());
9977        };
9978        let qty = if picker.quantity >= picker.stack_quantity {
9979            None
9980        } else {
9981            Some(picker.quantity)
9982        };
9983        self.destroy_item(picker.item_instance_id, picker.from, qty)
9984            .await?;
9985        let destroyed = qty.unwrap_or(picker.stack_quantity);
9986        if destroyed >= picker.stack_quantity {
9987            self.state
9988                .push_log(format!("Destroyed {}", picker.item_label));
9989        } else {
9990            self.state.push_log(format!(
9991                "Destroyed {} ×{} of {}",
9992                picker.item_label, destroyed, picker.stack_quantity
9993            ));
9994        }
9995        self.close_destroy_picker();
9996        Ok(())
9997    }
9998
9999    /// `r`: open deconstruct picker for the selected crafted item (quantity + confirm).
10000    pub fn open_deconstruct_picker(&mut self) -> anyhow::Result<()> {
10001        let Some(row) = self.state.inventory_selected_row() else {
10002            anyhow::bail!("inventory empty");
10003        };
10004        if row.is_equip_shell {
10005            anyhow::bail!("unequip the bag first (Enter), then deconstruct from your person");
10006        }
10007        if row.is_chest_shell {
10008            anyhow::bail!("pick up the chest first, then deconstruct it from your inventory");
10009        }
10010        if row.from != flatland_protocol::InventoryLocation::Root {
10011            anyhow::bail!("hold the item in your inventory to deconstruct it");
10012        }
10013        let Some(instance_id) = row.stack.item_instance_id else {
10014            anyhow::bail!("item has no instance id");
10015        };
10016        if self
10017            .state
10018            .hand_equipped_instance_ids()
10019            .contains(&instance_id)
10020        {
10021            anyhow::bail!("unequip that item first");
10022        }
10023        if self.state.deed_bound(&row.stack) {
10024            anyhow::bail!(
10025                "cannot deconstruct a property deed — store it or trade it to another player"
10026            );
10027        }
10028        if !GameState::stack_is_deconstructable(&row.stack) {
10029            anyhow::bail!("only crafted items can be deconstructed");
10030        }
10031        let item_label = row
10032            .stack
10033            .display_name
10034            .clone()
10035            .unwrap_or_else(|| row.stack.template_id.clone());
10036        self.state.deconstruct_picker = Some(DestroyPicker {
10037            item_instance_id: instance_id,
10038            from: row.from,
10039            item_label,
10040            stack_quantity: row.stack.quantity,
10041            quantity: 1,
10042        });
10043        self.state.deconstruct_confirm_pending = false;
10044        self.state.show_deconstruct_picker = true;
10045        self.state.show_move_picker = false;
10046        self.state.move_picker = None;
10047        self.close_destroy_picker();
10048        Ok(())
10049    }
10050
10051    pub fn close_deconstruct_picker(&mut self) {
10052        self.state.show_deconstruct_picker = false;
10053        self.state.deconstruct_confirm_pending = false;
10054        self.state.deconstruct_picker = None;
10055    }
10056
10057    pub fn cancel_deconstruct_confirm(&mut self) {
10058        self.state.deconstruct_confirm_pending = false;
10059    }
10060
10061    pub fn request_deconstruct_confirm(&mut self) -> anyhow::Result<()> {
10062        if self.state.deconstruct_picker.is_none() {
10063            self.close_deconstruct_picker();
10064            return Ok(());
10065        }
10066        self.state.deconstruct_confirm_pending = true;
10067        Ok(())
10068    }
10069
10070    pub fn deconstruct_picker_adjust_quantity(&mut self, delta: i32) {
10071        self.state.deconstruct_picker_adjust_quantity(delta);
10072    }
10073
10074    pub fn deconstruct_picker_set_quantity_max(&mut self) {
10075        self.state.deconstruct_picker_set_quantity_max();
10076    }
10077
10078    pub fn deconstruct_picker_set_quantity_min(&mut self) {
10079        self.state.deconstruct_picker_set_quantity_min();
10080    }
10081
10082    pub async fn confirm_deconstruct_item(&mut self) -> anyhow::Result<()> {
10083        let Some(picker) = self.state.deconstruct_picker.clone() else {
10084            self.close_deconstruct_picker();
10085            return Ok(());
10086        };
10087        let qty = if picker.quantity >= picker.stack_quantity {
10088            None
10089        } else {
10090            Some(picker.quantity)
10091        };
10092        self.deconstruct_item(picker.item_instance_id, picker.from, qty)
10093            .await?;
10094        let n = qty.unwrap_or(picker.stack_quantity);
10095        if n >= picker.stack_quantity {
10096            self.state
10097                .push_log(format!("Deconstructing {}", picker.item_label));
10098        } else {
10099            self.state.push_log(format!(
10100                "Deconstructing {} ×{} of {}",
10101                picker.item_label, n, picker.stack_quantity
10102            ));
10103        }
10104        self.close_deconstruct_picker();
10105        Ok(())
10106    }
10107
10108    pub async fn deconstruct_item(
10109        &mut self,
10110        item_instance_id: uuid::Uuid,
10111        from: flatland_protocol::InventoryLocation,
10112        quantity: Option<u32>,
10113    ) -> anyhow::Result<()> {
10114        self.seq += 1;
10115        self.session
10116            .submit_intent(Intent::DeconstructItem {
10117                entity_id: self.state.entity_id,
10118                item_instance_id,
10119                from,
10120                quantity,
10121                seq: self.seq,
10122            })
10123            .await?;
10124        self.state.intents_sent += 1;
10125        Ok(())
10126    }
10127
10128    pub async fn destroy_item(
10129        &mut self,
10130        item_instance_id: uuid::Uuid,
10131        from: flatland_protocol::InventoryLocation,
10132        quantity: Option<u32>,
10133    ) -> anyhow::Result<()> {
10134        self.seq += 1;
10135        self.session
10136            .submit_intent(Intent::DestroyItem {
10137                entity_id: self.state.entity_id,
10138                item_instance_id,
10139                from,
10140                quantity,
10141                seq: self.seq,
10142            })
10143            .await?;
10144        self.state.intents_sent += 1;
10145        Ok(())
10146    }
10147
10148    /// `l`: lock/unlock a placed chest — selected chest in inventory UI, else nearest.
10149    pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
10150        if let Some(row) = self.state.inventory_selected_row() {
10151            if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
10152                return self.toggle_placed_chest_lock(container_id).await;
10153            }
10154        }
10155        self.toggle_nearby_chest_lock().await
10156    }
10157
10158    pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
10159        let chest = self
10160            .state
10161            .placed_containers
10162            .iter()
10163            .find(|c| c.id == container_id)
10164            .cloned()
10165            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
10166        let (px, py) = self.state.player_position();
10167        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
10168            anyhow::bail!("too far from {}", chest.display_name);
10169        }
10170        if !chest.accessible && chest.locked {
10171            anyhow::bail!(
10172                "need the matching key for {} (each crafted chest has its own key)",
10173                chest.display_name
10174            );
10175        }
10176        let lock = !chest.locked;
10177        self.set_container_locked(
10178            flatland_protocol::InventoryLocation::Placed {
10179                container_id: chest.id.clone(),
10180            },
10181            lock,
10182        )
10183        .await?;
10184        self.state.push_log(if lock {
10185            format!("Locked {}", chest.display_name)
10186        } else {
10187            format!("Unlocked {}", chest.display_name)
10188        });
10189        Ok(())
10190    }
10191
10192    /// `l` outside inventory: lock/unlock the nearest placed chest (within `CONTAINER_RANGE_M`).
10193    pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
10194        let chest = self
10195            .state
10196            .nearest_placed_container(CONTAINER_RANGE_M)
10197            .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
10198        self.toggle_placed_chest_lock(&chest.id).await
10199    }
10200
10201    pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
10202        self.equip_mainhand(None).await
10203    }
10204
10205    pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
10206        if !self.state.is_alive() {
10207            anyhow::bail!("you are dead");
10208        }
10209        self.seq += 1;
10210        self.session
10211            .submit_intent(Intent::EquipOffhand {
10212                entity_id: self.state.entity_id,
10213                template_id,
10214                instance_id: None,
10215                seq: self.seq,
10216            })
10217            .await?;
10218        self.state.intents_sent += 1;
10219        Ok(())
10220    }
10221
10222    pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
10223        self.equip_offhand(None).await
10224    }
10225
10226    pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
10227        let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
10228        for slot in slots {
10229            self.equip_worn(slot, None).await?;
10230        }
10231        Ok(())
10232    }
10233
10234    pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
10235        let (px, py) = self.state.player_position();
10236        let in_range: Vec<_> = self
10237            .state
10238            .placed_containers
10239            .iter()
10240            .filter(|c| self.state.placed_container_in_current_space(c))
10241            .filter(|c| (c.x - px).hypot(c.y - py) <= 2.0)
10242            .collect();
10243        let nearest_free = in_range
10244            .iter()
10245            .copied()
10246            .filter(|c| !self.state.lodging_is_occupied(&c.id))
10247            .min_by(|a, b| {
10248                let da = (a.x - px).hypot(a.y - py);
10249                let db = (b.x - px).hypot(b.y - py);
10250                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
10251            })
10252            .cloned();
10253        if let Some(chest) = nearest_free {
10254            return self.pickup_container(chest.id).await;
10255        }
10256        if in_range
10257            .iter()
10258            .any(|c| self.state.lodging_is_occupied(&c.id))
10259        {
10260            anyhow::bail!("dismiss or reassign workers before picking up lodging");
10261        }
10262        if in_range.is_empty() {
10263            anyhow::bail!("no chest nearby");
10264        }
10265        anyhow::bail!("too far from chest");
10266    }
10267
10268    pub async fn equip_worn(
10269        &mut self,
10270        slot: BodySlot,
10271        instance_id: Option<uuid::Uuid>,
10272    ) -> anyhow::Result<()> {
10273        self.seq += 1;
10274        self.session
10275            .submit_intent(Intent::EquipWorn {
10276                entity_id: self.state.entity_id,
10277                slot,
10278                instance_id,
10279                seq: self.seq,
10280            })
10281            .await?;
10282        self.state.intents_sent += 1;
10283        Ok(())
10284    }
10285
10286    pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
10287        self.seq += 1;
10288        self.session
10289            .submit_intent(Intent::PlaceContainer {
10290                entity_id: self.state.entity_id,
10291                item_instance_id,
10292                seq: self.seq,
10293            })
10294            .await?;
10295        self.state.intents_sent += 1;
10296        Ok(())
10297    }
10298
10299    pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
10300        self.seq += 1;
10301        self.session
10302            .submit_intent(Intent::PickupContainer {
10303                entity_id: self.state.entity_id,
10304                container_id,
10305                seq: self.seq,
10306            })
10307            .await?;
10308        self.state.intents_sent += 1;
10309        Ok(())
10310    }
10311
10312    pub async fn move_item(
10313        &mut self,
10314        item_instance_id: uuid::Uuid,
10315        from: flatland_protocol::InventoryLocation,
10316        to: flatland_protocol::InventoryLocation,
10317        to_parent_instance_id: Option<uuid::Uuid>,
10318        quantity: Option<u32>,
10319    ) -> anyhow::Result<()> {
10320        self.seq += 1;
10321        self.session
10322            .submit_intent(Intent::MoveItem {
10323                entity_id: self.state.entity_id,
10324                item_instance_id,
10325                from,
10326                to,
10327                to_parent_instance_id,
10328                quantity,
10329                seq: self.seq,
10330            })
10331            .await?;
10332        self.state.intents_sent += 1;
10333        Ok(())
10334    }
10335
10336    pub async fn set_container_locked(
10337        &mut self,
10338        location: flatland_protocol::InventoryLocation,
10339        locked: bool,
10340    ) -> anyhow::Result<()> {
10341        self.seq += 1;
10342        self.session
10343            .submit_intent(Intent::SetContainerLocked {
10344                entity_id: self.state.entity_id,
10345                location,
10346                locked,
10347                seq: self.seq,
10348            })
10349            .await?;
10350        self.state.intents_sent += 1;
10351        Ok(())
10352    }
10353
10354    pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
10355        if !self.state.is_alive() {
10356            anyhow::bail!("you are dead");
10357        }
10358        self.seq += 1;
10359        self.session
10360            .submit_intent(Intent::Use {
10361                entity_id: self.state.entity_id,
10362                template_id: template_id.to_string(),
10363                seq: self.seq,
10364            })
10365            .await?;
10366        self.state.intents_sent += 1;
10367        Ok(())
10368    }
10369
10370    /// Bind a grant consumable onto a specific item instance (unique gear status).
10371    pub async fn use_grant(
10372        &mut self,
10373        grant_instance_id: uuid::Uuid,
10374        target_instance_id: uuid::Uuid,
10375    ) -> anyhow::Result<()> {
10376        if !self.state.is_alive() {
10377            anyhow::bail!("you are dead");
10378        }
10379        self.seq += 1;
10380        self.session
10381            .submit_intent(Intent::UseGrant {
10382                entity_id: self.state.entity_id,
10383                grant_instance_id,
10384                target_instance_id,
10385                seq: self.seq,
10386            })
10387            .await?;
10388        self.state.intents_sent += 1;
10389        Ok(())
10390    }
10391
10392    pub fn open_craft_menu(&mut self) {
10393        self.state.show_craft_menu = true;
10394        self.state.show_shop_menu = false;
10395        self.state.shop_catalog = None;
10396        self.state.show_stats = false;
10397        self.state.show_inventory_menu = false;
10398        self.state.reload_craft_prefs();
10399        self.state.craft_tab = CraftTab::Ready;
10400        self.state.craft_filter.clear();
10401        self.state.craft_filter_focused = false;
10402        self.state.craft_menu_index = 0;
10403        self.state.clamp_craft_menu_index();
10404        self.state.craft_batch_quantity = 1;
10405        self.state.clamp_craft_batch_quantity();
10406    }
10407
10408    pub fn close_craft_menu(&mut self) {
10409        self.state.show_craft_menu = false;
10410        self.state.craft_filter_focused = false;
10411    }
10412
10413    pub fn toggle_keychain_menu(&mut self) {
10414        if self.state.show_keychain_menu {
10415            self.close_keychain_menu();
10416        } else {
10417            self.state.show_keychain_menu = true;
10418            self.state.show_craft_menu = false;
10419            self.state.show_shop_menu = false;
10420            self.state.show_inventory_menu = false;
10421            let n = self.state.keychain_entries().len();
10422            if n == 0 {
10423                self.state.keychain_menu_index = 0;
10424            } else {
10425                self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
10426            }
10427        }
10428    }
10429
10430    pub fn close_keychain_menu(&mut self) {
10431        self.state.show_keychain_menu = false;
10432    }
10433
10434    pub fn keychain_menu_move(&mut self, delta: i32) {
10435        let n = self.state.keychain_entries().len();
10436        if n == 0 {
10437            self.state.keychain_menu_index = 0;
10438            return;
10439        }
10440        let idx = self.state.keychain_menu_index as i32 + delta;
10441        self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
10442    }
10443
10444    pub fn keychain_menu_page(&mut self, pages: i32) {
10445        let n = self.state.keychain_entries().len();
10446        self.state.keychain_menu_index = page_list_index(self.state.keychain_menu_index, pages, n);
10447    }
10448
10449    pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
10450        if !self.state.is_alive() {
10451            anyhow::bail!("you are dead");
10452        }
10453        let entries = self.state.keychain_entries();
10454        let Some(entry) = entries.get(self.state.keychain_menu_index) else {
10455            anyhow::bail!("nothing selected");
10456        };
10457        let Some(instance_id) = entry.stack.item_instance_id else {
10458            anyhow::bail!("key has no instance id");
10459        };
10460        if entry.stowed {
10461            self.move_item(
10462                instance_id,
10463                flatland_protocol::InventoryLocation::Keychain,
10464                flatland_protocol::InventoryLocation::Root,
10465                None,
10466                Some(1),
10467            )
10468            .await
10469        } else {
10470            self.move_item(
10471                instance_id,
10472                flatland_protocol::InventoryLocation::Root,
10473                flatland_protocol::InventoryLocation::Keychain,
10474                None,
10475                Some(1),
10476            )
10477            .await
10478        }
10479    }
10480
10481    pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
10482        let npc_id = self.state.shop_catalog.as_ref().map(|c| c.npc_id.clone());
10483        self.state.show_shop_menu = false;
10484        self.state.shop_catalog = None;
10485        self.state.clear_shop_trade_log();
10486        if let Some(npc_id) = npc_id {
10487            self.seq += 1;
10488            self.session
10489                .submit_intent(Intent::ShopClose {
10490                    entity_id: self.state.entity_id,
10491                    npc_id,
10492                    seq: self.seq,
10493                })
10494                .await?;
10495            self.state.intents_sent += 1;
10496        }
10497        Ok(())
10498    }
10499
10500    pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
10501        let Some(panel) = self.state.bank_panel.clone() else {
10502            return Ok(());
10503        };
10504        self.seq += 1;
10505        self.session
10506            .submit_intent(Intent::BankDeposit {
10507                entity_id: self.state.entity_id,
10508                npc_id: panel.npc_id,
10509                amount_copper,
10510                seq: self.seq,
10511            })
10512            .await?;
10513        self.state.intents_sent += 1;
10514        Ok(())
10515    }
10516
10517    pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
10518        let Some(panel) = self.state.bank_panel.clone() else {
10519            return Ok(());
10520        };
10521        self.seq += 1;
10522        self.session
10523            .submit_intent(Intent::BankWithdraw {
10524                entity_id: self.state.entity_id,
10525                npc_id: panel.npc_id,
10526                amount_copper,
10527                seq: self.seq,
10528            })
10529            .await?;
10530        self.state.intents_sent += 1;
10531        Ok(())
10532    }
10533
10534    pub async fn bank_transfer(
10535        &mut self,
10536        to_character_id: Option<uuid::Uuid>,
10537        to_name: String,
10538        amount_copper: u64,
10539    ) -> anyhow::Result<()> {
10540        let Some(panel) = self.state.bank_panel.clone() else {
10541            return Ok(());
10542        };
10543        self.seq += 1;
10544        self.session
10545            .submit_intent(Intent::BankTransfer {
10546                entity_id: self.state.entity_id,
10547                npc_id: panel.npc_id,
10548                to_character_id,
10549                to_name,
10550                amount_copper,
10551                seq: self.seq,
10552            })
10553            .await?;
10554        self.state.intents_sent += 1;
10555        Ok(())
10556    }
10557
10558    pub fn bank_menu_move(&mut self, delta: i32) {
10559        let n = self.state.bank_menu_options().len();
10560        if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
10561            return;
10562        }
10563        let idx = self.state.bank_menu_index as i32;
10564        self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10565    }
10566
10567    pub fn storage_menu_move(&mut self, delta: i32) {
10568        let n = self.state.storage_menu_options().len();
10569        if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
10570            return;
10571        }
10572        let idx = self.state.storage_menu_index as i32;
10573        self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10574    }
10575
10576    pub fn storage_pick_move(&mut self, delta: i32) {
10577        let n = match &self.state.storage_ui_mode {
10578            StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
10579            StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
10580                self.state.storage_vault_options().len()
10581            }
10582            StorageUiMode::Menu
10583            | StorageUiMode::StoreAmount { .. }
10584            | StorageUiMode::TakeAmount { .. }
10585            | StorageUiMode::ShipAmount { .. } => 0,
10586        };
10587        if n == 0 {
10588            return;
10589        }
10590        match &mut self.state.storage_ui_mode {
10591            StorageUiMode::StorePick { index }
10592            | StorageUiMode::TakePick { index }
10593            | StorageUiMode::ShipPick { index, .. } => {
10594                *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10595            }
10596            StorageUiMode::Menu
10597            | StorageUiMode::StoreAmount { .. }
10598            | StorageUiMode::TakeAmount { .. }
10599            | StorageUiMode::ShipAmount { .. } => {}
10600        }
10601    }
10602
10603    pub fn storage_ui_back(&mut self) {
10604        self.state.storage_ui_mode = match &self.state.storage_ui_mode {
10605            StorageUiMode::StoreAmount { pick_index, .. } => {
10606                StorageUiMode::StorePick { index: *pick_index }
10607            }
10608            StorageUiMode::TakeAmount { pick_index, .. } => {
10609                StorageUiMode::TakePick { index: *pick_index }
10610            }
10611            StorageUiMode::ShipAmount {
10612                dest_building_id,
10613                dest_label,
10614                pick_index,
10615                ..
10616            } => StorageUiMode::ShipPick {
10617                dest_building_id: dest_building_id.clone(),
10618                dest_label: dest_label.clone(),
10619                index: *pick_index,
10620            },
10621            StorageUiMode::StorePick { .. }
10622            | StorageUiMode::TakePick { .. }
10623            | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
10624            StorageUiMode::Menu => StorageUiMode::Menu,
10625        };
10626    }
10627
10628    pub fn storage_amount_append_char(&mut self, c: char) {
10629        match &mut self.state.storage_ui_mode {
10630            StorageUiMode::StoreAmount { input, .. }
10631            | StorageUiMode::TakeAmount { input, .. }
10632            | StorageUiMode::ShipAmount { input, .. } => {
10633                if c.is_ascii_digit() && input.len() < 8 {
10634                    input.push(c);
10635                }
10636            }
10637            _ => {}
10638        }
10639    }
10640
10641    pub fn storage_amount_backspace(&mut self) {
10642        match &mut self.state.storage_ui_mode {
10643            StorageUiMode::StoreAmount { input, .. }
10644            | StorageUiMode::TakeAmount { input, .. }
10645            | StorageUiMode::ShipAmount { input, .. } => {
10646                input.pop();
10647            }
10648            _ => {}
10649        }
10650    }
10651
10652    pub fn storage_ui_typing(&self) -> bool {
10653        matches!(
10654            self.state.storage_ui_mode,
10655            StorageUiMode::StoreAmount { .. }
10656                | StorageUiMode::TakeAmount { .. }
10657                | StorageUiMode::ShipAmount { .. }
10658        )
10659    }
10660
10661    pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
10662        match self.state.storage_ui_mode.clone() {
10663            StorageUiMode::Menu => {
10664                let index = self.state.storage_menu_index;
10665                match index {
10666                    0 => {
10667                        let opts = self.state.storage_store_options();
10668                        if opts.is_empty() {
10669                            self.state.push_log("Nothing loose to store.");
10670                            return Ok(());
10671                        }
10672                        self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
10673                    }
10674                    1 => {
10675                        let opts = self.state.storage_vault_options();
10676                        if opts.is_empty() {
10677                            self.state.push_log("Vault is empty.");
10678                            return Ok(());
10679                        }
10680                        self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
10681                    }
10682                    n => {
10683                        let dest = self
10684                            .state
10685                            .storage_panel
10686                            .as_ref()
10687                            .and_then(|p| p.ship_destinations.get(n - 2))
10688                            .cloned();
10689                        let Some(dest) = dest else {
10690                            return Ok(());
10691                        };
10692                        let opts = self.state.storage_vault_options();
10693                        if opts.is_empty() {
10694                            self.state.push_log("Vault is empty — nothing to ship.");
10695                            return Ok(());
10696                        }
10697                        self.state.storage_ui_mode = StorageUiMode::ShipPick {
10698                            dest_building_id: dest.building_id,
10699                            dest_label: dest.label,
10700                            index: 0,
10701                        };
10702                    }
10703                }
10704            }
10705            StorageUiMode::StorePick { index } => {
10706                let opts = self.state.storage_store_options();
10707                let Some(opt) = opts.get(index) else {
10708                    self.state.push_log("Nothing loose to store.");
10709                    self.state.storage_ui_mode = StorageUiMode::Menu;
10710                    return Ok(());
10711                };
10712                self.state.storage_ui_mode = StorageUiMode::StoreAmount {
10713                    pick_index: index,
10714                    item_instance_id: opt.item_instance_id,
10715                    label: opt.label.clone(),
10716                    max_qty: opt.quantity.max(1),
10717                    input: String::new(),
10718                };
10719            }
10720            StorageUiMode::TakePick { index } => {
10721                let opts = self.state.storage_vault_options();
10722                let Some(opt) = opts.get(index) else {
10723                    self.state.push_log("Vault is empty.");
10724                    self.state.storage_ui_mode = StorageUiMode::Menu;
10725                    return Ok(());
10726                };
10727                self.state.storage_ui_mode = StorageUiMode::TakeAmount {
10728                    pick_index: index,
10729                    item_instance_id: opt.item_instance_id,
10730                    label: opt.label.clone(),
10731                    max_qty: opt.quantity.max(1),
10732                    input: String::new(),
10733                };
10734            }
10735            StorageUiMode::ShipPick {
10736                dest_building_id,
10737                dest_label,
10738                index,
10739            } => {
10740                let opts = self.state.storage_vault_options();
10741                let Some(opt) = opts.get(index) else {
10742                    self.state.push_log("Vault is empty — nothing to ship.");
10743                    self.state.storage_ui_mode = StorageUiMode::Menu;
10744                    return Ok(());
10745                };
10746                self.state.storage_ui_mode = StorageUiMode::ShipAmount {
10747                    dest_building_id,
10748                    dest_label,
10749                    pick_index: index,
10750                    item_instance_id: opt.item_instance_id,
10751                    label: opt.label.clone(),
10752                    max_qty: opt.quantity.max(1),
10753                    input: String::new(),
10754                };
10755            }
10756            StorageUiMode::StoreAmount {
10757                item_instance_id,
10758                max_qty,
10759                input,
10760                ..
10761            } => {
10762                let Some(qty) = parse_storage_quantity(&input) else {
10763                    self.state.push_log("Enter a quantity (blank or 0 = all).");
10764                    return Ok(());
10765                };
10766                let qty = qty.map(|n| n.min(max_qty).max(1));
10767                self.storage_store(item_instance_id, qty).await?;
10768                self.state.storage_ui_mode = StorageUiMode::Menu;
10769            }
10770            StorageUiMode::TakeAmount {
10771                item_instance_id,
10772                max_qty,
10773                input,
10774                ..
10775            } => {
10776                let Some(qty) = parse_storage_quantity(&input) else {
10777                    self.state.push_log("Enter a quantity (blank or 0 = all).");
10778                    return Ok(());
10779                };
10780                let qty = qty.map(|n| n.min(max_qty).max(1));
10781                self.storage_take(item_instance_id, qty).await?;
10782                self.state.storage_ui_mode = StorageUiMode::Menu;
10783            }
10784            StorageUiMode::ShipAmount {
10785                dest_building_id,
10786                item_instance_id,
10787                max_qty,
10788                input,
10789                ..
10790            } => {
10791                let Some(qty) = parse_storage_quantity(&input) else {
10792                    self.state.push_log("Enter a quantity (blank or 0 = all).");
10793                    return Ok(());
10794                };
10795                let qty = qty.map(|n| n.min(max_qty).max(1));
10796                self.storage_ship(dest_building_id, item_instance_id, qty)
10797                    .await?;
10798                self.state.storage_ui_mode = StorageUiMode::Menu;
10799            }
10800        }
10801        Ok(())
10802    }
10803
10804    pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
10805        match self.state.bank_ui_mode.clone() {
10806            BankUiMode::Menu => {
10807                let choice = self
10808                    .state
10809                    .bank_menu_options()
10810                    .get(self.state.bank_menu_index)
10811                    .copied()
10812                    .unwrap_or("Deposit…");
10813                match choice {
10814                    "Withdraw…" => {
10815                        self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
10816                            input: String::new(),
10817                        };
10818                    }
10819                    "Deposit all" => self.bank_deposit(0).await?,
10820                    "Withdraw all" => self.bank_withdraw(0).await?,
10821                    "Transfer…" => {
10822                        self.state.bank_ui_mode = BankUiMode::TransferName {
10823                            input: String::new(),
10824                        };
10825                    }
10826                    _ => {
10827                        self.state.bank_ui_mode = BankUiMode::DepositAmount {
10828                            input: String::new(),
10829                        };
10830                    }
10831                }
10832            }
10833            BankUiMode::DepositAmount { input } => {
10834                let Some(amount) = parse_bank_copper_amount(&input) else {
10835                    self.state
10836                        .push_log("Enter a copper amount (blank or 0 = everything on person).");
10837                    return Ok(());
10838                };
10839                self.bank_deposit(amount).await?;
10840                self.state.bank_ui_mode = BankUiMode::Menu;
10841            }
10842            BankUiMode::WithdrawAmount { input } => {
10843                let Some(amount) = parse_bank_copper_amount(&input) else {
10844                    self.state
10845                        .push_log("Enter a copper amount (blank or 0 = full ledger).");
10846                    return Ok(());
10847                };
10848                self.bank_withdraw(amount).await?;
10849                self.state.bank_ui_mode = BankUiMode::Menu;
10850            }
10851            BankUiMode::TransferName { input } => {
10852                let name = input.trim().to_string();
10853                if name.is_empty() {
10854                    self.state.push_log("Enter the recipient character name.");
10855                    return Ok(());
10856                }
10857                self.state.bank_ui_mode = BankUiMode::TransferAmount {
10858                    to_name: name,
10859                    input: String::new(),
10860                };
10861            }
10862            BankUiMode::TransferAmount { to_name, input } => {
10863                let amount: u64 = match input.trim().parse() {
10864                    Ok(v) if v > 0 => v,
10865                    _ => {
10866                        self.state
10867                            .push_log("Enter a positive copper amount to transfer.");
10868                        return Ok(());
10869                    }
10870                };
10871                self.bank_transfer(None, to_name, amount).await?;
10872                self.state.bank_ui_mode = BankUiMode::Menu;
10873            }
10874        }
10875        Ok(())
10876    }
10877
10878    pub fn bank_transfer_back(&mut self) {
10879        match &self.state.bank_ui_mode {
10880            BankUiMode::TransferAmount { to_name, .. } => {
10881                self.state.bank_ui_mode = BankUiMode::TransferName {
10882                    input: to_name.clone(),
10883                };
10884            }
10885            BankUiMode::TransferName { .. }
10886            | BankUiMode::DepositAmount { .. }
10887            | BankUiMode::WithdrawAmount { .. } => {
10888                self.state.bank_ui_mode = BankUiMode::Menu;
10889            }
10890            BankUiMode::Menu => {}
10891        }
10892    }
10893
10894    pub fn bank_transfer_append_char(&mut self, c: char) {
10895        match &mut self.state.bank_ui_mode {
10896            BankUiMode::TransferName { input } => {
10897                if input.len() < 32 && !c.is_control() {
10898                    input.push(c);
10899                }
10900            }
10901            BankUiMode::DepositAmount { input }
10902            | BankUiMode::WithdrawAmount { input }
10903            | BankUiMode::TransferAmount { input, .. } => {
10904                if c.is_ascii_digit() && input.len() < 12 {
10905                    input.push(c);
10906                }
10907            }
10908            BankUiMode::Menu => {}
10909        }
10910    }
10911
10912    pub fn bank_transfer_backspace(&mut self) {
10913        match &mut self.state.bank_ui_mode {
10914            BankUiMode::TransferName { input }
10915            | BankUiMode::DepositAmount { input }
10916            | BankUiMode::WithdrawAmount { input }
10917            | BankUiMode::TransferAmount { input, .. } => {
10918                input.pop();
10919            }
10920            BankUiMode::Menu => {}
10921        }
10922    }
10923
10924    pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
10925        let npc_id = self.state.bank_panel.as_ref().map(|p| p.npc_id.clone());
10926        self.state.clear_bank_panel();
10927        if let Some(npc_id) = npc_id {
10928            self.seq += 1;
10929            self.session
10930                .submit_intent(Intent::BankClose {
10931                    entity_id: self.state.entity_id,
10932                    npc_id,
10933                    seq: self.seq,
10934                })
10935                .await?;
10936            self.state.intents_sent += 1;
10937        }
10938        Ok(())
10939    }
10940
10941    pub async fn storage_store(
10942        &mut self,
10943        item_instance_id: uuid::Uuid,
10944        quantity: Option<u32>,
10945    ) -> anyhow::Result<()> {
10946        let Some(panel) = self.state.storage_panel.clone() else {
10947            return Ok(());
10948        };
10949        self.seq += 1;
10950        self.session
10951            .submit_intent(Intent::StorageStore {
10952                entity_id: self.state.entity_id,
10953                npc_id: panel.npc_id,
10954                item_instance_id,
10955                quantity,
10956                seq: self.seq,
10957            })
10958            .await?;
10959        self.state.intents_sent += 1;
10960        Ok(())
10961    }
10962
10963    pub async fn storage_take(
10964        &mut self,
10965        item_instance_id: uuid::Uuid,
10966        quantity: Option<u32>,
10967    ) -> anyhow::Result<()> {
10968        let Some(panel) = self.state.storage_panel.clone() else {
10969            return Ok(());
10970        };
10971        self.seq += 1;
10972        self.session
10973            .submit_intent(Intent::StorageTake {
10974                entity_id: self.state.entity_id,
10975                npc_id: panel.npc_id,
10976                item_instance_id,
10977                quantity,
10978                seq: self.seq,
10979            })
10980            .await?;
10981        self.state.intents_sent += 1;
10982        Ok(())
10983    }
10984
10985    pub async fn storage_ship(
10986        &mut self,
10987        dest_building_id: String,
10988        item_instance_id: uuid::Uuid,
10989        quantity: Option<u32>,
10990    ) -> anyhow::Result<()> {
10991        let Some(panel) = self.state.storage_panel.clone() else {
10992            return Ok(());
10993        };
10994        self.seq += 1;
10995        self.session
10996            .submit_intent(Intent::StorageShip {
10997                entity_id: self.state.entity_id,
10998                npc_id: panel.npc_id,
10999                dest_building_id,
11000                item_instance_id,
11001                quantity,
11002                seq: self.seq,
11003            })
11004            .await?;
11005        self.state.intents_sent += 1;
11006        Ok(())
11007    }
11008
11009    pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
11010        let npc_id = self.state.storage_panel.as_ref().map(|p| p.npc_id.clone());
11011        self.state.clear_storage_panel();
11012        if let Some(npc_id) = npc_id {
11013            self.seq += 1;
11014            self.session
11015                .submit_intent(Intent::StorageClose {
11016                    entity_id: self.state.entity_id,
11017                    npc_id,
11018                    seq: self.seq,
11019                })
11020                .await?;
11021            self.state.intents_sent += 1;
11022        }
11023        Ok(())
11024    }
11025
11026    pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
11027        let npc_id = self.state.market_panel.as_ref().map(|p| p.npc_id.clone());
11028        self.state.clear_market_panel();
11029        if let Some(npc_id) = npc_id {
11030            self.seq += 1;
11031            self.session
11032                .submit_intent(Intent::MarketClose {
11033                    entity_id: self.state.entity_id,
11034                    npc_id,
11035                    seq: self.seq,
11036                })
11037                .await?;
11038            self.state.intents_sent += 1;
11039        }
11040        Ok(())
11041    }
11042
11043    pub fn market_move_selection(&mut self, delta: i32) {
11044        let indices = self.state.market_filtered_listing_indices();
11045        let n = indices.len();
11046        if n == 0 {
11047            self.state.market_menu_index = 0;
11048            return;
11049        }
11050        let cur = self.state.market_menu_index as i32;
11051        self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
11052    }
11053
11054    pub fn market_page_selection(&mut self, pages: i32) {
11055        let indices = self.state.market_filtered_listing_indices();
11056        let n = indices.len();
11057        if n == 0 {
11058            self.state.market_menu_index = 0;
11059            return;
11060        }
11061        self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
11062    }
11063
11064    pub fn market_list_page(&mut self, pages: i32) {
11065        match &self.state.market_ui_mode {
11066            MarketUiMode::ListSource { index } => {
11067                let n = self.state.market_list_source_options().len();
11068                if n == 0 {
11069                    return;
11070                }
11071                let next = page_list_index(*index, pages, n);
11072                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
11073            }
11074            MarketUiMode::ListPricingMode { index, .. } => {
11075                let next = page_list_index(*index, pages, 2);
11076                if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
11077                {
11078                    *index = next;
11079                }
11080            }
11081            MarketUiMode::ListPick { source, index } => {
11082                let opts = self.state.market_list_item_options(source);
11083                let n = opts.len();
11084                if n == 0 {
11085                    return;
11086                }
11087                let next = page_list_index(*index, pages, n);
11088                self.state.market_ui_mode = MarketUiMode::ListPick {
11089                    source: source.clone(),
11090                    index: next,
11091                };
11092            }
11093            _ => {}
11094        }
11095    }
11096
11097    pub fn market_cycle_category(&mut self, delta: i32) {
11098        let groups = self.state.market_available_category_groups();
11099        // All + groups
11100        let mut labels: Vec<Option<&'static str>> = vec![None];
11101        labels.extend(groups.into_iter().map(Some));
11102        let n = labels.len() as i32;
11103        let cur = labels
11104            .iter()
11105            .position(|g| *g == self.state.market_category_filter)
11106            .unwrap_or(0) as i32;
11107        let next = (cur + delta).rem_euclid(n) as usize;
11108        self.state.market_category_filter = labels[next];
11109        self.state.market_menu_index = 0;
11110        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
11111            let source = source.clone();
11112            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11113        }
11114    }
11115
11116    pub fn focus_market_filter(&mut self) {
11117        self.state.market_filter_focused = true;
11118    }
11119
11120    pub fn append_market_filter_char(&mut self, ch: char) {
11121        if !self.state.market_filter_focused {
11122            return;
11123        }
11124        if !is_list_filter_char(ch) {
11125            return;
11126        }
11127        if self.state.market_filter.len() < 48 {
11128            self.state.market_filter.push(ch);
11129            self.state.market_menu_index = 0;
11130            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
11131                let source = source.clone();
11132                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11133            }
11134        }
11135    }
11136
11137    pub fn market_filter_backspace(&mut self) {
11138        if !self.state.market_filter_focused {
11139            return;
11140        }
11141        self.state.market_filter.pop();
11142        self.state.market_menu_index = 0;
11143        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
11144            let source = source.clone();
11145            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11146        }
11147    }
11148
11149    /// Clears filter text, blurs search, or returns false if already idle.
11150    pub fn clear_or_blur_market_filter(&mut self) -> bool {
11151        if self.state.market_filter_focused {
11152            if !self.state.market_filter.is_empty() {
11153                self.state.market_filter.clear();
11154                self.state.market_menu_index = 0;
11155                if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
11156                    let source = source.clone();
11157                    self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11158                }
11159                return true;
11160            }
11161            self.state.market_filter_focused = false;
11162            return true;
11163        }
11164        if !self.state.market_filter.is_empty() {
11165            self.state.market_filter.clear();
11166            self.state.market_menu_index = 0;
11167            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
11168                let source = source.clone();
11169                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11170            }
11171            return true;
11172        }
11173        false
11174    }
11175
11176    pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
11177        if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
11178            return self.market_confirm_buy(listing_id, qty).await;
11179        }
11180        let Some(panel) = self.state.market_panel.clone() else {
11181            return Ok(());
11182        };
11183        let indices = self.state.market_filtered_listing_indices();
11184        let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
11185            return Ok(());
11186        };
11187        let Some(listing) = panel.listings.get(raw_idx) else {
11188            return Ok(());
11189        };
11190        if listing.mine {
11191            self.seq += 1;
11192            self.session
11193                .submit_intent(Intent::MarketDelist {
11194                    entity_id: self.state.entity_id,
11195                    npc_id: panel.npc_id.clone(),
11196                    listing_id: listing.listing_id,
11197                    dest: flatland_protocol::GoodsLocation::Person,
11198                    seq: self.seq,
11199                })
11200                .await?;
11201            self.state.intents_sent += 1;
11202            return Ok(());
11203        }
11204        if listing.npc_price {
11205            self.state
11206                .push_log("NPC-price listings are bought by merchants only.");
11207            return Ok(());
11208        }
11209        let qty = 1u32.min(listing.quantity).max(1);
11210        let line = listing.unit_price_copper.saturating_mul(qty as u64);
11211        self.state.market_buy_confirm = Some((
11212            listing.listing_id,
11213            qty,
11214            listing.unit_price_copper,
11215            line,
11216            listing.display_name.clone(),
11217        ));
11218        Ok(())
11219    }
11220
11221    pub async fn market_confirm_buy(
11222        &mut self,
11223        listing_id: uuid::Uuid,
11224        quantity: u32,
11225    ) -> anyhow::Result<()> {
11226        let Some(panel) = self.state.market_panel.clone() else {
11227            self.state.market_buy_confirm = None;
11228            return Ok(());
11229        };
11230        self.state.market_buy_confirm = None;
11231        self.seq += 1;
11232        self.session
11233            .submit_intent(Intent::MarketBuy {
11234                entity_id: self.state.entity_id,
11235                npc_id: panel.npc_id,
11236                listing_id,
11237                quantity,
11238                dest: flatland_protocol::GoodsLocation::Person,
11239                seq: self.seq,
11240            })
11241            .await?;
11242        self.state.intents_sent += 1;
11243        Ok(())
11244    }
11245
11246    /// Begin the list wizard (`l` in market browse).
11247    pub fn market_begin_list(&mut self) {
11248        if self.state.market_panel.is_none() {
11249            return;
11250        }
11251        let sources = self.state.market_list_source_options();
11252        if sources.is_empty() {
11253            self.state.push_log("Nothing to list from.");
11254            return;
11255        }
11256        // One source (person only) → skip straight to item pick when it has stacks.
11257        if sources.len() == 1 {
11258            let (source, _) = sources[0].clone();
11259            let opts = self.state.market_list_item_options(&source);
11260            if opts.is_empty() {
11261                self.state.push_log("Nothing loose to list.");
11262                return;
11263            }
11264            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11265            self.state.market_buy_confirm = None;
11266            return;
11267        }
11268        self.state.market_buy_confirm = None;
11269        self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
11270    }
11271
11272    pub fn market_ui_back(&mut self) {
11273        self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
11274            MarketUiMode::Browse => MarketUiMode::Browse,
11275            MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
11276            MarketUiMode::ListPick { .. } => {
11277                if self.state.market_list_source_options().len() <= 1 {
11278                    MarketUiMode::Browse
11279                } else {
11280                    MarketUiMode::ListSource { index: 0 }
11281                }
11282            }
11283            MarketUiMode::ListAmount {
11284                source, pick_index, ..
11285            } => MarketUiMode::ListPick {
11286                source,
11287                index: pick_index,
11288            },
11289            MarketUiMode::ListPricingMode {
11290                source,
11291                item_instance_id,
11292                template_id,
11293                label,
11294                max_qty,
11295                quantity,
11296                pick_index,
11297                ..
11298            } => {
11299                let input = quantity.map(|q| q.to_string()).unwrap_or_default();
11300                MarketUiMode::ListAmount {
11301                    source,
11302                    pick_index,
11303                    item_instance_id,
11304                    template_id,
11305                    label,
11306                    max_qty,
11307                    input,
11308                }
11309            }
11310            MarketUiMode::ListPrice {
11311                source,
11312                pick_index,
11313                item_instance_id,
11314                template_id,
11315                label,
11316                max_qty,
11317                quantity,
11318                ..
11319            } => MarketUiMode::ListPricingMode {
11320                source,
11321                pick_index,
11322                item_instance_id,
11323                template_id,
11324                label,
11325                quantity,
11326                max_qty,
11327                index: 1,
11328            },
11329        };
11330    }
11331
11332    pub fn market_list_move(&mut self, delta: i32) {
11333        match &self.state.market_ui_mode {
11334            MarketUiMode::ListSource { index } => {
11335                let n = self.state.market_list_source_options().len();
11336                if n == 0 {
11337                    return;
11338                }
11339                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
11340                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
11341            }
11342            MarketUiMode::ListPricingMode { index, .. } => {
11343                let next = (*index as i32 + delta).rem_euclid(2) as usize;
11344                if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
11345                {
11346                    *index = next;
11347                }
11348            }
11349            MarketUiMode::ListPick { source, index } => {
11350                let opts = self.state.market_list_item_options(source);
11351                let n = opts.len();
11352                if n == 0 {
11353                    return;
11354                }
11355                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
11356                self.state.market_ui_mode = MarketUiMode::ListPick {
11357                    source: source.clone(),
11358                    index: next,
11359                };
11360            }
11361            _ => {}
11362        }
11363    }
11364
11365    pub fn market_list_amount_append_char(&mut self, c: char) {
11366        if !c.is_ascii_digit() {
11367            return;
11368        }
11369        match &mut self.state.market_ui_mode {
11370            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
11371                if input.len() < 12 {
11372                    input.push(c);
11373                }
11374            }
11375            _ => {}
11376        }
11377    }
11378
11379    pub fn market_list_amount_backspace(&mut self) {
11380        match &mut self.state.market_ui_mode {
11381            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
11382                input.pop();
11383            }
11384            _ => {}
11385        }
11386    }
11387
11388    pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
11389        match self.state.market_ui_mode.clone() {
11390            MarketUiMode::Browse => Ok(()),
11391            MarketUiMode::ListSource { index } => {
11392                let sources = self.state.market_list_source_options();
11393                let Some((source, _)) = sources.get(index).cloned() else {
11394                    return Ok(());
11395                };
11396                let opts = self.state.market_list_item_options(&source);
11397                if opts.is_empty() {
11398                    self.state.push_log("Nothing to list from that source.");
11399                    return Ok(());
11400                }
11401                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11402                Ok(())
11403            }
11404            MarketUiMode::ListPick { source, index } => {
11405                let opts = self.state.market_list_item_options(&source);
11406                let Some(opt) = opts.get(index) else {
11407                    self.state.push_log("Nothing to list.");
11408                    self.state.market_ui_mode = MarketUiMode::Browse;
11409                    return Ok(());
11410                };
11411                self.state.market_ui_mode = MarketUiMode::ListAmount {
11412                    source,
11413                    pick_index: index,
11414                    item_instance_id: opt.item_instance_id,
11415                    template_id: opt.template_id.clone(),
11416                    label: opt.label.clone(),
11417                    max_qty: opt.quantity.max(1),
11418                    input: String::new(),
11419                };
11420                Ok(())
11421            }
11422            MarketUiMode::ListAmount {
11423                source,
11424                pick_index,
11425                item_instance_id,
11426                template_id,
11427                label,
11428                max_qty,
11429                input,
11430                ..
11431            } => {
11432                let Some(qty_opt) = parse_storage_quantity(&input) else {
11433                    self.state.push_log("Enter a quantity (blank = all).");
11434                    return Ok(());
11435                };
11436                if let Some(q) = qty_opt {
11437                    if q > max_qty {
11438                        self.state.push_log(format!("Only {max_qty} available."));
11439                        return Ok(());
11440                    }
11441                }
11442                self.state.market_ui_mode = MarketUiMode::ListPricingMode {
11443                    source,
11444                    pick_index,
11445                    item_instance_id,
11446                    template_id,
11447                    label,
11448                    quantity: qty_opt,
11449                    max_qty,
11450                    index: 0,
11451                };
11452                Ok(())
11453            }
11454            MarketUiMode::ListPricingMode {
11455                source,
11456                pick_index,
11457                item_instance_id,
11458                template_id,
11459                label,
11460                quantity,
11461                max_qty,
11462                index,
11463            } => {
11464                if index == 0 {
11465                    if self
11466                        .state
11467                        .npc_market_dump_unit_estimate(&template_id)
11468                        .is_none()
11469                    {
11470                        self.state
11471                            .push_log("That item has no NPC value — use a fixed price instead.");
11472                        return Ok(());
11473                    }
11474                    return self
11475                        .submit_market_list_intent(
11476                            source,
11477                            item_instance_id,
11478                            quantity,
11479                            0,
11480                            true,
11481                            &label,
11482                        )
11483                        .await;
11484                }
11485                self.state.market_ui_mode = MarketUiMode::ListPrice {
11486                    source,
11487                    pick_index,
11488                    item_instance_id,
11489                    template_id,
11490                    label,
11491                    quantity,
11492                    max_qty,
11493                    input: String::new(),
11494                };
11495                Ok(())
11496            }
11497            MarketUiMode::ListPrice {
11498                source,
11499                item_instance_id,
11500                label,
11501                quantity,
11502                input,
11503                ..
11504            } => {
11505                let price = input.trim().parse::<u64>().unwrap_or(0);
11506                if price == 0 {
11507                    self.state
11508                        .push_log("Enter a unit price of at least 1 copper.");
11509                    return Ok(());
11510                }
11511                self.submit_market_list_intent(
11512                    source,
11513                    item_instance_id,
11514                    quantity,
11515                    price,
11516                    false,
11517                    &label,
11518                )
11519                .await
11520            }
11521        }
11522    }
11523
11524    async fn submit_market_list_intent(
11525        &mut self,
11526        source: MarketListSourceKind,
11527        item_instance_id: uuid::Uuid,
11528        quantity: Option<u32>,
11529        unit_price_copper: u64,
11530        npc_price: bool,
11531        label: &str,
11532    ) -> anyhow::Result<()> {
11533        let Some(panel) = self.state.market_panel.clone() else {
11534            self.state.market_ui_mode = MarketUiMode::Browse;
11535            return Ok(());
11536        };
11537        let goods = match source {
11538            MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
11539            MarketListSourceKind::TownStorage { building_id } => {
11540                flatland_protocol::GoodsLocation::TownStorage { building_id }
11541            }
11542        };
11543        self.seq += 1;
11544        self.session
11545            .submit_intent(Intent::MarketList {
11546                entity_id: self.state.entity_id,
11547                npc_id: panel.npc_id,
11548                source: goods,
11549                item_instance_id,
11550                quantity,
11551                unit_price_copper,
11552                npc_price,
11553                seq: self.seq,
11554            })
11555            .await?;
11556        self.state.intents_sent += 1;
11557        if npc_price {
11558            self.state
11559                .push_log(format!("Listing {label} at NPC price…"));
11560        } else {
11561            self.state
11562                .push_log(format!("Listing {label} @ {unit_price_copper} cp…"));
11563        }
11564        self.state.market_ui_mode = MarketUiMode::Browse;
11565        Ok(())
11566    }
11567
11568    /// Close shop and return to the Talk/Trade verb menu when inside an NPC session.
11569    pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
11570        let return_to_verbs = self.state.npc_verb_target.is_some();
11571        self.close_shop_menu().await?;
11572        if return_to_verbs {
11573            self.state.show_npc_verb_menu = true;
11574            self.state.npc_verb_notice = None;
11575        }
11576        Ok(())
11577    }
11578
11579    pub fn shop_tab_toggle(&mut self) {
11580        self.state.shop_tab = match self.state.shop_tab {
11581            ShopTab::Buy => ShopTab::Sell,
11582            ShopTab::Sell => ShopTab::Buy,
11583        };
11584        self.state.shop_menu_index = 0;
11585        if self.state.shop_tab == ShopTab::Sell {
11586            self.state.shop_quantity_set_max();
11587        }
11588        self.state.clamp_shop_selection();
11589    }
11590
11591    pub fn shop_menu_move(&mut self, delta: i32) {
11592        self.state.shop_menu_move(delta);
11593    }
11594
11595    pub fn shop_quantity_adjust(&mut self, delta: i32) {
11596        self.state.shop_quantity_adjust(delta);
11597    }
11598
11599    pub fn shop_quantity_set_max(&mut self) {
11600        self.state.shop_quantity_set_max();
11601    }
11602
11603    pub fn shop_quantity_set_min(&mut self) {
11604        self.state.shop_quantity_set_min();
11605    }
11606
11607    pub fn toggle_quest_menu(&mut self) {
11608        self.state.show_quest_menu = !self.state.show_quest_menu;
11609        if self.state.show_quest_menu {
11610            self.state.quest_menu_index = 0;
11611            self.state.quest_withdraw_confirm = false;
11612            self.state.show_workers_menu = false;
11613        }
11614    }
11615
11616    pub fn toggle_workers_menu(&mut self) {
11617        if self.state.show_workers_menu {
11618            self.close_workers_menu_ui();
11619        } else {
11620            self.state.show_workers_menu = true;
11621            self.state.social_chat.picking_stone = false;
11622            // Keep prior selection/scroll focus; only clamp if the roster shrank.
11623            if self.state.workers_menu_index >= self.state.hired_workers.len() {
11624                self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11625            }
11626            self.state.show_quest_menu = false;
11627            self.close_worker_give_picker();
11628            self.close_worker_give_target_picker();
11629            self.close_worker_take_picker();
11630            self.close_worker_teach_picker();
11631            self.cancel_worker_rename();
11632        }
11633    }
11634
11635    /// Close workers UI layers without network (caller should release attend).
11636    pub fn close_workers_menu_ui(&mut self) {
11637        self.state.show_workers_menu = false;
11638        self.cancel_worker_dismissal();
11639        self.close_worker_give_picker();
11640        self.close_worker_give_target_picker();
11641        self.close_worker_take_picker();
11642        self.close_worker_teach_picker();
11643        self.cancel_worker_rename();
11644    }
11645
11646    /// Open the workers menu focused on `instance_id` and pause that worker's job.
11647    pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
11648        let Some(idx) = self
11649            .state
11650            .hired_workers
11651            .iter()
11652            .position(|w| w.instance_id == instance_id)
11653        else {
11654            anyhow::bail!("worker not found");
11655        };
11656        let label = self.state.hired_workers[idx].label.clone();
11657        self.state.show_workers_menu = true;
11658        self.state.social_chat.picking_stone = false;
11659        self.state.workers_menu_index = idx;
11660        self.state.show_quest_menu = false;
11661        self.close_worker_give_picker();
11662        self.close_worker_give_target_picker();
11663        self.close_worker_take_picker();
11664        self.close_worker_teach_picker();
11665        self.cancel_worker_rename();
11666        self.set_worker_attending(instance_id, true).await?;
11667        self.state
11668            .push_log(format!("Managing {label} — job paused while menu is open"));
11669        Ok(())
11670    }
11671
11672    /// Close workers menu and release any attend pause.
11673    pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
11674        self.close_workers_menu_ui();
11675        self.release_worker_attend().await
11676    }
11677
11678    async fn set_worker_attending(
11679        &mut self,
11680        instance_id: &str,
11681        attending: bool,
11682    ) -> anyhow::Result<()> {
11683        if attending {
11684            if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11685                return Ok(());
11686            }
11687            // Switch target: release previous first.
11688            if let Some(prev) = self.state.attending_worker_instance_id.clone() {
11689                if prev != instance_id {
11690                    self.send_attend_hired_worker(&prev, false).await?;
11691                }
11692            }
11693            self.send_attend_hired_worker(instance_id, true).await?;
11694            self.state.attending_worker_instance_id = Some(instance_id.to_string());
11695        } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11696            self.send_attend_hired_worker(instance_id, false).await?;
11697            self.state.attending_worker_instance_id = None;
11698        }
11699        Ok(())
11700    }
11701
11702    pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
11703        let Some(id) = self.state.attending_worker_instance_id.take() else {
11704            return Ok(());
11705        };
11706        self.send_attend_hired_worker(&id, false).await
11707    }
11708
11709    async fn send_attend_hired_worker(
11710        &mut self,
11711        worker_instance_id: &str,
11712        attending: bool,
11713    ) -> anyhow::Result<()> {
11714        self.seq += 1;
11715        self.session
11716            .submit_intent(Intent::AttendHiredWorker {
11717                entity_id: self.state.entity_id,
11718                worker_instance_id: worker_instance_id.to_string(),
11719                attending,
11720                seq: self.seq,
11721            })
11722            .await?;
11723        self.state.intents_sent += 1;
11724        Ok(())
11725    }
11726
11727    pub fn workers_menu_move(&mut self, delta: i32) {
11728        let n = self.state.hired_workers.len();
11729        if n == 0 {
11730            return;
11731        }
11732        let idx = self.state.workers_menu_index as i32;
11733        self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
11734    }
11735
11736    pub fn toggle_workers_menu_compact(&mut self) {
11737        self.state.workers_menu_compact = !self.state.workers_menu_compact;
11738        let mut cfg = crate::client_config::ClientConfig::load();
11739        let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
11740    }
11741
11742    pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
11743        let Some(worker) = self
11744            .state
11745            .hired_workers
11746            .get(self.state.workers_menu_index)
11747            .cloned()
11748        else {
11749            anyhow::bail!("no worker selected");
11750        };
11751        self.dismiss_worker_by_id(&worker.instance_id, &worker.label)
11752            .await
11753    }
11754
11755    /// Open a confirmation dialog before permanently dismissing the selected worker.
11756    pub fn request_worker_dismissal(&mut self) -> anyhow::Result<()> {
11757        let Some(worker) = self
11758            .state
11759            .hired_workers
11760            .get(self.state.workers_menu_index)
11761            .cloned()
11762        else {
11763            anyhow::bail!("no worker selected");
11764        };
11765        self.state.worker_dismiss_confirmation = Some(WorkerDismissConfirmation {
11766            worker_instance_id: worker.instance_id,
11767            worker_label: worker.label,
11768        });
11769        Ok(())
11770    }
11771
11772    pub fn cancel_worker_dismissal(&mut self) {
11773        self.state.worker_dismiss_confirmation = None;
11774    }
11775
11776    pub async fn confirm_worker_dismissal(&mut self) -> anyhow::Result<()> {
11777        let Some(confirm) = self.state.worker_dismiss_confirmation.clone() else {
11778            return Ok(());
11779        };
11780        self.dismiss_worker_by_id(&confirm.worker_instance_id, &confirm.worker_label)
11781            .await?;
11782        self.cancel_worker_dismissal();
11783        Ok(())
11784    }
11785
11786    async fn dismiss_worker_by_id(
11787        &mut self,
11788        worker_instance_id: &str,
11789        worker_label: &str,
11790    ) -> anyhow::Result<()> {
11791        self.seq += 1;
11792        self.session
11793            .submit_intent(Intent::DismissWorker {
11794                entity_id: self.state.entity_id,
11795                worker_instance_id: worker_instance_id.to_string(),
11796                seq: self.seq,
11797            })
11798            .await?;
11799        self.state.intents_sent += 1;
11800        self.state
11801            .hired_workers
11802            .retain(|w| w.instance_id != worker_instance_id);
11803        if self.state.workers_menu_index >= self.state.hired_workers.len() {
11804            self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11805        }
11806        self.state.push_log(format!("Dismissed {worker_label}"));
11807        Ok(())
11808    }
11809
11810    pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
11811        let Some(worker) = self
11812            .state
11813            .hired_workers
11814            .get(self.state.workers_menu_index)
11815            .cloned()
11816        else {
11817            anyhow::bail!("no worker selected");
11818        };
11819        let mode = match worker.mode {
11820            flatland_protocol::WorkerModeView::Companion => "defender",
11821            flatland_protocol::WorkerModeView::Defender => "job_loop",
11822            flatland_protocol::WorkerModeView::JobLoop => "idle",
11823            flatland_protocol::WorkerModeView::Idle => "companion",
11824        };
11825        self.seq += 1;
11826        self.session
11827            .submit_intent(Intent::SetWorkerMode {
11828                entity_id: self.state.entity_id,
11829                worker_instance_id: worker.instance_id,
11830                mode: mode.into(),
11831                seq: self.seq,
11832            })
11833            .await?;
11834        self.state.intents_sent += 1;
11835        Ok(())
11836    }
11837
11838    pub async fn workers_deliver_selected_to_storage(&mut self) -> anyhow::Result<()> {
11839        let Some(worker) = self
11840            .state
11841            .hired_workers
11842            .get(self.state.workers_menu_index)
11843            .cloned()
11844        else {
11845            anyhow::bail!("no worker selected");
11846        };
11847        if !matches!(worker.mode, flatland_protocol::WorkerModeView::Companion) {
11848            anyhow::bail!("switch the worker to companion mode first");
11849        }
11850        if worker.step_label.starts_with("delivering to ")
11851            || worker.step_label == "returning to you"
11852        {
11853            anyhow::bail!("worker is already delivering to storage");
11854        }
11855        self.seq += 1;
11856        self.session
11857            .submit_intent(Intent::DeliverWorkerToNearestStorage {
11858                entity_id: self.state.entity_id,
11859                worker_instance_id: worker.instance_id.clone(),
11860                seq: self.seq,
11861            })
11862            .await?;
11863        self.state.intents_sent += 1;
11864        self.state.push_log(format!(
11865            "{} is delivering carried items to storage",
11866            worker.label
11867        ));
11868        Ok(())
11869    }
11870
11871    pub async fn workers_cancel_delivery_selected(&mut self) -> anyhow::Result<()> {
11872        let Some(worker) = self
11873            .state
11874            .hired_workers
11875            .get(self.state.workers_menu_index)
11876            .cloned()
11877        else {
11878            anyhow::bail!("no worker selected");
11879        };
11880        if !(worker.step_label.starts_with("delivering to ")
11881            || worker.step_label == "returning to you")
11882        {
11883            anyhow::bail!("worker has no active delivery");
11884        }
11885        self.seq += 1;
11886        self.session
11887            .submit_intent(Intent::CancelWorkerDelivery {
11888                entity_id: self.state.entity_id,
11889                worker_instance_id: worker.instance_id,
11890                seq: self.seq,
11891            })
11892            .await?;
11893        self.state.intents_sent += 1;
11894        self.state
11895            .push_log(format!("Canceled delivery for {}", worker.label));
11896        Ok(())
11897    }
11898
11899    pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
11900        if self.state.hired_workers.is_empty() {
11901            return self.hire_worker_laborer().await;
11902        }
11903        self.workers_toggle_mode_selected().await
11904    }
11905
11906    /// Open a nearby-worker picker for the selected inventory stack (inventory `g`).
11907    /// Always shows a chooser so you can pick Bruce vs Cookie when several are close.
11908    pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11909        let row = self
11910            .state
11911            .inventory_selected_row()
11912            .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
11913            .clone();
11914        if row.from != flatland_protocol::InventoryLocation::Root {
11915            anyhow::bail!("select a carried item to give");
11916        }
11917        let Some(instance_id) = row.stack.item_instance_id else {
11918            anyhow::bail!("that stack can't be given");
11919        };
11920        let options = self.nearby_worker_give_targets();
11921        if options.is_empty() {
11922            anyhow::bail!(
11923                "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
11924            );
11925        }
11926        let item_label = row
11927            .stack
11928            .display_name
11929            .as_deref()
11930            .unwrap_or(&row.stack.template_id)
11931            .to_string();
11932        self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
11933            item_instance_id: instance_id,
11934            item_label,
11935            quantity: None,
11936            options,
11937        });
11938        self.state.worker_give_target_picker_index = 0;
11939        self.state.show_worker_give_target_picker = true;
11940        // Let the target picker own keys (inventory would otherwise swallow ↑↓/Enter).
11941        self.state.show_inventory_menu = false;
11942        Ok(())
11943    }
11944
11945    /// Hired workers within give/take range, nearest first.
11946    pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
11947        let (px, py, _) = self.state.player_position_with_z();
11948        let mut options: Vec<WorkerGiveTargetOption> = self
11949            .state
11950            .hired_workers
11951            .iter()
11952            .filter_map(|w| {
11953                let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
11954                if dist > WORKER_GIVE_RANGE_M {
11955                    return None;
11956                }
11957                Some(WorkerGiveTargetOption {
11958                    instance_id: w.instance_id.clone(),
11959                    label: w.label.clone(),
11960                    distance_m: dist,
11961                })
11962            })
11963            .collect();
11964        options.sort_by(|a, b| {
11965            a.distance_m
11966                .partial_cmp(&b.distance_m)
11967                .unwrap_or(std::cmp::Ordering::Equal)
11968        });
11969        options
11970    }
11971
11972    pub fn close_worker_give_target_picker(&mut self) {
11973        self.state.show_worker_give_target_picker = false;
11974        self.state.worker_give_target_picker = None;
11975        self.state.worker_give_target_picker_index = 0;
11976    }
11977
11978    pub fn worker_give_target_picker_move(&mut self, delta: i32) {
11979        let Some(picker) = &self.state.worker_give_target_picker else {
11980            return;
11981        };
11982        let n = picker.options.len();
11983        if n == 0 {
11984            return;
11985        }
11986        let idx = self.state.worker_give_target_picker_index as i32;
11987        self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11988    }
11989
11990    pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11991        let Some(picker) = self.state.worker_give_target_picker.clone() else {
11992            anyhow::bail!("give target picker not open");
11993        };
11994        let Some(opt) = picker
11995            .options
11996            .get(self.state.worker_give_target_picker_index)
11997            .cloned()
11998        else {
11999            anyhow::bail!("no worker selected");
12000        };
12001        let Some(worker) = self
12002            .state
12003            .hired_workers
12004            .iter()
12005            .find(|w| w.instance_id == opt.instance_id)
12006            .cloned()
12007        else {
12008            self.close_worker_give_target_picker();
12009            anyhow::bail!("worker no longer hired");
12010        };
12011        self.give_item_to_worker(
12012            &worker.instance_id,
12013            &worker.label,
12014            worker.x,
12015            worker.y,
12016            picker.item_instance_id,
12017            &picker.item_label,
12018            picker.quantity,
12019        )
12020        .await?;
12021        self.close_worker_give_target_picker();
12022        Ok(())
12023    }
12024
12025    /// Give the selected inventory stack to a hired worker (opens nearby-worker picker).
12026    pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
12027        self.open_worker_give_target_picker()
12028    }
12029
12030    /// Open the workers-menu give picker for the selected worker (must be in range).
12031    pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
12032        let Some(worker) = self
12033            .state
12034            .hired_workers
12035            .get(self.state.workers_menu_index)
12036            .cloned()
12037        else {
12038            anyhow::bail!("select a hired worker first");
12039        };
12040        let (px, py, _) = self.state.player_position_with_z();
12041        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
12042        if dist > WORKER_GIVE_RANGE_M {
12043            anyhow::bail!(
12044                "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
12045                worker.label
12046            );
12047        }
12048        let options = self.state.giveable_inventory_options();
12049        if options.is_empty() {
12050            anyhow::bail!("nothing in inventory to give");
12051        }
12052        self.state.worker_give_picker = Some(WorkerGivePicker {
12053            worker_instance_id: worker.instance_id,
12054            worker_label: worker.label,
12055            options,
12056        });
12057        self.state.worker_give_picker_index = 0;
12058        self.state.show_worker_give_picker = true;
12059        Ok(())
12060    }
12061
12062    pub fn close_worker_give_picker(&mut self) {
12063        self.state.show_worker_give_picker = false;
12064        self.state.worker_give_picker = None;
12065        self.state.worker_give_picker_index = 0;
12066    }
12067
12068    pub fn worker_give_picker_move(&mut self, delta: i32) {
12069        let Some(picker) = &self.state.worker_give_picker else {
12070            return;
12071        };
12072        let n = picker.options.len();
12073        if n == 0 {
12074            return;
12075        }
12076        let idx = self.state.worker_give_picker_index as i32;
12077        self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
12078    }
12079
12080    /// Confirm the selected row in the workers-menu give picker.
12081    pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
12082        let Some(picker) = self.state.worker_give_picker.clone() else {
12083            anyhow::bail!("give picker not open");
12084        };
12085        let Some(opt) = picker
12086            .options
12087            .get(self.state.worker_give_picker_index)
12088            .cloned()
12089        else {
12090            anyhow::bail!("no item selected");
12091        };
12092        let Some(worker) = self
12093            .state
12094            .hired_workers
12095            .iter()
12096            .find(|w| w.instance_id == picker.worker_instance_id)
12097            .cloned()
12098        else {
12099            self.close_worker_give_picker();
12100            anyhow::bail!("worker no longer hired");
12101        };
12102        self.give_item_to_worker(
12103            &worker.instance_id,
12104            &worker.label,
12105            worker.x,
12106            worker.y,
12107            opt.item_instance_id,
12108            &opt.label,
12109            None,
12110        )
12111        .await?;
12112        // Refresh options (stack may be gone / reduced) or close when empty.
12113        let options = self.state.giveable_inventory_options();
12114        if options.is_empty() {
12115            self.close_worker_give_picker();
12116        } else {
12117            self.state.worker_give_picker = Some(WorkerGivePicker {
12118                worker_instance_id: picker.worker_instance_id,
12119                worker_label: picker.worker_label,
12120                options,
12121            });
12122            if self.state.worker_give_picker_index
12123                >= self
12124                    .state
12125                    .worker_give_picker
12126                    .as_ref()
12127                    .map(|p| p.options.len())
12128                    .unwrap_or(0)
12129            {
12130                self.state.worker_give_picker_index = self
12131                    .state
12132                    .worker_give_picker
12133                    .as_ref()
12134                    .map(|p| p.options.len().saturating_sub(1))
12135                    .unwrap_or(0);
12136            }
12137        }
12138        Ok(())
12139    }
12140
12141    /// Open the workers-menu teach picker for the selected worker (must be in range).
12142    pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
12143        let Some(worker) = self
12144            .state
12145            .hired_workers
12146            .get(self.state.workers_menu_index)
12147            .cloned()
12148        else {
12149            anyhow::bail!("select a hired worker first");
12150        };
12151        let (px, py, _) = self.state.player_position_with_z();
12152        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
12153        if dist > WORKER_GIVE_RANGE_M {
12154            anyhow::bail!(
12155                "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
12156                worker.label
12157            );
12158        }
12159        let options = self.state.teachable_blueprint_options(&worker);
12160        if options.is_empty() {
12161            anyhow::bail!("no recipes you know that {} still needs", worker.label);
12162        }
12163        self.state.worker_teach_picker = Some(WorkerTeachPicker {
12164            worker_instance_id: worker.instance_id,
12165            worker_label: worker.label,
12166            worker_level: worker.level,
12167            options,
12168        });
12169        self.state.worker_teach_picker_index = 0;
12170        self.state.show_worker_teach_picker = true;
12171        Ok(())
12172    }
12173
12174    pub fn close_worker_teach_picker(&mut self) {
12175        self.state.show_worker_teach_picker = false;
12176        self.state.worker_teach_picker = None;
12177        self.state.worker_teach_picker_index = 0;
12178    }
12179
12180    pub fn worker_teach_picker_move(&mut self, delta: i32) {
12181        let Some(picker) = &self.state.worker_teach_picker else {
12182            return;
12183        };
12184        let n = picker.options.len();
12185        if n == 0 {
12186            return;
12187        }
12188        let idx = self.state.worker_teach_picker_index as i32;
12189        self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
12190    }
12191
12192    pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
12193        let Some(picker) = self.state.worker_teach_picker.clone() else {
12194            anyhow::bail!("teach picker not open");
12195        };
12196        let Some(opt) = picker
12197            .options
12198            .get(self.state.worker_teach_picker_index)
12199            .cloned()
12200        else {
12201            anyhow::bail!("nothing selected");
12202        };
12203        if !opt.level_ok {
12204            anyhow::bail!(
12205                "{} needs level {} (is level {})",
12206                picker.worker_label,
12207                opt.min_level,
12208                opt.worker_level
12209            );
12210        }
12211        if !opt.can_afford {
12212            anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
12213        }
12214        let Some(worker) = self
12215            .state
12216            .hired_workers
12217            .iter()
12218            .find(|w| w.instance_id == picker.worker_instance_id)
12219            .cloned()
12220        else {
12221            anyhow::bail!("worker gone");
12222        };
12223        let (px, py, _) = self.state.player_position_with_z();
12224        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
12225        if dist > WORKER_GIVE_RANGE_M {
12226            anyhow::bail!("worker {} too far — stand next to them", worker.label);
12227        }
12228        self.seq += 1;
12229        self.session
12230            .submit_intent(Intent::TeachWorkerBlueprint {
12231                entity_id: self.state.entity_id,
12232                worker_instance_id: picker.worker_instance_id.clone(),
12233                blueprint_id: opt.blueprint_id.clone(),
12234                seq: self.seq,
12235            })
12236            .await?;
12237        self.state.intents_sent += 1;
12238        self.state.push_log(format!(
12239            "Teaching {} to {} ({} cp)",
12240            opt.label, picker.worker_label, opt.cost_copper
12241        ));
12242        self.close_worker_teach_picker();
12243        Ok(())
12244    }
12245
12246    async fn give_item_to_worker(
12247        &mut self,
12248        worker_instance_id: &str,
12249        worker_label: &str,
12250        worker_x: f32,
12251        worker_y: f32,
12252        item_instance_id: uuid::Uuid,
12253        item_label: &str,
12254        quantity: Option<u32>,
12255    ) -> anyhow::Result<()> {
12256        let (px, py, _) = self.state.player_position_with_z();
12257        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
12258        if dist > WORKER_GIVE_RANGE_M {
12259            anyhow::bail!("worker {worker_label} too far — stand next to them");
12260        }
12261        self.seq += 1;
12262        self.session
12263            .submit_intent(Intent::GiveWorkerItem {
12264                entity_id: self.state.entity_id,
12265                worker_instance_id: worker_instance_id.to_string(),
12266                item_instance_id,
12267                quantity,
12268                seq: self.seq,
12269            })
12270            .await?;
12271        self.state.intents_sent += 1;
12272        self.state
12273            .remove_carried_instance(item_instance_id, quantity);
12274        self.state
12275            .push_log(format!("Gave {item_label} to {worker_label}"));
12276        Ok(())
12277    }
12278
12279    /// Equip one item from the player's inventory on the selected worker.
12280    /// The server validates the requested hand/body slot and replaces the
12281    /// previous item into the worker's pack.
12282    pub async fn equip_item_on_worker(
12283        &mut self,
12284        worker_instance_id: &str,
12285        item_instance_id: uuid::Uuid,
12286        slot: &str,
12287    ) -> anyhow::Result<()> {
12288        let Some(worker) = self
12289            .state
12290            .hired_workers
12291            .iter()
12292            .find(|worker| worker.instance_id == worker_instance_id)
12293            .cloned()
12294        else {
12295            anyhow::bail!("worker not found");
12296        };
12297        let (px, py, _) = self.state.player_position_with_z();
12298        if (worker.x - px).hypot(worker.y - py) > WORKER_GIVE_RANGE_M {
12299            anyhow::bail!("worker {} too far — stand next to them", worker.label);
12300        }
12301        self.seq += 1;
12302        self.session
12303            .submit_intent(Intent::EquipWorkerItem {
12304                entity_id: self.state.entity_id,
12305                worker_instance_id: worker.instance_id.clone(),
12306                item_instance_id,
12307                slot: slot.to_string(),
12308                seq: self.seq,
12309            })
12310            .await?;
12311        self.state.intents_sent += 1;
12312        self.state
12313            .push_log(format!("Equipped {slot} on {}", worker.label));
12314        Ok(())
12315    }
12316
12317    /// Open the workers-menu take picker for the selected worker (must be in range).
12318    pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
12319        let Some(worker) = self
12320            .state
12321            .hired_workers
12322            .get(self.state.workers_menu_index)
12323            .cloned()
12324        else {
12325            anyhow::bail!("select a hired worker first");
12326        };
12327        let (px, py, _) = self.state.player_position_with_z();
12328        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
12329        if dist > WORKER_GIVE_RANGE_M {
12330            anyhow::bail!(
12331                "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
12332                worker.label
12333            );
12334        }
12335        let options = Self::worker_inventory_options(&worker);
12336        if options.is_empty() {
12337            anyhow::bail!("{} isn't carrying anything", worker.label);
12338        }
12339        let initial_qty = options
12340            .first()
12341            .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
12342            .unwrap_or(1);
12343        self.state.worker_take_picker = Some(WorkerTakePicker {
12344            worker_instance_id: worker.instance_id,
12345            worker_label: worker.label,
12346            options,
12347            quantity: initial_qty,
12348        });
12349        self.state.worker_take_picker_index = 0;
12350        self.state.show_worker_take_picker = true;
12351        Ok(())
12352    }
12353
12354    fn worker_inventory_options(
12355        worker: &flatland_protocol::HiredWorkerView,
12356    ) -> Vec<WorkerGiveOption> {
12357        worker
12358            .inventory
12359            .iter()
12360            .filter_map(|stack| {
12361                let item_instance_id = stack.item_instance_id?;
12362                let label = stack
12363                    .display_name
12364                    .clone()
12365                    .unwrap_or_else(|| stack.template_id.clone());
12366                let label = if stack.quantity > 1 {
12367                    format!("{label} ×{}", stack.quantity)
12368                } else {
12369                    label
12370                };
12371                Some(WorkerGiveOption {
12372                    item_instance_id,
12373                    label,
12374                    quantity: stack.quantity,
12375                    template_id: stack.template_id.clone(),
12376                })
12377            })
12378            .collect()
12379    }
12380
12381    pub fn close_worker_take_picker(&mut self) {
12382        self.state.show_worker_take_picker = false;
12383        self.state.worker_take_picker = None;
12384        self.state.worker_take_picker_index = 0;
12385    }
12386
12387    pub fn worker_take_picker_move(&mut self, delta: i32) {
12388        let Some(picker) = &self.state.worker_take_picker else {
12389            return;
12390        };
12391        let n = picker.options.len();
12392        if n == 0 {
12393            return;
12394        }
12395        let idx = self.state.worker_take_picker_index as i32;
12396        self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
12397        self.clamp_worker_take_quantity();
12398    }
12399
12400    pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
12401        let Some(picker) = &mut self.state.worker_take_picker else {
12402            return;
12403        };
12404        let max = picker
12405            .options
12406            .get(self.state.worker_take_picker_index)
12407            .map(|o| o.quantity.max(1))
12408            .unwrap_or(1);
12409        let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
12410        picker.quantity = next as u32;
12411    }
12412
12413    pub fn worker_take_picker_set_quantity_max(&mut self) {
12414        let Some(picker) = &mut self.state.worker_take_picker else {
12415            return;
12416        };
12417        let max = picker
12418            .options
12419            .get(self.state.worker_take_picker_index)
12420            .map(|o| o.quantity.max(1))
12421            .unwrap_or(1);
12422        picker.quantity = max;
12423    }
12424
12425    pub fn worker_take_picker_set_quantity_min(&mut self) {
12426        let Some(picker) = &mut self.state.worker_take_picker else {
12427            return;
12428        };
12429        picker.quantity = 1;
12430        self.clamp_worker_take_quantity();
12431    }
12432
12433    fn clamp_worker_take_quantity(&mut self) {
12434        let Some(picker) = &mut self.state.worker_take_picker else {
12435            return;
12436        };
12437        let max = picker
12438            .options
12439            .get(self.state.worker_take_picker_index)
12440            .map(|o| o.quantity.max(1))
12441            .unwrap_or(1);
12442        if picker.quantity == 0 || picker.quantity > max {
12443            picker.quantity = if max > 1 { 1 } else { max };
12444        }
12445    }
12446
12447    pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
12448        let Some(picker) = self.state.worker_take_picker.clone() else {
12449            anyhow::bail!("take picker not open");
12450        };
12451        let Some(opt) = picker
12452            .options
12453            .get(self.state.worker_take_picker_index)
12454            .cloned()
12455        else {
12456            anyhow::bail!("no item selected");
12457        };
12458        let Some(worker) = self
12459            .state
12460            .hired_workers
12461            .iter()
12462            .find(|w| w.instance_id == picker.worker_instance_id)
12463            .cloned()
12464        else {
12465            self.close_worker_take_picker();
12466            anyhow::bail!("worker no longer hired");
12467        };
12468        let qty = picker.quantity.clamp(1, opt.quantity.max(1));
12469        let intent_qty = if qty >= opt.quantity { None } else { Some(qty) };
12470        self.take_item_from_worker(
12471            &worker.instance_id,
12472            &worker.label,
12473            worker.x,
12474            worker.y,
12475            opt.item_instance_id,
12476            &opt.label,
12477            intent_qty,
12478        )
12479        .await?;
12480        // Leave the picker open — server Interaction / hired-workers sync refresh
12481        // options. Do not optimistic-strip stacks (rejects used to look like success).
12482        Ok(())
12483    }
12484
12485    async fn take_item_from_worker(
12486        &mut self,
12487        worker_instance_id: &str,
12488        worker_label: &str,
12489        worker_x: f32,
12490        worker_y: f32,
12491        item_instance_id: uuid::Uuid,
12492        item_label: &str,
12493        quantity: Option<u32>,
12494    ) -> anyhow::Result<()> {
12495        let (px, py, _) = self.state.player_position_with_z();
12496        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
12497        if dist > WORKER_GIVE_RANGE_M {
12498            anyhow::bail!("worker {worker_label} too far — stand next to them");
12499        }
12500        self.seq += 1;
12501        self.session
12502            .submit_intent(Intent::TakeWorkerItem {
12503                entity_id: self.state.entity_id,
12504                worker_instance_id: worker_instance_id.to_string(),
12505                item_instance_id,
12506                quantity,
12507                seq: self.seq,
12508            })
12509            .await?;
12510        self.state.intents_sent += 1;
12511        let qty_note = quantity.map(|q| format!(" ×{q}")).unwrap_or_default();
12512        self.state.push_log(format!(
12513            "Taking {item_label}{qty_note} from {worker_label}…"
12514        ));
12515        Ok(())
12516    }
12517
12518    pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
12519        if let Some(since) = self.state.pending_worker_hire_since {
12520            if since.elapsed() < WORKER_HIRE_PENDING_TIMEOUT {
12521                anyhow::bail!("hire request still pending — wait for the worker roster update");
12522            }
12523            self.state.pending_worker_hire_since = None;
12524        }
12525        if !self.state.has_worker_lodging() {
12526            anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
12527        }
12528        self.seq += 1;
12529        self.session
12530            .submit_intent(Intent::HireWorker {
12531                entity_id: self.state.entity_id,
12532                def_id: "worker_laborer".into(),
12533                wage_copper_per_interval: 8,
12534                lodging_container_id: None,
12535                job_yaml: None,
12536                seq: self.seq,
12537            })
12538            .await?;
12539        self.state.intents_sent += 1;
12540        self.state.pending_worker_hire_since = Some(Instant::now());
12541        Ok(())
12542    }
12543
12544    pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
12545        let Some(worker) = self
12546            .state
12547            .hired_workers
12548            .get(self.state.workers_menu_index)
12549            .cloned()
12550        else {
12551            anyhow::bail!("select a hired worker first");
12552        };
12553        let lodging = worker.lodging_container_id.clone().or_else(|| {
12554            crate::worker_route_editor::owned_lodging_container_ids(
12555                &self.state.placed_containers,
12556                self.state.character_id,
12557            )
12558            .into_iter()
12559            .next()
12560            .map(|(id, _)| id)
12561        });
12562        let label = worker.label.clone();
12563        let editor = if let Some(route) = &worker.route {
12564            crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
12565                worker.instance_id,
12566                worker.label,
12567                route,
12568                lodging,
12569            )
12570        } else {
12571            crate::worker_route_editor::WorkerRouteEditorState::new(
12572                worker.instance_id,
12573                worker.label,
12574                lodging,
12575            )
12576        };
12577        self.state.worker_route_editor = Some(editor);
12578        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12579            if let Some(collapsed) =
12580                crate::client_config::ClientConfig::load().worker_route_panel_collapsed
12581            {
12582                ed.panel_collapsed = collapsed;
12583            }
12584        }
12585        self.state.show_workers_menu = false;
12586        self.state.push_log(format!(
12587            "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
12588        ));
12589        Ok(())
12590    }
12591
12592    pub fn close_worker_route_editor(&mut self) {
12593        self.state.worker_route_editor = None;
12594    }
12595
12596    pub fn worker_route_editor_toggle_panel(&mut self) {
12597        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12598            ed.toggle_panel_collapsed();
12599            let collapsed = ed.panel_collapsed;
12600            let mut cfg = crate::client_config::ClientConfig::load();
12601            let _ = cfg.save_worker_route_panel_collapsed(collapsed);
12602        }
12603    }
12604
12605    pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
12606        let n = {
12607            let Some(ed) = self.state.worker_route_editor.as_mut() else {
12608                return;
12609            };
12610            ed.append_waypoint(x, y, z);
12611            ed.stop_count()
12612        };
12613        self.state
12614            .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
12615    }
12616
12617    // ---- picker candidate snapshots ----------------------------------
12618
12619    fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
12620        let (px, py, _) = self.state.player_position_with_z();
12621        let inside = self.state.effective_inside_building();
12622        crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
12623            &self.state.placed_containers,
12624            &self.state.buildings,
12625            self.state.character_id,
12626            px,
12627            py,
12628            &self.state.hired_workers,
12629            inside.as_deref(),
12630        )
12631    }
12632
12633    fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
12634        self.state.route_editor_node_candidates()
12635    }
12636
12637    fn re_open_harvest_picker(&mut self, index: usize, picked: std::collections::BTreeSet<String>) {
12638        use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
12639        let nodes = self.state.route_editor_node_candidates();
12640        let index = if nodes.is_empty() {
12641            ROUTE_PICKER_DONE_ROW
12642        } else {
12643            index.max(1).min(nodes.len())
12644        };
12645        self.re_open_sheet(S::HarvestPicker {
12646            index,
12647            picked,
12648            nodes,
12649        });
12650    }
12651
12652    fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
12653        let (px, py, _) = self.state.player_position_with_z();
12654        let templates = self.re_template_candidates();
12655        crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py, &templates)
12656    }
12657
12658    fn re_template_candidates(&self) -> Vec<String> {
12659        let mut extra = Vec::new();
12660        if let Some(ed) = self.state.worker_route_editor.as_ref() {
12661            for stop in &ed.stops {
12662                match stop {
12663                    crate::worker_route_editor::WorkerRouteStop::DepositAt {
12664                        filter: Some(filter),
12665                        ..
12666                    } => extra.extend(filter.iter().cloned()),
12667                    crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. }
12668                    | crate::worker_route_editor::WorkerRouteStop::ListOnMarket {
12669                        template, ..
12670                    } => {
12671                        extra.push(template.clone());
12672                    }
12673                    crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
12674                        if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint)
12675                        {
12676                            extra.push(bp.output.clone());
12677                            for input in &bp.inputs {
12678                                extra.push(input.template_id.clone());
12679                            }
12680                        }
12681                    }
12682                    crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
12683                        for it in items {
12684                            extra.push(it.template.clone());
12685                        }
12686                    }
12687                    _ => {}
12688                }
12689            }
12690            // Worker-known recipe outputs even if the player hasn't learned them yet.
12691            if let Some(worker) = self
12692                .state
12693                .hired_workers
12694                .iter()
12695                .find(|w| w.instance_id == ed.worker_instance_id)
12696            {
12697                for recipe in &worker.known_blueprint_ids {
12698                    if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
12699                        extra.push(bp.output.clone());
12700                    }
12701                }
12702                for stack in &worker.inventory {
12703                    if !stack.template_id.is_empty() && stack.quantity > 0 {
12704                        extra.push(stack.template_id.clone());
12705                    }
12706                }
12707            }
12708        }
12709        crate::worker_route_editor::route_item_template_candidates(
12710            &self.state.placed_containers,
12711            self.state.character_id,
12712            &self.state.inventory,
12713            &self.state.blueprints,
12714            if self.state.harvest_route_nodes.is_empty() {
12715                &self.state.resource_nodes
12716            } else {
12717                &self.state.harvest_route_nodes
12718            },
12719            &extra,
12720            Some(&self.state.item_catalog),
12721        )
12722    }
12723
12724    fn re_blueprint_ids(&self) -> Vec<String> {
12725        let worker_known: Option<&[String]> = self
12726            .state
12727            .worker_route_editor
12728            .as_ref()
12729            .and_then(|ed| {
12730                self.state
12731                    .hired_workers
12732                    .iter()
12733                    .find(|w| w.instance_id == ed.worker_instance_id)
12734            })
12735            .map(|w| w.known_blueprint_ids.as_slice());
12736        crate::worker_route_editor::worker_craft_blueprint_ids(&self.state.blueprints, worker_known)
12737    }
12738
12739    fn re_bed_candidates(&self) -> Vec<(String, String)> {
12740        crate::worker_route_editor::owned_lodging_container_ids(
12741            &self.state.placed_containers,
12742            self.state.character_id,
12743        )
12744    }
12745
12746    fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
12747        self.state
12748            .placed_containers
12749            .iter()
12750            .find(|c| c.id == container_id)
12751            .map(|c| c.contents.clone())
12752            .unwrap_or_default()
12753    }
12754
12755    // ---- sheet navigation (`plans/33`) --------------------------------
12756
12757    fn re_sheet_supports_filter(&self) -> bool {
12758        use crate::worker_route_editor::RouteEditorSheet as S;
12759        self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12760            matches!(
12761                ed.sheet,
12762                S::HarvestPicker { .. }
12763                    | S::SellItem { .. }
12764                    | S::MarketListItem { .. }
12765                    | S::DepositFilter { .. }
12766                    | S::WithdrawItems { .. }
12767                    | S::WithdrawContainers { .. }
12768                    | S::DepositContainers { .. }
12769                    | S::SellNpcs { .. }
12770                    | S::CraftBlueprint { .. }
12771                    | S::BedPicker { .. }
12772            )
12773        })
12774    }
12775
12776    /// Whether a sheet row is shown under the current `/` filter.
12777    pub fn re_sheet_row_visible(&self, row: usize) -> bool {
12778        use crate::worker_route_editor::{
12779            harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
12780            ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
12781        };
12782        let Some(ed) = self.state.worker_route_editor.as_ref() else {
12783            return false;
12784        };
12785        let filter = &ed.sheet_filter;
12786        match &ed.sheet {
12787            S::HarvestPicker { nodes, .. } => harvest_picker_row_matches(nodes, row, filter),
12788            S::SellItem { templates, .. } | S::MarketListItem { templates, .. } => {
12789                if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
12790                    return true;
12791                }
12792                let slot = row.saturating_sub(2);
12793                templates.get(slot).is_some_and(|t| {
12794                    let label = self.state.template_display_name(t);
12795                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12796                })
12797            }
12798            S::DepositFilter { rows, .. } => {
12799                if row >= rows.len() {
12800                    return true;
12801                }
12802                rows.get(row).is_some_and(|(t, _)| {
12803                    let label = self.state.template_display_name(t);
12804                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12805                })
12806            }
12807            S::WithdrawItems { lines, .. } => {
12808                if row >= lines.len() {
12809                    return true;
12810                }
12811                lines.get(row).is_some_and(|l| {
12812                    let label = self.state.template_display_name(&l.template);
12813                    list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
12814                })
12815            }
12816            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12817                self.re_container_candidates().get(row).is_some_and(|c| {
12818                    list_filter_row_matches(
12819                        filter,
12820                        Some(c.dist),
12821                        &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
12822                    )
12823                })
12824            }
12825            S::SellNpcs { .. } => {
12826                if row == 0 {
12827                    return true;
12828                }
12829                self.re_npc_candidates().get(row - 1).is_some_and(|n| {
12830                    list_filter_row_matches(
12831                        filter,
12832                        Some(n.dist),
12833                        &[n.label.as_str(), n.id.as_str()],
12834                    )
12835                })
12836            }
12837            S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
12838                let label = self
12839                    .state
12840                    .blueprints
12841                    .iter()
12842                    .find(|b| &b.id == id)
12843                    .map(|b| {
12844                        if b.label.is_empty() {
12845                            id.as_str()
12846                        } else {
12847                            b.label.as_str()
12848                        }
12849                    })
12850                    .unwrap_or(id.as_str());
12851                list_filter_row_matches(filter, None, &[id.as_str(), label])
12852            }),
12853            S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
12854                list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
12855            }),
12856            _ => true,
12857        }
12858    }
12859
12860    fn re_sheet_clamp_index(&mut self) {
12861        let count = self.re_sheet_row_count();
12862        if count == 0 {
12863            return;
12864        }
12865        let cur = self.re_sheet_index();
12866        if self.re_sheet_row_visible(cur) {
12867            return;
12868        }
12869        for offset in 1..count {
12870            if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
12871                self.re_sheet_set_index(cur + offset);
12872                return;
12873            }
12874            if cur >= offset && self.re_sheet_row_visible(cur - offset) {
12875                self.re_sheet_set_index(cur - offset);
12876                return;
12877            }
12878        }
12879    }
12880
12881    fn re_sheet_set_index(&mut self, index: usize) {
12882        use crate::worker_route_editor::RouteEditorSheet as S;
12883        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12884            return;
12885        };
12886        match &mut ed.sheet {
12887            S::AddMenu { index: slot }
12888            | S::WaypointMenu { index: slot }
12889            | S::HarvestPicker { index: slot, .. }
12890            | S::WithdrawContainers { index: slot }
12891            | S::DepositContainers { index: slot }
12892            | S::SellNpcs { index: slot }
12893            | S::CraftBlueprint { index: slot }
12894            | S::BedPicker { index: slot }
12895            | S::FarmPlotPicker { index: slot, .. }
12896            | S::FarmPlantSeed { index: slot, .. }
12897            | S::WithdrawItems { index: slot, .. }
12898            | S::DepositFilter { index: slot, .. }
12899            | S::SellItem { index: slot, .. }
12900            | S::MarketListItem { index: slot, .. } => *slot = index,
12901            _ => {}
12902        }
12903    }
12904
12905    pub fn re_focus_sheet_filter(&mut self) {
12906        if !self.re_sheet_supports_filter() {
12907            return;
12908        }
12909        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12910            ed.sheet_filter_focused = true;
12911        }
12912    }
12913
12914    pub fn re_blur_sheet_filter_keep_text(&mut self) {
12915        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12916            return;
12917        };
12918        if !ed.sheet_filter_focused {
12919            return;
12920        }
12921        ed.sheet_filter_focused = false;
12922        self.re_sheet_clamp_index();
12923    }
12924
12925    pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
12926        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12927            return false;
12928        };
12929        if ed.sheet_filter_focused {
12930            ed.sheet_filter_focused = false;
12931            self.re_sheet_clamp_index();
12932            return true;
12933        }
12934        if !ed.sheet_filter.is_empty() {
12935            ed.sheet_filter.clear();
12936            self.re_sheet_clamp_index();
12937            return true;
12938        }
12939        false
12940    }
12941
12942    pub fn re_append_sheet_filter_char(&mut self, ch: char) {
12943        if ch.is_control() {
12944            return;
12945        }
12946        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12947            return;
12948        };
12949        if !ed.sheet_filter_focused {
12950            return;
12951        }
12952        ed.sheet_filter.push(ch);
12953        self.re_sheet_set_index(0);
12954        self.re_sheet_clamp_index();
12955    }
12956
12957    pub fn re_sheet_filter_backspace(&mut self) {
12958        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12959            return;
12960        };
12961        if !ed.sheet_filter_focused {
12962            return;
12963        }
12964        ed.sheet_filter.pop();
12965        self.re_sheet_set_index(0);
12966        self.re_sheet_clamp_index();
12967    }
12968
12969    /// Row count of the current sheet (for cursor wrapping).
12970    pub fn re_sheet_row_count(&self) -> usize {
12971        use crate::worker_route_editor::{
12972            harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
12973        };
12974        let Some(ed) = self.state.worker_route_editor.as_ref() else {
12975            return 0;
12976        };
12977        match &ed.sheet {
12978            S::Stops => ed.stops.len(),
12979            S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
12980            S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
12981            S::WaypointMapPick => 0,
12982            S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
12983            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12984                self.re_container_candidates().len()
12985            }
12986            S::WithdrawItems { lines, .. } => lines.len() + 1, // + Done row
12987            S::DepositFilter { rows, .. } => rows.len() + 1,   // + Done row
12988            S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, // + auto row
12989            S::SellItem { templates, .. } | S::MarketListItem { templates, .. } => {
12990                sell_item_picker_row_count(templates.len())
12991            }
12992            S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
12993            S::WaitEntry { .. } => 1,
12994            S::BedPicker { .. } => self.re_bed_candidates().len(),
12995            S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
12996            S::FarmPlantSeed { seeds, .. } => seeds.len(),
12997        }
12998    }
12999
13000    /// Current sheet cursor index (0 for sheets without one).
13001    pub fn re_sheet_index(&self) -> usize {
13002        use crate::worker_route_editor::RouteEditorSheet as S;
13003        let Some(ed) = self.state.worker_route_editor.as_ref() else {
13004            return 0;
13005        };
13006        match &ed.sheet {
13007            S::AddMenu { index }
13008            | S::WaypointMenu { index }
13009            | S::HarvestPicker { index, .. }
13010            | S::WithdrawContainers { index }
13011            | S::DepositContainers { index }
13012            | S::SellNpcs { index }
13013            | S::CraftBlueprint { index }
13014            | S::BedPicker { index }
13015            | S::FarmPlotPicker { index, .. }
13016            | S::FarmPlantSeed { index, .. }
13017            | S::WithdrawItems { index, .. }
13018            | S::DepositFilter { index, .. }
13019            | S::SellItem { index, .. }
13020            | S::MarketListItem { index, .. } => *index,
13021            _ => 0,
13022        }
13023    }
13024
13025    /// Move the current sheet's cursor, wrapping within its rows.
13026    pub fn re_sheet_move(&mut self, delta: i32) {
13027        let count = self.re_sheet_row_count();
13028        if count == 0 {
13029            return;
13030        }
13031        let cur = self.re_sheet_index();
13032        let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
13033        self.re_sheet_set_index(next);
13034    }
13035
13036    pub fn re_sheet_page(&mut self, pages: i32) {
13037        let count = self.re_sheet_row_count();
13038        if count == 0 {
13039            return;
13040        }
13041        let cur = self.re_sheet_index();
13042        let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
13043        self.re_sheet_set_index(next);
13044    }
13045
13046    /// `[`/`]` on a sheet: adjust quantity (withdraw lines, wait ticks).
13047    pub fn re_sheet_adjust(&mut self, delta: i32) {
13048        use crate::worker_route_editor::RouteEditorSheet as S;
13049        let index = self.re_sheet_index();
13050        let Some(ed) = self.state.worker_route_editor.as_mut() else {
13051            return;
13052        };
13053        match &mut ed.sheet {
13054            S::WithdrawItems { lines, .. } => {
13055                if let Some(line) = lines.get_mut(index) {
13056                    line.adjust_qty(delta);
13057                }
13058            }
13059            S::WaitEntry { ticks } => {
13060                *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
13061            }
13062            _ => {}
13063        }
13064    }
13065
13066    pub fn re_sheet_back(&mut self) {
13067        let Some(ed) = self.state.worker_route_editor.as_mut() else {
13068            return;
13069        };
13070        use crate::worker_route_editor::RouteEditorSheet as S;
13071        let was_editing = ed.editing_index.is_some();
13072        let from_top_picker = matches!(
13073            ed.sheet,
13074            S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
13075        );
13076        ed.sheet_back();
13077        if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
13078            // Chest retarget commits on pick; Esc here just ends the edit sheet.
13079            self.state
13080                .push_log("Route: left edit sheet — press s to save current stops".to_string());
13081        }
13082    }
13083
13084    /// True when the editor is at its root sheet (Esc should close it).
13085    pub fn re_at_root_sheet(&self) -> bool {
13086        self.state.worker_route_editor.as_ref().is_some_and(|ed| {
13087            matches!(
13088                ed.sheet,
13089                crate::worker_route_editor::RouteEditorSheet::Stops
13090            )
13091        })
13092    }
13093
13094    pub fn re_open_add_menu(&mut self) {
13095        if let Some(ed) = self.state.worker_route_editor.as_mut() {
13096            ed.open_add_menu();
13097        }
13098    }
13099
13100    pub fn re_open_bed_picker(&mut self) {
13101        let beds = self.re_bed_candidates();
13102        if beds.is_empty() {
13103            self.state
13104                .push_log("Route: place a camp bed first".to_string());
13105            return;
13106        }
13107        let current = self
13108            .state
13109            .worker_route_editor
13110            .as_ref()
13111            .and_then(|ed| ed.lodging_container_id.clone());
13112        let index = current
13113            .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
13114            .unwrap_or(0);
13115        self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
13116    }
13117
13118    fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
13119        if let Some(ed) = self.state.worker_route_editor.as_mut() {
13120            ed.open_sheet(sheet);
13121        }
13122    }
13123
13124    /// Confirm a sheet's stop and log the outcome.
13125    fn re_confirm_stop(&mut self, stop: crate::worker_route_editor::WorkerRouteStop, what: String) {
13126        let appended = self
13127            .state
13128            .worker_route_editor
13129            .as_mut()
13130            .is_some_and(|ed| ed.confirm_stop(stop));
13131        if appended {
13132            self.state.push_log(format!("Route: + {what}"));
13133        } else {
13134            self.state
13135                .push_log(format!("Route: {what} already in route — selected it"));
13136        }
13137    }
13138
13139    fn re_open_withdraw_items(&mut self, container_id: String) {
13140        use crate::worker_route_editor::{
13141            RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
13142        };
13143        let contents = self.re_container_contents(&container_id);
13144        // When editing, keep the stop's item picks even if the player retargets
13145        // to a different (possibly empty) chest — otherwise Done has nothing to
13146        // confirm and the edit appears to "revert".
13147        let existing = self
13148            .state
13149            .worker_route_editor
13150            .as_ref()
13151            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
13152            .and_then(|stop| match stop {
13153                WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
13154                _ => None,
13155            })
13156            .unwrap_or_default();
13157        let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
13158        // Commit the new chest immediately while editing so Esc-before-Done still
13159        // keeps the retarget (items update only when Done is pressed).
13160        if let Some(ed) = self.state.worker_route_editor.as_mut() {
13161            let _ = ed.retarget_withdraw_container(container_id.clone());
13162        }
13163        self.re_open_sheet(S::WithdrawItems {
13164            container_id,
13165            lines,
13166            index: 0,
13167        });
13168    }
13169
13170    fn re_withdraw_items_activate(&mut self, index: usize) {
13171        use crate::worker_route_editor::{
13172            RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
13173        };
13174        enum Outcome {
13175            Cycled,
13176            Confirmed(WorkerRouteStop),
13177            Empty,
13178        }
13179        let outcome = {
13180            let Some(ed) = self.state.worker_route_editor.as_mut() else {
13181                return;
13182            };
13183            let S::WithdrawItems {
13184                container_id,
13185                lines,
13186                index: sheet_index,
13187            } = &mut ed.sheet
13188            else {
13189                return;
13190            };
13191            *sheet_index = index;
13192            if index < lines.len() {
13193                lines[index].cycle();
13194                Outcome::Cycled
13195            } else {
13196                let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
13197                if items.is_empty() {
13198                    Outcome::Empty
13199                } else {
13200                    let stop = WorkerRouteStop::WithdrawFrom {
13201                        container_id: container_id.clone(),
13202                        items,
13203                    };
13204                    ed.confirm_stop(stop.clone());
13205                    Outcome::Confirmed(stop)
13206                }
13207            }
13208        };
13209        match outcome {
13210            Outcome::Cycled => {}
13211            Outcome::Confirmed(stop) => {
13212                let what = self.state.worker_route_stop_summary(&stop);
13213                self.state.push_log(format!("Route: + {what}"));
13214            }
13215            Outcome::Empty => self.state.push_log(
13216                "Route: pick at least one item (Space/Enter toggles All/qty)".to_string(),
13217            ),
13218        }
13219    }
13220
13221    fn re_open_deposit_filter(&mut self, container_id: String) {
13222        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13223        // Preserve filter when retargeting the deposit chest while editing.
13224        let existing_filter = self
13225            .state
13226            .worker_route_editor
13227            .as_ref()
13228            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
13229            .and_then(|stop| match stop {
13230                WorkerRouteStop::DepositAt { filter, .. } => {
13231                    Some(filter.clone().unwrap_or_default())
13232                }
13233                _ => None,
13234            });
13235        let mut candidates = self.re_template_candidates();
13236        if let Some(ref chosen) = existing_filter {
13237            for t in chosen {
13238                if !candidates.iter().any(|c| c == t) {
13239                    candidates.push(t.clone());
13240                }
13241            }
13242            candidates.sort();
13243            candidates.dedup();
13244        }
13245        let rows: Vec<(String, bool)> = match existing_filter {
13246            Some(chosen) => candidates
13247                .iter()
13248                .map(|t| (t.clone(), chosen.contains(t)))
13249                .collect(),
13250            None => candidates.into_iter().map(|t| (t, false)).collect(),
13251        };
13252        if let Some(ed) = self.state.worker_route_editor.as_mut() {
13253            let _ = ed.retarget_deposit_container(container_id.clone());
13254        }
13255        self.re_open_sheet(S::DepositFilter {
13256            container_id,
13257            rows,
13258            index: 0,
13259        });
13260    }
13261
13262    fn re_deposit_filter_activate(&mut self, index: usize) {
13263        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13264        let mut confirmed: Option<WorkerRouteStop> = None;
13265        {
13266            let Some(ed) = self.state.worker_route_editor.as_mut() else {
13267                return;
13268            };
13269            let S::DepositFilter {
13270                container_id,
13271                rows,
13272                index: sheet_index,
13273            } = &mut ed.sheet
13274            else {
13275                return;
13276            };
13277            *sheet_index = index;
13278            if index < rows.len() {
13279                rows[index].1 = !rows[index].1;
13280            } else {
13281                // "Done" row.
13282                let chosen: Vec<String> = rows
13283                    .iter()
13284                    .filter(|(_, on)| *on)
13285                    .map(|(t, _)| t.clone())
13286                    .collect();
13287                let filter = if chosen.is_empty() {
13288                    None
13289                } else {
13290                    Some(chosen)
13291                };
13292                let stop = WorkerRouteStop::DepositAt {
13293                    container_id: container_id.clone(),
13294                    filter,
13295                };
13296                confirmed = Some(stop.clone());
13297                ed.confirm_stop(stop);
13298            }
13299        }
13300        if let Some(stop) = confirmed {
13301            let what = self.state.worker_route_stop_summary(&stop);
13302            self.state.push_log(format!("Route: + {what}"));
13303        }
13304    }
13305
13306    fn re_open_sell_item(&mut self, npc_id: Option<String>) {
13307        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13308        // Prefill when editing an existing sell stop.
13309        let (pre_npc, pre_template, pre_all) = self
13310            .state
13311            .worker_route_editor
13312            .as_ref()
13313            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
13314            .and_then(|stop| match stop {
13315                WorkerRouteStop::TradeWith {
13316                    npc_id,
13317                    template,
13318                    sell_all,
13319                } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
13320                _ => None,
13321            })
13322            .unwrap_or((None, None, true));
13323        let npc_id = npc_id.or(pre_npc);
13324        let mut templates = crate::worker_route_editor::sellable_route_item_template_candidates(
13325            &self.re_template_candidates(),
13326            &self.state.npcs,
13327            npc_id.as_deref(),
13328        );
13329        // Keep an existing invalid selection visible long enough to remove it
13330        // when editing a route after NPC buy lists change.
13331        if let Some(template) = pre_template.as_ref() {
13332            if !templates.iter().any(|candidate| candidate == template) {
13333                templates.push(template.clone());
13334                templates.sort();
13335            }
13336        }
13337        if templates.is_empty() {
13338            let msg = crate::worker_route_editor::sell_merchant_empty_reason(
13339                npc_id.as_deref(),
13340                &self.state.npcs,
13341                &self.re_template_candidates(),
13342            );
13343            self.state.push_log(msg);
13344            return;
13345        }
13346        let mut picked = std::collections::BTreeSet::new();
13347        if let Some(t) = pre_template {
13348            picked.insert(t);
13349        }
13350        self.re_open_sheet(S::SellItem {
13351            npc_id,
13352            templates,
13353            index: if picked.is_empty() {
13354                crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
13355            } else {
13356                2
13357            },
13358            sell_all: pre_all,
13359            picked,
13360        });
13361    }
13362
13363    fn re_sell_item_activate(&mut self, index: usize) {
13364        use crate::worker_route_editor::{
13365            RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
13366        };
13367        let mut batch_log: Option<String> = None;
13368        {
13369            let Some(ed) = self.state.worker_route_editor.as_mut() else {
13370                return;
13371            };
13372            let S::SellItem {
13373                npc_id,
13374                templates,
13375                index: sheet_index,
13376                sell_all,
13377                picked,
13378            } = &mut ed.sheet
13379            else {
13380                return;
13381            };
13382            *sheet_index = index;
13383            if index == ROUTE_PICKER_DONE_ROW {
13384                if picked.is_empty() {
13385                    batch_log =
13386                        Some("Route: pick at least one item (Space toggles, Done confirms)".into());
13387                } else {
13388                    let picks: Vec<String> = picked.iter().cloned().collect();
13389                    let npc = npc_id.clone();
13390                    let all = *sell_all;
13391                    let added = ed.confirm_trade_picks(npc, &picks, all);
13392                    batch_log = Some(format!("Route: + {added} sell stop(s)"));
13393                }
13394            } else if index == SELL_ITEM_TOGGLE_ROW {
13395                *sell_all = !*sell_all;
13396            } else if let Some(template) = templates.get(index.saturating_sub(2)) {
13397                let sellable = crate::worker_route_editor::sellable_route_item_template_candidates(
13398                    std::slice::from_ref(template),
13399                    &self.state.npcs,
13400                    npc_id.as_deref(),
13401                )
13402                .iter()
13403                .any(|candidate| candidate == template);
13404                if !sellable && !picked.contains(template) {
13405                    return;
13406                }
13407                if picked.contains(template) {
13408                    picked.remove(template);
13409                } else {
13410                    picked.insert(template.clone());
13411                }
13412            }
13413        }
13414        if let Some(msg) = batch_log {
13415            self.state.push_log(msg);
13416        }
13417    }
13418
13419    fn re_open_market_list_item(&mut self) {
13420        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13421        let (pre_hall, pre_template, pre_all) = self
13422            .state
13423            .worker_route_editor
13424            .as_ref()
13425            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
13426            .and_then(|stop| match stop {
13427                WorkerRouteStop::ListOnMarket {
13428                    hall_id,
13429                    template,
13430                    list_all,
13431                } => Some((hall_id.clone(), Some(template.clone()), *list_all)),
13432                _ => None,
13433            })
13434            .unwrap_or((None, None, true));
13435        let mut templates = self.re_template_candidates();
13436        // Prefer items with a known NPC-value hint when available; keep the rest
13437        // so the stop can still be authored (sim rejects no-value / unlistable).
13438        templates.sort_by_key(|t| {
13439            std::cmp::Reverse(self.state.item_base_value_copper_hint(t).unwrap_or(0))
13440        });
13441        if let Some(template) = pre_template.as_ref() {
13442            if !templates.iter().any(|c| c == template) {
13443                templates.push(template.clone());
13444            }
13445        }
13446        if templates.is_empty() {
13447            self.state
13448                .push_log("Route: no item templates available for market list".to_string());
13449            return;
13450        }
13451        let mut picked = std::collections::BTreeSet::new();
13452        if let Some(t) = pre_template {
13453            picked.insert(t);
13454        }
13455        self.re_open_sheet(S::MarketListItem {
13456            hall_id: pre_hall,
13457            templates,
13458            index: if picked.is_empty() {
13459                crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
13460            } else {
13461                2
13462            },
13463            list_all: pre_all,
13464            picked,
13465        });
13466    }
13467
13468    fn re_market_list_item_activate(&mut self, index: usize) {
13469        use crate::worker_route_editor::{
13470            RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
13471        };
13472        let mut batch_log: Option<String> = None;
13473        {
13474            let Some(ed) = self.state.worker_route_editor.as_mut() else {
13475                return;
13476            };
13477            let S::MarketListItem {
13478                hall_id,
13479                templates,
13480                index: sheet_index,
13481                list_all,
13482                picked,
13483            } = &mut ed.sheet
13484            else {
13485                return;
13486            };
13487            *sheet_index = index;
13488            if index == ROUTE_PICKER_DONE_ROW {
13489                if picked.is_empty() {
13490                    batch_log =
13491                        Some("Route: pick at least one item (Space toggles, Done confirms)".into());
13492                } else {
13493                    let picks: Vec<String> = picked.iter().cloned().collect();
13494                    let hall = hall_id.clone();
13495                    let all = *list_all;
13496                    let added = ed.confirm_market_list_picks(hall, &picks, all);
13497                    batch_log = Some(format!("Route: + {added} market-list stop(s)"));
13498                }
13499            } else if index == SELL_ITEM_TOGGLE_ROW {
13500                *list_all = !*list_all;
13501            } else if let Some(template) = templates.get(index.saturating_sub(2)) {
13502                if picked.contains(template) {
13503                    picked.remove(template);
13504                } else {
13505                    picked.insert(template.clone());
13506                }
13507            }
13508        }
13509        if let Some(msg) = batch_log {
13510            self.state.push_log(msg);
13511        }
13512    }
13513
13514    /// Enter on the stop list: open the selected stop's sheet, prefilled.
13515    pub fn re_edit_selected_stop(&mut self) {
13516        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13517        let Some(stop) = self
13518            .state
13519            .worker_route_editor
13520            .as_ref()
13521            .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
13522        else {
13523            self.state
13524                .push_log("Route: no stop selected — press a to add one".to_string());
13525            return;
13526        };
13527        if let Some(ed) = self.state.worker_route_editor.as_mut() {
13528            ed.begin_edit_selected();
13529        }
13530        match stop {
13531            WorkerRouteStop::Waypoint { .. } => {
13532                self.re_open_sheet(S::WaypointMenu { index: 0 });
13533            }
13534            WorkerRouteStop::HarvestNode { node_id } => {
13535                let nodes = self.state.route_editor_node_candidates();
13536                if nodes.is_empty() {
13537                    self.re_cancel_edit();
13538                    self.state.push_log(
13539                        "Route: no harvestable nodes in this region to retarget".to_string(),
13540                    );
13541                } else {
13542                    let mut picked = std::collections::BTreeSet::new();
13543                    picked.insert(node_id.clone());
13544                    let index = nodes
13545                        .iter()
13546                        .position(|n| n.id == node_id)
13547                        .map(|i| i + 1)
13548                        .unwrap_or(1);
13549                    self.re_open_harvest_picker(index, picked);
13550                }
13551            }
13552            WorkerRouteStop::WithdrawFrom { container_id, .. } => {
13553                // Open the container picker so the player can retarget storage
13554                // (previously jumped straight to items on the same chest).
13555                let containers = self.re_container_candidates();
13556                if containers.is_empty() {
13557                    self.re_cancel_edit();
13558                    self.state
13559                        .push_log("Route: place a storage chest first".to_string());
13560                } else {
13561                    let index = containers
13562                        .iter()
13563                        .position(|c| c.id == container_id)
13564                        .unwrap_or(0);
13565                    self.re_open_sheet(S::WithdrawContainers { index });
13566                }
13567            }
13568            WorkerRouteStop::DepositAt { container_id, .. } => {
13569                let containers = self.re_container_candidates();
13570                if containers.is_empty() {
13571                    self.re_cancel_edit();
13572                    self.state
13573                        .push_log("Route: place a storage chest first".to_string());
13574                } else {
13575                    let index = containers
13576                        .iter()
13577                        .position(|c| c.id == container_id)
13578                        .unwrap_or(0);
13579                    self.re_open_sheet(S::DepositContainers { index });
13580                }
13581            }
13582            WorkerRouteStop::TradeWith { npc_id, .. } => {
13583                let npcs = self.re_npc_candidates();
13584                // Row 0 is "auto / nearest"; rows 1.. map to npcs.
13585                let index = npc_id
13586                    .as_ref()
13587                    .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
13588                    .unwrap_or(0);
13589                self.re_open_sheet(S::SellNpcs { index });
13590            }
13591            WorkerRouteStop::ListOnMarket { .. } => {
13592                self.re_open_market_list_item();
13593            }
13594            WorkerRouteStop::CraftAt { blueprint, .. } => {
13595                let bps = self.re_blueprint_ids();
13596                let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
13597                if bps.is_empty() {
13598                    self.re_cancel_edit();
13599                    self.state
13600                        .push_log("Route: no known blueprints to retarget".to_string());
13601                } else {
13602                    self.re_open_sheet(S::CraftBlueprint { index });
13603                }
13604            }
13605            WorkerRouteStop::CultivatePlot { .. } => {
13606                self.re_open_farm_plot_picker(
13607                    crate::worker_route_editor::FarmPlotAction::Cultivate,
13608                );
13609            }
13610            WorkerRouteStop::PlantPlot { .. } => {
13611                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
13612            }
13613            WorkerRouteStop::HarvestPlot { .. } => {
13614                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
13615            }
13616            WorkerRouteStop::RestIfNeeded => {
13617                self.re_cancel_edit();
13618                self.state
13619                    .push_log("Route: rest has no settings (change the bed with l)".to_string());
13620            }
13621            WorkerRouteStop::Wait { wait_ticks } => {
13622                self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
13623            }
13624        }
13625    }
13626
13627    fn re_cancel_edit(&mut self) {
13628        if let Some(ed) = self.state.worker_route_editor.as_mut() {
13629            ed.editing_index = None;
13630        }
13631    }
13632
13633    // ---- mouse clicks inside the overlay ------------------------------
13634
13635    pub fn worker_route_editor_ui_click(
13636        &mut self,
13637        click: crate::worker_route_editor::RouteEditorClick,
13638    ) {
13639        use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
13640        match click {
13641            RouteEditorClick::SelectStop(i) => {
13642                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13643                    ed.sheet = S::Stops;
13644                    ed.select_stop(i);
13645                }
13646            }
13647            RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
13648            RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
13649            RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
13650        }
13651    }
13652
13653    /// Activate (Enter / click) a row of the current sheet.
13654    pub fn re_sheet_row_activate(&mut self, row: usize) {
13655        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13656        let Some(sheet) = self
13657            .state
13658            .worker_route_editor
13659            .as_ref()
13660            .map(|ed| ed.sheet.clone())
13661        else {
13662            return;
13663        };
13664        match sheet {
13665            S::Stops => {
13666                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13667                    ed.select_stop(row);
13668                }
13669            }
13670            S::AddMenu { .. } => match row {
13671                0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
13672                1 => {
13673                    if self.re_node_candidates().is_empty() {
13674                        self.state
13675                            .push_log("Route: no harvestable nodes in this region".to_string());
13676                    } else {
13677                        self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
13678                    }
13679                }
13680                2 | 3 => {
13681                    if self.re_container_candidates().is_empty() {
13682                        self.state
13683                            .push_log("Route: place a storage chest first".to_string());
13684                    } else if row == 2 {
13685                        self.re_open_sheet(S::WithdrawContainers { index: 0 });
13686                    } else {
13687                        self.re_open_sheet(S::DepositContainers { index: 0 });
13688                    }
13689                }
13690                4 => {
13691                    if self.re_template_candidates().is_empty() {
13692                        self.state.push_log(
13693                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
13694                                .to_string(),
13695                        );
13696                    } else {
13697                        self.re_open_sheet(S::SellNpcs { index: 0 });
13698                    }
13699                }
13700                5 => {
13701                    if self.re_template_candidates().is_empty() {
13702                        self.state.push_log(
13703                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
13704                                .to_string(),
13705                        );
13706                    } else {
13707                        self.re_open_market_list_item();
13708                    }
13709                }
13710                6 => {
13711                    if self.re_blueprint_ids().is_empty() {
13712                        self.state.push_log(
13713                            "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
13714                                .to_string(),
13715                        );
13716                    } else {
13717                        self.re_open_sheet(S::CraftBlueprint { index: 0 });
13718                    }
13719                }
13720                7 => self.re_confirm_stop(
13721                    WorkerRouteStop::RestIfNeeded,
13722                    "rest at lodging (if needed)".into(),
13723                ),
13724                8 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
13725                9 => self.re_open_farm_plot_picker(
13726                    crate::worker_route_editor::FarmPlotAction::Cultivate,
13727                ),
13728                10 => {
13729                    self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant)
13730                }
13731                11 => self
13732                    .re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
13733                _ => {}
13734            },
13735            S::WaypointMenu { .. } => match row {
13736                0 => {
13737                    let (x, y, z) = self.state.player_position_with_z();
13738                    let stop = WorkerRouteStop::Waypoint { x, y, z };
13739                    self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13740                }
13741                1 => {
13742                    self.re_open_sheet(S::WaypointMapPick);
13743                    self.state.push_log(
13744                        "Route: click the map to place the waypoint (Esc to finish)".to_string(),
13745                    );
13746                }
13747                _ => {}
13748            },
13749            S::HarvestPicker { .. } => {
13750                let mut log: Option<String> = None;
13751                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13752                    let S::HarvestPicker {
13753                        index: sheet_index,
13754                        picked,
13755                        nodes,
13756                    } = &mut ed.sheet
13757                    else {
13758                        return;
13759                    };
13760                    *sheet_index = row;
13761                    if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
13762                        if picked.is_empty() {
13763                            log = Some(
13764                                "Route: pick at least one node (Space toggles, Done confirms)"
13765                                    .into(),
13766                            );
13767                        } else {
13768                            let ids: Vec<String> = picked.iter().cloned().collect();
13769                            let added = ed.confirm_harvest_picks(&ids);
13770                            log = Some(format!("Route: + {added} harvest stop(s)"));
13771                        }
13772                    } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
13773                        if picked.contains(&n.id) {
13774                            picked.remove(&n.id);
13775                        } else {
13776                            picked.insert(n.id.clone());
13777                        }
13778                    }
13779                }
13780                if let Some(msg) = log {
13781                    self.state.push_log(msg);
13782                }
13783            }
13784            S::WithdrawContainers { .. } => {
13785                let containers = self.re_container_candidates();
13786                if let Some(c) = containers.get(row) {
13787                    let id = c.id.clone();
13788                    self.re_open_withdraw_items(id);
13789                }
13790            }
13791            S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
13792            S::DepositContainers { .. } => {
13793                let containers = self.re_container_candidates();
13794                if let Some(c) = containers.get(row) {
13795                    let id = c.id.clone();
13796                    self.re_open_deposit_filter(id);
13797                }
13798            }
13799            S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
13800            S::SellNpcs { .. } => {
13801                let templates = self.re_template_candidates();
13802                let npcs = self.re_npc_candidates();
13803                if row == 0 {
13804                    if !crate::worker_route_editor::any_trade_npc_buys_route_item(
13805                        &self.state.npcs,
13806                        &templates,
13807                    ) {
13808                        self.state.push_log(
13809                            crate::worker_route_editor::sell_merchant_empty_reason(
13810                                None,
13811                                &self.state.npcs,
13812                                &templates,
13813                            ),
13814                        );
13815                        return;
13816                    }
13817                    self.re_open_sell_item(None);
13818                    return;
13819                }
13820                let Some(n) = npcs.get(row - 1) else {
13821                    return;
13822                };
13823                if !n.buys_route_item {
13824                    self.state
13825                        .push_log(crate::worker_route_editor::sell_merchant_empty_reason(
13826                            Some(n.id.as_str()),
13827                            &self.state.npcs,
13828                            &templates,
13829                        ));
13830                    return;
13831                }
13832                self.re_open_sell_item(Some(n.id.clone()));
13833            }
13834            S::SellItem { .. } => self.re_sell_item_activate(row),
13835            S::MarketListItem { .. } => self.re_market_list_item_activate(row),
13836            S::CraftBlueprint { .. } => {
13837                let bps = self.re_blueprint_ids();
13838                if let Some(bp) = bps.get(row) {
13839                    let stop = WorkerRouteStop::CraftAt {
13840                        device: "hand".into(),
13841                        blueprint: bp.clone(),
13842                        qty: None,
13843                    };
13844                    self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
13845                }
13846            }
13847            S::WaitEntry { ticks } => {
13848                let stop = WorkerRouteStop::Wait { wait_ticks: ticks };
13849                self.re_confirm_stop(stop, format!("wait {ticks}t"));
13850            }
13851            S::BedPicker { .. } => {
13852                let beds = self.re_bed_candidates();
13853                if let Some((id, name)) = beds.get(row) {
13854                    let (id, name) = (id.clone(), name.clone());
13855                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13856                        ed.lodging_container_id = Some(id.clone());
13857                        ed.sheet = S::Stops;
13858                    }
13859                    self.state
13860                        .push_log(format!("Route: rest bed set to {name}"));
13861                }
13862            }
13863            S::FarmPlotPicker { action, .. } => {
13864                let plots = self.re_farm_plot_candidates();
13865                let Some(plot) = plots.get(row).cloned() else {
13866                    return;
13867                };
13868                match action {
13869                    crate::worker_route_editor::FarmPlotAction::Cultivate => {
13870                        let label = plot_route_label(&plot);
13871                        self.re_confirm_stop(
13872                            WorkerRouteStop::CultivatePlot {
13873                                plot_id: plot.plot_id,
13874                            },
13875                            format!("cultivate {label}"),
13876                        );
13877                    }
13878                    crate::worker_route_editor::FarmPlotAction::Harvest => {
13879                        let label = plot_route_label(&plot);
13880                        self.re_confirm_stop(
13881                            WorkerRouteStop::HarvestPlot {
13882                                plot_id: plot.plot_id,
13883                            },
13884                            format!("harvest {label}"),
13885                        );
13886                    }
13887                    crate::worker_route_editor::FarmPlotAction::Plant => {
13888                        let seeds = self.re_farm_seed_candidates();
13889                        if seeds.is_empty() {
13890                            self.state.push_log(
13891                                "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
13892                            );
13893                            return;
13894                        }
13895                        self.re_open_sheet(S::FarmPlantSeed {
13896                            plot_id: plot.plot_id,
13897                            seeds,
13898                            index: 0,
13899                        });
13900                    }
13901                }
13902            }
13903            S::FarmPlantSeed { plot_id, seeds, .. } => {
13904                if let Some(seed) = seeds.get(row).cloned() {
13905                    self.re_confirm_stop(
13906                        WorkerRouteStop::PlantPlot {
13907                            plot_id,
13908                            seed_template: seed.clone(),
13909                        },
13910                        format!("plant {seed}"),
13911                    );
13912                }
13913            }
13914            S::WaypointMapPick => {}
13915        }
13916    }
13917
13918    fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
13919        use crate::worker_route_editor::RouteEditorSheet as S;
13920        if self.re_farm_plot_candidates().is_empty() {
13921            self.state
13922                .push_log("Route: no farmable plots visible — claim land or get farm access first");
13923            return;
13924        }
13925        self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
13926    }
13927
13928    fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
13929        self.state
13930            .property_plots
13931            .iter()
13932            .filter(|p| p.is_mine || p.may_farm)
13933            .cloned()
13934            .collect()
13935    }
13936
13937    /// Seed templates for a plant-plot route stop — does **not** require seeds on the
13938    /// player. Unions inventory, owned storage, withdraw lines already in the draft,
13939    /// and catalog `seed_for` templates so routes can withdraw-then-plant.
13940    fn re_farm_seed_candidates(&self) -> Vec<String> {
13941        let mut set = std::collections::BTreeSet::new();
13942        let looks_like_seed =
13943            |id: &str, catalog: &std::collections::HashMap<String, ItemCatalogEntryView>| {
13944                catalog.get(id).is_some_and(|e| e.is_farm_seed()) || id.ends_with("_seed")
13945            };
13946        for (id, _, _) in self.state.farm_seed_entries() {
13947            set.insert(id);
13948        }
13949        for c in &self.state.placed_containers {
13950            let mine = match (self.state.character_id, c.owner_character_id) {
13951                (Some(a), Some(b)) => a == b,
13952                _ => false,
13953            };
13954            if !mine {
13955                continue;
13956            }
13957            for s in &c.contents {
13958                if s.quantity > 0
13959                    && (s.props.contains_key("seed_for")
13960                        || looks_like_seed(&s.template_id, &self.state.item_catalog))
13961                {
13962                    set.insert(s.template_id.clone());
13963                }
13964            }
13965        }
13966        if let Some(ed) = self.state.worker_route_editor.as_ref() {
13967            for stop in &ed.stops {
13968                if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } =
13969                    stop
13970                {
13971                    for it in items {
13972                        if looks_like_seed(&it.template, &self.state.item_catalog) {
13973                            set.insert(it.template.clone());
13974                        }
13975                    }
13976                }
13977                if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
13978                    seed_template,
13979                    ..
13980                } = stop
13981                {
13982                    if !seed_template.is_empty() {
13983                        set.insert(seed_template.clone());
13984                    }
13985                }
13986            }
13987        }
13988        for (id, entry) in &self.state.item_catalog {
13989            if entry.is_farm_seed() {
13990                set.insert(id.clone());
13991            }
13992        }
13993        set.into_iter().collect()
13994    }
13995
13996    // ---- map clicks (sheet-scoped accelerators) ------------------------
13997
13998    /// Map click while the route editor is open. When a picker sheet is open
13999    /// the click feeds *that* sheet (chest clicks choose the withdraw/deposit
14000    /// container, NPC clicks the merchant, node clicks the harvest target);
14001    /// otherwise the click quick-adds the nearest target (context-aware).
14002    pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
14003        use crate::worker_route_editor as wre;
14004        use wre::RouteEditorSheet as S;
14005        if self.state.worker_route_editor.is_none() {
14006            return;
14007        }
14008        let sheet = self
14009            .state
14010            .worker_route_editor
14011            .as_ref()
14012            .map(|ed| ed.sheet.clone())
14013            .unwrap_or(S::Stops);
14014        match sheet {
14015            S::WaypointMapPick => {
14016                let (_, _, z) = self.state.player_position_with_z();
14017                let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
14018                self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
14019                // Stay in map-pick mode when adding (not editing) for fast pathing.
14020                let editing = self
14021                    .state
14022                    .worker_route_editor
14023                    .as_ref()
14024                    .is_some_and(|ed| ed.editing_index.is_some());
14025                if !editing {
14026                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
14027                        ed.sheet = S::WaypointMapPick;
14028                    }
14029                }
14030            }
14031            S::HarvestPicker { .. } => {
14032                if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
14033                    let mut log: Option<String> = None;
14034                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
14035                        let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
14036                            return;
14037                        };
14038                        let selected = if picked.contains(&node.id) {
14039                            picked.remove(&node.id);
14040                            false
14041                        } else {
14042                            picked.insert(node.id.clone());
14043                            true
14044                        };
14045                        log = Some(format!(
14046                            "Route: {} {}",
14047                            if selected { "selected" } else { "deselected" },
14048                            resource_node_route_label(node)
14049                        ));
14050                    }
14051                    if let Some(msg) = log {
14052                        self.state.push_log(msg);
14053                    }
14054                }
14055            }
14056            S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
14057                // Chest click picks (or switches) the withdraw source.
14058                let inside = self.state.effective_inside_building();
14059                if let Some(cid) = wre::pick_storage_container_at(
14060                    &self.state.placed_containers,
14061                    self.state.character_id,
14062                    x,
14063                    y,
14064                    inside.as_deref(),
14065                ) {
14066                    self.re_open_withdraw_items(cid);
14067                }
14068            }
14069            S::DepositContainers { .. } | S::DepositFilter { .. } => {
14070                let inside = self.state.effective_inside_building();
14071                if let Some(cid) = wre::pick_storage_container_at(
14072                    &self.state.placed_containers,
14073                    self.state.character_id,
14074                    x,
14075                    y,
14076                    inside.as_deref(),
14077                ) {
14078                    self.re_open_deposit_filter(cid);
14079                }
14080            }
14081            S::SellNpcs { .. } => {
14082                if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
14083                    self.re_open_sell_item(Some(npc_id));
14084                }
14085            }
14086            S::SellItem { .. } => {
14087                if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
14088                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
14089                        if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
14090                            *slot = Some(npc_id.clone());
14091                        }
14092                    }
14093                    self.state
14094                        .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
14095                }
14096            }
14097            // Stop list / other sheets: context-aware quick add.
14098            _ => self.worker_route_editor_quick_add_click(x, y),
14099        }
14100    }
14101
14102    /// Quick-add map click with no picker open: nearest of bed/chest/merchant/
14103    /// node wins; duplicates select the existing stop; a click on a merchant
14104    /// while a sell stop is selected pins it.
14105    fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
14106        use crate::worker_route_editor as wre;
14107        let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
14108            let dx = ax - bx;
14109            let dy = ay - by;
14110            (dx * dx + dy * dy).sqrt()
14111        };
14112
14113        // 1. Retarget the selected stop when the click names its target:
14114        //    sell stop → pin the merchant; withdraw stop → set the source chest.
14115        let selected_stop_kind = self
14116            .state
14117            .worker_route_editor
14118            .as_ref()
14119            .and_then(|ed| ed.stops.get(ed.selected_stop_index))
14120            .map(|s| match s {
14121                wre::WorkerRouteStop::TradeWith { .. } => 1,
14122                wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
14123                _ => 0,
14124            })
14125            .unwrap_or(0);
14126        if selected_stop_kind == 1 {
14127            if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
14128                if let Some(ed) = self.state.worker_route_editor.as_mut() {
14129                    ed.set_selected_trade_npc(npc_id.clone());
14130                }
14131                self.state
14132                    .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
14133                return;
14134            }
14135        }
14136        if selected_stop_kind == 2 {
14137            let inside = self.state.effective_inside_building();
14138            if let Some(cid) = wre::pick_storage_container_at(
14139                &self.state.placed_containers,
14140                self.state.character_id,
14141                x,
14142                y,
14143                inside.as_deref(),
14144            ) {
14145                let name = self
14146                    .state
14147                    .placed_containers
14148                    .iter()
14149                    .find(|c| c.id == cid)
14150                    .map(|c| c.display_name.clone())
14151                    .unwrap_or_else(|| "container".into());
14152                if let Some(ed) = self.state.worker_route_editor.as_mut() {
14153                    ed.set_selected_withdraw_container(cid.clone());
14154                }
14155                self.state
14156                    .push_log(format!("Route: withdraw source → {name}"));
14157                return;
14158            }
14159        }
14160
14161        // 2. Nearest candidate of any kind wins. Category order is the
14162        //    tie-break for exact overlaps (bed over chest over merchant over node).
14163        enum Target {
14164            Bed(String),
14165            Container(String),
14166            Npc(String, String),
14167            Node(String, String),
14168        }
14169        let mut best: Option<(f32, u8, Target)> = None;
14170        let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
14171            let better = match best {
14172                None => true,
14173                Some((bd, brank, _)) => {
14174                    d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank)
14175                }
14176            };
14177            if better {
14178                *best = Some((d, rank, t));
14179            }
14180        };
14181        let inside = self.state.effective_inside_building();
14182        if let Some(bed_id) = wre::pick_lodging_container_at(
14183            &self.state.placed_containers,
14184            self.state.character_id,
14185            x,
14186            y,
14187            inside.as_deref(),
14188        ) {
14189            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
14190                // A bed that's already the rest bed doubles as a storage chest —
14191                // a second click on it means "deposit here", not "set bed again".
14192                let already_bed =
14193                    self.state.worker_route_editor.as_ref().is_some_and(|ed| {
14194                        ed.lodging_container_id.as_deref() == Some(bed_id.as_str())
14195                    });
14196                if already_bed {
14197                    consider(
14198                        dist(x, y, c.x, c.y),
14199                        1,
14200                        Target::Container(bed_id),
14201                        &mut best,
14202                    );
14203                } else {
14204                    consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
14205                }
14206            }
14207        }
14208        if let Some(cid) = wre::pick_storage_container_at(
14209            &self.state.placed_containers,
14210            self.state.character_id,
14211            x,
14212            y,
14213            inside.as_deref(),
14214        ) {
14215            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
14216                consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
14217            }
14218        }
14219        if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
14220            if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
14221                consider(
14222                    dist(x, y, n.x, n.y),
14223                    2,
14224                    Target::Npc(npc_id, label),
14225                    &mut best,
14226                );
14227            }
14228        }
14229        if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
14230            let d = dist(x, y, node.x, node.y);
14231            let label = resource_node_route_label(node);
14232            consider(d, 3, Target::Node(node.id.clone(), label), &mut best);
14233        }
14234
14235        match best.map(|(_, _, t)| t) {
14236            Some(Target::Bed(bed_id)) => {
14237                let name = self
14238                    .state
14239                    .placed_containers
14240                    .iter()
14241                    .find(|c| c.id == bed_id)
14242                    .map(|c| c.display_name.clone())
14243                    .unwrap_or_else(|| "camp bed".into());
14244                if let Some(ed) = self.state.worker_route_editor.as_mut() {
14245                    ed.lodging_container_id = Some(bed_id.clone());
14246                }
14247                self.state
14248                    .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
14249            }
14250            Some(Target::Container(cid)) => {
14251                let name = self
14252                    .state
14253                    .placed_containers
14254                    .iter()
14255                    .find(|c| c.id == cid)
14256                    .map(|c| c.display_name.clone())
14257                    .unwrap_or_else(|| "container".into());
14258                let added = self
14259                    .state
14260                    .worker_route_editor
14261                    .as_mut()
14262                    .is_some_and(|ed| ed.append_deposit_at(&cid));
14263                if added {
14264                    self.state
14265                        .push_log(format!("Route: + deposit at {name} ({cid})"));
14266                } else {
14267                    self.state.push_log(format!(
14268                        "Route: {name} already in route — selected it (d to remove)"
14269                    ));
14270                }
14271            }
14272            Some(Target::Npc(npc_id, label)) => {
14273                // Quick-add sell: first storage template (the full sell flow
14274                // lives in the a → Sell sheet).
14275                let template = self.re_template_candidates().into_iter().next();
14276                let Some(template) = template else {
14277                    self.state.push_log(
14278                        "Route: no items in your storage to sell — stock a chest first".to_string(),
14279                    );
14280                    return;
14281                };
14282                let added = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
14283                    ed.append_trade_with(template.clone(), Some(npc_id.clone()), true)
14284                });
14285                if added {
14286                    self.state
14287                        .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
14288                } else {
14289                    self.state.push_log(format!(
14290                        "Route: {label} already sells {template} — selected it (d to remove)"
14291                    ));
14292                }
14293            }
14294            Some(Target::Node(id, label)) => {
14295                let added = self
14296                    .state
14297                    .worker_route_editor
14298                    .as_mut()
14299                    .is_some_and(|ed| ed.append_harvest_node(&id));
14300                if added {
14301                    self.state
14302                        .push_log(format!("Route: + harvest node {label}"));
14303                } else {
14304                    self.state.push_log(format!(
14305                        "Route: {label} already in route — selected it (d to remove)"
14306                    ));
14307                }
14308            }
14309            None => {}
14310        }
14311    }
14312
14313    pub fn worker_route_editor_select(&mut self, delta: i32) {
14314        let Some(ed) = self.state.worker_route_editor.as_mut() else {
14315            return;
14316        };
14317        if ed.stops.is_empty() {
14318            return;
14319        }
14320        let n = ed.stops.len() as i32;
14321        let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
14322        ed.selected_stop_index = next;
14323    }
14324
14325    pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
14326        let Some(ed) = self.state.worker_route_editor.as_mut() else {
14327            return;
14328        };
14329        if delta < 0 {
14330            ed.move_selected_up();
14331        } else if delta > 0 {
14332            ed.move_selected_down();
14333        }
14334    }
14335
14336    pub fn worker_route_editor_delete_selected(&mut self) {
14337        let removed = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
14338            let before = ed.stop_count();
14339            ed.remove_selected_stop();
14340            ed.stop_count() < before
14341        });
14342        if removed {
14343            self.state.push_log("Route: removed selected stop");
14344        }
14345    }
14346
14347    /// Clear all stops. Saving afterwards parks the worker in idle mode
14348    /// instead of leaving it in a broken job loop.
14349    pub fn worker_route_editor_clear_stops(&mut self) {
14350        let Some(ed) = self.state.worker_route_editor.as_mut() else {
14351            return;
14352        };
14353        if ed.stops.is_empty() {
14354            self.state
14355                .push_log("Route: already empty — s saves an idle worker".to_string());
14356            return;
14357        }
14358        ed.stops.clear();
14359        ed.selected_stop_index = 0;
14360        self.state.push_log(
14361            "Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string(),
14362        );
14363    }
14364
14365    pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
14366        if self.state.pending_worker_job_ack.is_some() {
14367            anyhow::bail!("route save still pending — wait for server ack");
14368        }
14369        let Some(ed) = self.state.worker_route_editor.clone() else {
14370            anyhow::bail!("route editor not open");
14371        };
14372        // Empty route = stand down: park the worker in idle mode rather than
14373        // erroring out or leaving it marching a ghost loop.
14374        let (job_yaml, idle) = if ed.stops.is_empty() {
14375            (ed.build_idle_job_yaml(), true)
14376        } else {
14377            (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
14378        };
14379        let worker_id = ed.worker_instance_id.clone();
14380        let route_view = if idle { None } else { Some(ed.to_route_view()) };
14381        let mode = if idle {
14382            flatland_protocol::WorkerModeView::Idle
14383        } else {
14384            flatland_protocol::WorkerModeView::JobLoop
14385        };
14386        let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
14387            .state
14388            .hired_workers
14389            .iter()
14390            .find(|w| w.instance_id == worker_id)
14391            .map(|w| {
14392                (
14393                    w.route.clone(),
14394                    w.mode,
14395                    w.step_label.clone(),
14396                    w.last_error.clone(),
14397                )
14398            })
14399            .unwrap_or((
14400                None,
14401                flatland_protocol::WorkerModeView::Idle,
14402                String::new(),
14403                None,
14404            ));
14405        self.seq += 1;
14406        let seq = self.seq;
14407        self.session
14408            .submit_intent(Intent::SetWorkerJob {
14409                entity_id: self.state.entity_id,
14410                worker_instance_id: worker_id.clone(),
14411                job_yaml,
14412                seq,
14413            })
14414            .await?;
14415        self.state.intents_sent += 1;
14416        if let Some(w) = self
14417            .state
14418            .hired_workers
14419            .iter_mut()
14420            .find(|w| w.instance_id == worker_id)
14421        {
14422            w.route = route_view;
14423            w.mode = mode;
14424            w.last_error = None;
14425            if idle {
14426                w.step_label.clear();
14427                w.route_stop_index = None;
14428            }
14429        }
14430        self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
14431            seq,
14432            worker_instance_id: worker_id,
14433            worker_label: ed.worker_label.clone(),
14434            idle,
14435            stop_count: ed.stops.len(),
14436            prev_route,
14437            prev_mode,
14438            prev_step_label,
14439            prev_last_error,
14440        });
14441        self.state.push_log(format!(
14442            "Route: saving for {}… (waiting for server)",
14443            ed.worker_label
14444        ));
14445        // Keep editor open until IntentAck; reject Interaction reverts optimistic state.
14446        Ok(())
14447    }
14448    pub fn quest_menu_move(&mut self, delta: i32) {
14449        let n = self.state.active_quest_entries().len();
14450        if n == 0 {
14451            return;
14452        }
14453        let idx = self.state.quest_menu_index as i32;
14454        self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
14455    }
14456
14457    pub fn quest_menu_page(&mut self, pages: i32) {
14458        let n = self.state.active_quest_entries().len();
14459        self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
14460    }
14461
14462    pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
14463        let Some(offer) = self.state.selected_quest_offer().cloned() else {
14464            anyhow::bail!("no quest offer");
14465        };
14466        self.seq += 1;
14467        let seq = self.seq;
14468        self.session
14469            .submit_intent(Intent::AcceptQuest {
14470                entity_id: self.state.entity_id,
14471                quest_id: offer.quest_id,
14472                seq,
14473            })
14474            .await?;
14475        self.state.intents_sent += 1;
14476        Ok(())
14477    }
14478
14479    pub fn quest_offer_move(&mut self, delta: i32) {
14480        self.state.move_quest_offer_selection(delta);
14481    }
14482
14483    pub fn quest_offer_decline(&mut self) {
14484        self.state.clear_quest_offers();
14485        if !self.state.show_npc_chat
14486            && !self.state.show_shop_menu
14487            && self.state.npc_verb_target.is_some()
14488        {
14489            self.state.show_npc_verb_menu = true;
14490        }
14491    }
14492
14493    pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
14494        if !self.state.show_quest_menu {
14495            return Ok(());
14496        }
14497        let active: Vec<_> = self
14498            .state
14499            .active_quest_entries()
14500            .into_iter()
14501            .cloned()
14502            .collect();
14503        let Some(entry) = active.get(self.state.quest_menu_index) else {
14504            return Ok(());
14505        };
14506        if self.state.quest_withdraw_confirm {
14507            if !entry.can_withdraw {
14508                anyhow::bail!("quest cannot be withdrawn");
14509            }
14510            self.seq += 1;
14511            let seq = self.seq;
14512            self.session
14513                .submit_intent(Intent::WithdrawQuest {
14514                    entity_id: self.state.entity_id,
14515                    quest_id: entry.quest_id.clone(),
14516                    seq,
14517                })
14518                .await?;
14519            self.state.intents_sent += 1;
14520            self.state.quest_withdraw_confirm = false;
14521            return Ok(());
14522        }
14523        self.seq += 1;
14524        let seq = self.seq;
14525        self.session
14526            .submit_intent(Intent::TrackQuest {
14527                entity_id: self.state.entity_id,
14528                quest_id: entry.quest_id.clone(),
14529                seq,
14530            })
14531            .await?;
14532        self.state.intents_sent += 1;
14533        Ok(())
14534    }
14535
14536    pub fn quest_request_withdraw(&mut self) {
14537        if self.state.show_quest_menu {
14538            self.state.quest_withdraw_confirm = true;
14539        }
14540    }
14541
14542    pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
14543        if !self.state.is_alive() {
14544            anyhow::bail!("you are dead");
14545        }
14546        let Some(catalog) = self.state.shop_catalog.clone() else {
14547            anyhow::bail!("no shop open");
14548        };
14549        self.seq += 1;
14550        let seq = self.seq;
14551        match self.state.shop_tab {
14552            ShopTab::Buy => {
14553                let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
14554                    anyhow::bail!("nothing selected");
14555                };
14556                if offer.already_owned {
14557                    anyhow::bail!("already owned");
14558                }
14559                self.session
14560                    .submit_intent(Intent::ShopBuy {
14561                        entity_id: self.state.entity_id,
14562                        npc_id: catalog.npc_id.clone(),
14563                        offer_id: offer.offer_id.clone(),
14564                        quantity: self.state.shop_quantity,
14565                        seq,
14566                    })
14567                    .await?;
14568            }
14569            ShopTab::Sell => {
14570                let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
14571                    anyhow::bail!("nothing to sell");
14572                };
14573                if line.quantity == 0 {
14574                    anyhow::bail!("you have no {}", line.label);
14575                }
14576                let quantity = self.state.shop_quantity.min(line.quantity).max(1);
14577                self.session
14578                    .submit_intent(Intent::ShopSell {
14579                        entity_id: self.state.entity_id,
14580                        npc_id: catalog.npc_id.clone(),
14581                        template_id: line.template_id.clone(),
14582                        quantity,
14583                        seq,
14584                    })
14585                    .await?;
14586            }
14587        }
14588        self.state.intents_sent += 1;
14589        Ok(())
14590    }
14591
14592    pub fn craft_menu_move(&mut self, delta: i32) {
14593        let n = self.state.craft_filtered_indices().len();
14594        if n == 0 {
14595            return;
14596        }
14597        let idx = self.state.craft_menu_index as i32;
14598        let next = (idx + delta).rem_euclid(n as i32);
14599        self.state.craft_menu_index = next as usize;
14600        self.state.clamp_craft_batch_quantity();
14601    }
14602
14603    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
14604        self.state.craft_batch_adjust_quantity(delta);
14605    }
14606
14607    pub fn craft_batch_set_max(&mut self) {
14608        self.state.craft_batch_set_max();
14609    }
14610
14611    pub fn craft_batch_set_min(&mut self) {
14612        self.state.craft_batch_set_min();
14613    }
14614
14615    pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
14616        let Some(blueprint) = self.state.craft_selected_blueprint().cloned() else {
14617            anyhow::bail!("no blueprints in this tab");
14618        };
14619        if !self.state.can_craft_blueprint(&blueprint) {
14620            let hint = self
14621                .state
14622                .craft_missing_hint(&blueprint)
14623                .unwrap_or_else(|| "missing materials".into());
14624            anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
14625        }
14626        let count = self.state.craft_batch_quantity;
14627        let max = self.state.max_craft_batches(&blueprint);
14628        if max == 0 {
14629            anyhow::bail!("cannot craft {}", blueprint.label);
14630        }
14631        let batches = count.min(max);
14632        self.craft(&blueprint.id, Some(batches)).await?;
14633        // Keep the craft menu open so progress / blockers stay visible.
14634        Ok(())
14635    }
14636
14637    pub async fn move_by(
14638        &mut self,
14639        forward: f32,
14640        strafe: f32,
14641        vertical: f32,
14642        sprint: bool,
14643        sneak: bool,
14644    ) -> anyhow::Result<()> {
14645        if !self.state.is_alive() {
14646            anyhow::bail!("you are dead");
14647        }
14648        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
14649            self.last_move_forward = forward;
14650            self.last_move_strafe = strafe;
14651        }
14652        self.seq += 1;
14653        self.session
14654            .submit_intent(Intent::Move {
14655                entity_id: self.state.entity_id,
14656                forward,
14657                strafe,
14658                vertical,
14659                sprint: sprint && !sneak,
14660                sneak,
14661                seq: self.seq,
14662            })
14663            .await?;
14664        self.state.intents_sent += 1;
14665        Ok(())
14666    }
14667
14668    pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
14669        if !self.state.connected {
14670            crate::harvest_trace!("harvest_nearest rejected: not connected");
14671            anyhow::bail!("not connected");
14672        }
14673        if !self.state.is_alive() {
14674            crate::harvest_trace!("harvest_nearest rejected: player dead");
14675            anyhow::bail!("you are dead");
14676        }
14677        if self.state.harvest_in_progress {
14678            if self.state.harvest_state_stale() {
14679                self.state.clear_harvest_state();
14680            } else {
14681                anyhow::bail!("already harvesting");
14682            }
14683        }
14684        let (px, py) = self
14685            .state
14686            .player
14687            .as_ref()
14688            .map(|p| (p.transform.position.x, p.transform.position.y))
14689            .unwrap_or((0.0, 0.0));
14690
14691        let available = self
14692            .state
14693            .resource_nodes
14694            .iter()
14695            .filter(|n| !n.harvest_off)
14696            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14697            .count();
14698        let node_id = self
14699            .state
14700            .resource_nodes
14701            .iter()
14702            .filter(|n| !n.harvest_off)
14703            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14704            .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
14705            .min_by(|a, b| {
14706                let da = distance(px, py, a.x, a.y);
14707                let db = distance(px, py, b.x, b.y);
14708                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
14709            })
14710            .map(|n| n.id.clone());
14711
14712        let Some(node_id) = node_id else {
14713            let has_loot = self
14714                .state
14715                .ground_drops
14716                .iter()
14717                .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14718            if has_loot {
14719                return self.pickup_nearest().await;
14720            }
14721            anyhow::bail!(
14722                "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
14723            );
14724        };
14725
14726        self.seq += 1;
14727        let seq = self.seq;
14728        crate::harvest_trace!(
14729            entity_id = self.state.entity_id,
14730            node_id = %node_id,
14731            seq,
14732            px,
14733            py,
14734            available_nodes = available,
14735            "submitting harvest intent"
14736        );
14737        self.session
14738            .submit_intent(Intent::Harvest {
14739                entity_id: self.state.entity_id,
14740                node_id,
14741                seq,
14742            })
14743            .await?;
14744        self.state.intents_sent += 1;
14745        self.state.harvest_in_progress = true;
14746        self.state.harvest_started_at = Some(Instant::now());
14747        self.state.push_log("Harvesting…");
14748        crate::harvest_trace!(
14749            entity_id = self.state.entity_id,
14750            seq,
14751            "harvest intent queued to session"
14752        );
14753        Ok(())
14754    }
14755
14756    pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
14757        if !self.state.is_alive() {
14758            anyhow::bail!("you are dead");
14759        }
14760        let blueprint_id = self
14761            .state
14762            .blueprints
14763            .iter()
14764            .find(|bp| self.state.can_craft_blueprint(bp))
14765            .map(|bp| bp.id.clone())
14766            .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
14767        self.craft(&blueprint_id, None).await
14768    }
14769
14770    pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
14771        if !self.state.is_alive() {
14772            anyhow::bail!("you are dead");
14773        }
14774        self.seq += 1;
14775        self.session
14776            .submit_intent(Intent::Craft {
14777                entity_id: self.state.entity_id,
14778                blueprint_id: blueprint_id.to_string(),
14779                count,
14780                seq: self.seq,
14781            })
14782            .await?;
14783        self.state.intents_sent += 1;
14784        let (label, batches) = self
14785            .state
14786            .blueprints
14787            .iter()
14788            .find(|b| b.id == blueprint_id)
14789            .map(|b| {
14790                let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
14791                (b.label.as_str(), n)
14792            })
14793            .unwrap_or((blueprint_id, count.unwrap_or(1)));
14794        self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
14795        self.state.craft_channel_blueprint_id = Some(blueprint_id.to_string());
14796        self.state.craft_channel_seen = false;
14797        Ok(())
14798    }
14799
14800    pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
14801        if !self.state.is_alive() {
14802            anyhow::bail!("you are dead");
14803        }
14804        let target_id = if let Some(primary) = self.state.probe_use_world().primary {
14805            if primary.kind.cascade_stage() == 0 && primary.in_range {
14806                primary.id
14807            } else {
14808                match self.state.nearest_interact_target() {
14809                    Some(id) => id,
14810                    None => anyhow::bail!("nothing to interact with nearby"),
14811                }
14812            }
14813        } else {
14814            match self.state.nearest_interact_target() {
14815                Some(id) => id,
14816                None => anyhow::bail!("nothing to interact with nearby"),
14817            }
14818        };
14819        if self.state.npcs.iter().any(|n| n.id == target_id) {
14820            self.state.show_npc_verb_menu = true;
14821            self.state.npc_verb_target = Some(target_id);
14822            self.state.npc_verb_index = 0;
14823            self.state.npc_verb_notice = None;
14824            return Ok(());
14825        }
14826        if self
14827            .state
14828            .hired_workers
14829            .iter()
14830            .any(|w| w.instance_id == target_id)
14831        {
14832            return self.open_workers_menu_for(&target_id).await;
14833        }
14834        if let Ok(peer_id) = target_id.parse::<EntityId>() {
14835            if self
14836                .state
14837                .hired_workers
14838                .iter()
14839                .any(|w| w.entity_id == peer_id)
14840            {
14841                if let Some(w) = self
14842                    .state
14843                    .hired_workers
14844                    .iter()
14845                    .find(|w| w.entity_id == peer_id)
14846                {
14847                    let id = w.instance_id.clone();
14848                    return self.open_workers_menu_for(&id).await;
14849                }
14850            }
14851            if let Some(entity) = self
14852                .state
14853                .entities
14854                .iter()
14855                .find(|e| e.id == peer_id && e.id != self.state.entity_id)
14856            {
14857                self.state.player_verbs.open_for(peer_id, &entity.label);
14858                return Ok(());
14859            }
14860        }
14861        self.seq += 1;
14862        self.session
14863            .submit_intent(Intent::Interact {
14864                entity_id: self.state.entity_id,
14865                target_id: target_id.clone(),
14866                seq: self.seq,
14867            })
14868            .await?;
14869        self.state.intents_sent += 1;
14870        Ok(())
14871    }
14872
14873    /// Context-sensitive world use: loot → interact/door → chest → plot farm → harvest.
14874    ///
14875    /// After loot-first, follows [`crate::UseWorldKind::cascade_stage`] so HUD probe
14876    /// and `f` never diverge (doors beat lodging/chest pickup indoors).
14877    pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
14878        if !self.state.is_alive() {
14879            anyhow::bail!("you are dead");
14880        }
14881        let (px, py) = self.state.player_position();
14882        let has_loot = self
14883            .state
14884            .ground_drops
14885            .iter()
14886            .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14887        if has_loot {
14888            return self.pickup_nearest().await;
14889        }
14890
14891        // Align with probe cascade: interact (doors/NPCs/boards) → chest → harvest.
14892        if let Some(primary) = self.state.probe_use_world().primary {
14893            match primary.kind.cascade_stage() {
14894                0 => return self.interact_nearest().await,
14895                2 => return self.pickup_nearest_container().await,
14896                3 => return self.harvest_nearest().await,
14897                _ => {}
14898            }
14899        }
14900
14901        if let Some(plot) = self.state.my_plot_under_player().cloned() {
14902            // Double-tap f within 1.2s sells; otherwise farm the plot.
14903            const SELL_WINDOW: Duration = Duration::from_millis(1200);
14904            let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
14905                && self
14906                    .state
14907                    .sell_plot_armed_at
14908                    .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
14909            if sell_armed {
14910                return self.confirm_sell_plot_to_crown(plot.plot_id).await;
14911            }
14912            self.state.sell_plot_confirm = None;
14913            self.state.sell_plot_armed_at = None;
14914
14915            // Prefer farm actions on your own land unless an NPC/player/door/board
14916            // is in interact range (those still win).
14917            let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
14918                self.state.npcs.iter().any(|n| n.id == id)
14919                    || self.state.hired_workers.iter().any(|w| w.instance_id == id)
14920                    || self.state.doors.iter().any(|d| d.id == id)
14921                    || self.state.interactables.iter().any(|i| {
14922                        i.id == id
14923                            && matches!(i.kind.as_str(), "quest_board" | "well" | "exit" | "enter")
14924                    })
14925                    || id.parse::<EntityId>().is_ok_and(|eid| {
14926                        self.state
14927                            .entities
14928                            .iter()
14929                            .any(|e| e.id == eid && e.id != self.state.entity_id)
14930                    })
14931            });
14932            if !blocking_interact {
14933                // On your plot, `f` is harvest only — use `c` / `p` for till and plant.
14934                match self.harvest_nearest().await {
14935                    Ok(()) => return Ok(()),
14936                    Err(err) => {
14937                        let msg = err.to_string();
14938                        if !(msg.contains("no harvestable")
14939                            || msg.contains("press p")
14940                            || msg.contains("press f")
14941                            || msg.contains("nothing"))
14942                        {
14943                            return Err(err);
14944                        }
14945                    }
14946                }
14947                return Ok(());
14948            }
14949        }
14950        if self.state.nearest_interact_target().is_some() {
14951            return self.interact_nearest().await;
14952        }
14953        // Prefer a clear "move closer" when a board is visible but out of reach,
14954        // instead of silently falling through to harvest.
14955        if let Some((label, dist)) = self.state.nearest_quest_board() {
14956            if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
14957                anyhow::bail!(
14958                    "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
14959                );
14960            }
14961        }
14962
14963        match self.harvest_nearest().await {
14964            Ok(()) => Ok(()),
14965            Err(err) => {
14966                let msg = err.to_string();
14967                if msg.contains("no harvestable")
14968                    || msg.contains("press p")
14969                    || msg.contains("press f")
14970                {
14971                    anyhow::bail!(
14972                        "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
14973                    );
14974                }
14975                Err(err)
14976            }
14977        }
14978    }
14979
14980    /// Enter claim-mode footprint editor on unclaimed crown land (`k`).
14981    pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
14982        if !self.state.is_alive() {
14983            anyhow::bail!("you are dead");
14984        }
14985        if self.state.claim_mode.is_some() {
14986            anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
14987        }
14988        let zone = self
14989            .state
14990            .free_property_zone_under_player()
14991            .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
14992        let zone_id = zone.id.clone();
14993        let label = zone
14994            .label
14995            .as_deref()
14996            .filter(|s| !s.trim().is_empty())
14997            .unwrap_or(zone.id.as_str())
14998            .to_string();
14999        self.enter_claim_mode(&zone_id);
15000        self.state.push_log(format!(
15001            "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
15002        ));
15003        Ok(())
15004    }
15005
15006    /// Enter claim mode over a property zone (default 4×4 or min size).
15007    pub fn enter_claim_mode(&mut self, zone_id: &str) {
15008        let Some(zone) = self
15009            .state
15010            .property_zones
15011            .iter()
15012            .find(|z| z.id == zone_id)
15013            .cloned()
15014        else {
15015            self.state.push_log("unknown property zone");
15016            return;
15017        };
15018        self.state.sell_plot_confirm = None;
15019        self.state.sell_plot_armed_at = None;
15020        let min_area = self
15021            .state
15022            .property_plot_settings
15023            .as_ref()
15024            .map(|s| s.min_plot_area_m2)
15025            .unwrap_or(4.0)
15026            .max(1.0);
15027        let min_side = min_area.sqrt().ceil().max(1.0) as u32;
15028        let side = 4u32.max(min_side);
15029        let (px, py) = self.state.player_position();
15030        let anchor_x = px.floor();
15031        let anchor_y = py.floor();
15032        self.state.claim_mode = Some(ClaimModeState {
15033            zone_id: zone.id.clone(),
15034            width_m: side,
15035            height_m: side,
15036            anchor_x,
15037            anchor_y,
15038        });
15039        let label = zone
15040            .label
15041            .as_deref()
15042            .filter(|s| !s.trim().is_empty())
15043            .unwrap_or(zone.id.as_str());
15044        self.state.push_log(format!(
15045            "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
15046        ));
15047    }
15048
15049    pub fn cancel_claim_mode(&mut self) {
15050        if self.state.claim_mode.take().is_some() {
15051            self.state.push_log("Claim cancelled");
15052        }
15053    }
15054
15055    /// Enter relocate mode for a placed container (1×1 ghost).
15056    pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
15057        if !self.state.is_alive() {
15058            anyhow::bail!("you are dead");
15059        }
15060        if self.state.relocate_mode.is_some() {
15061            anyhow::bail!("already relocating — Enter confirm, Esc cancel");
15062        }
15063        if self.state.claim_mode.is_some() {
15064            anyhow::bail!("finish or cancel claim mode first");
15065        }
15066        let chest = self
15067            .state
15068            .placed_containers
15069            .iter()
15070            .find(|c| c.id == container_id)
15071            .cloned()
15072            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
15073        let (px, py) = self.state.player_position();
15074        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
15075            anyhow::bail!("too far from {}", chest.display_name);
15076        }
15077        if chest.locked && !chest.accessible {
15078            anyhow::bail!(
15079                "need the matching key for {} before moving it",
15080                chest.display_name
15081            );
15082        }
15083        let label = if chest.display_name.trim().is_empty() {
15084            chest.template_id.clone()
15085        } else {
15086            chest.display_name.clone()
15087        };
15088        self.state.relocate_mode = Some(RelocateModeState {
15089            container_id: chest.id.clone(),
15090            label: label.clone(),
15091            cursor_x: chest.x.floor() + 0.5,
15092            cursor_y: chest.y.floor() + 0.5,
15093        });
15094        self.state.push_log(format!(
15095            "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
15096        ));
15097        Ok(())
15098    }
15099
15100    /// World `Shift+m`: relocate nearest owned/accessible placed container.
15101    pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
15102        let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
15103            anyhow::bail!("no chest nearby to relocate");
15104        };
15105        if chest.locked && !chest.accessible {
15106            anyhow::bail!(
15107                "need the matching key for {} before moving it",
15108                chest.display_name
15109            );
15110        }
15111        // Prefer own chests: if owner is set and we're not the owner, skip unless accessible unlocked?
15112        // Server enforces ownership; client starts on nearest accessible chest in range.
15113        self.begin_relocate_container(&chest.id)
15114    }
15115
15116    pub fn cancel_relocate_mode(&mut self) {
15117        if self.state.relocate_mode.take().is_some() {
15118            self.state.push_log("Relocate cancelled");
15119        }
15120    }
15121
15122    pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
15123        let Some(mode) = self.state.relocate_mode.as_mut() else {
15124            return;
15125        };
15126        let max_x = self.state.world_width_m.max(1.0);
15127        let max_y = self.state.world_height_m.max(1.0);
15128        let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
15129        let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
15130        mode.cursor_x = nx.floor() + 0.5;
15131        mode.cursor_y = ny.floor() + 0.5;
15132    }
15133
15134    pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
15135        let Some(mode) = self.state.relocate_mode.as_mut() else {
15136            return;
15137        };
15138        let max_x = self.state.world_width_m.max(1.0);
15139        let max_y = self.state.world_height_m.max(1.0);
15140        mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
15141        mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
15142    }
15143
15144    pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
15145        if !self.state.is_alive() {
15146            anyhow::bail!("you are dead");
15147        }
15148        let Some(mode) = self.state.relocate_mode.clone() else {
15149            anyhow::bail!("not relocating");
15150        };
15151        let (px, py) = self.state.player_position();
15152        let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
15153        if dist > 8.0 {
15154            anyhow::bail!("destination too far (max 8 m)");
15155        }
15156        self.seq += 1;
15157        self.session
15158            .submit_intent(Intent::MovePlacedContainer {
15159                entity_id: self.state.entity_id,
15160                container_id: mode.container_id.clone(),
15161                x: mode.cursor_x,
15162                y: mode.cursor_y,
15163                seq: self.seq,
15164            })
15165            .await?;
15166        self.state.intents_sent += 1;
15167        self.state.relocate_mode = None;
15168        self.state.push_log(format!("Moving {}…", mode.label));
15169        Ok(())
15170    }
15171
15172    pub fn claim_set_preset(&mut self, w: u32, h: u32) {
15173        let Some(mode) = self.state.claim_mode.as_mut() else {
15174            return;
15175        };
15176        mode.width_m = w.max(1);
15177        mode.height_m = h.max(1);
15178    }
15179
15180    pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
15181        let Some(mode) = self.state.claim_mode.as_mut() else {
15182            return;
15183        };
15184        let w = (mode.width_m as i32 + dw).max(1) as u32;
15185        let h = (mode.height_m as i32 + dh).max(1) as u32;
15186        mode.width_m = w;
15187        mode.height_m = h;
15188    }
15189
15190    /// Nudge the claim footprint SW corner one meter cell (WASD / arrows).
15191    pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
15192        let Some(mode) = self.state.claim_mode.as_mut() else {
15193            return;
15194        };
15195        let max_x = self.state.world_width_m.max(1.0);
15196        let max_y = self.state.world_height_m.max(1.0);
15197        let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
15198        let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
15199        mode.anchor_x = nx.floor();
15200        mode.anchor_y = ny.floor();
15201    }
15202
15203    pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
15204        if !self.state.is_alive() {
15205            anyhow::bail!("you are dead");
15206        }
15207        let Some(mode) = self.state.claim_mode.clone() else {
15208            anyhow::bail!("not in claim mode");
15209        };
15210        let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
15211            self.state.claim_quote()
15212        else {
15213            anyhow::bail!("cannot quote claim");
15214        };
15215        if !valid {
15216            anyhow::bail!(reason);
15217        }
15218        if !can_afford {
15219            anyhow::bail!(
15220                "not enough copper (need {})",
15221                crate::currency::format_copper(purchase)
15222            );
15223        }
15224        let (x0, y0, x1, y1) = self
15225            .state
15226            .claim_footprint_rect()
15227            .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
15228        let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
15229        self.seq += 1;
15230        self.session
15231            .submit_intent(Intent::BuyPlot {
15232                entity_id: self.state.entity_id,
15233                zone_id: mode.zone_id,
15234                x0,
15235                y0,
15236                x1,
15237                y1,
15238                seq: self.seq,
15239            })
15240            .await?;
15241        self.state.intents_sent += 1;
15242        self.state.claim_mode = None;
15243        self.state.push_log(format!(
15244            "Buying plot for {}",
15245            crate::currency::format_copper(purchase)
15246        ));
15247        Ok(())
15248    }
15249
15250    pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
15251        if !self.state.is_alive() {
15252            anyhow::bail!("you are dead");
15253        }
15254        let zone_id = self
15255            .state
15256            .claim_mode
15257            .as_ref()
15258            .map(|m| m.zone_id.clone())
15259            .or_else(|| {
15260                self.state
15261                    .free_property_zone_under_player()
15262                    .map(|z| z.id.clone())
15263            })
15264            .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
15265        self.seq += 1;
15266        self.session
15267            .submit_intent(Intent::BuyPlotAllFree {
15268                entity_id: self.state.entity_id,
15269                zone_id,
15270                seq: self.seq,
15271            })
15272            .await?;
15273        self.state.intents_sent += 1;
15274        self.state.claim_mode = None;
15275        self.state.push_log("Claiming largest free plot…");
15276        Ok(())
15277    }
15278
15279    pub async fn confirm_sell_plot_to_crown(&mut self, plot_id: uuid::Uuid) -> anyhow::Result<()> {
15280        if !self.state.is_alive() {
15281            anyhow::bail!("you are dead");
15282        }
15283        self.seq += 1;
15284        self.session
15285            .submit_intent(Intent::SellPlotToCrown {
15286                entity_id: self.state.entity_id,
15287                plot_id,
15288                seq: self.seq,
15289            })
15290            .await?;
15291        self.state.intents_sent += 1;
15292        self.state.sell_plot_confirm = None;
15293        self.state.sell_plot_armed_at = None;
15294        self.state.push_log("Selling plot to the crown…");
15295        Ok(())
15296    }
15297
15298    pub async fn set_plot_farm_public(
15299        &mut self,
15300        plot_id: uuid::Uuid,
15301        public: bool,
15302        public_tax_discount_bps: u32,
15303    ) -> anyhow::Result<()> {
15304        self.seq += 1;
15305        self.session
15306            .submit_intent(Intent::SetPlotFarmPublic {
15307                entity_id: self.state.entity_id,
15308                plot_id,
15309                public,
15310                public_tax_discount_bps,
15311                seq: self.seq,
15312            })
15313            .await?;
15314        self.state.intents_sent += 1;
15315        Ok(())
15316    }
15317
15318    pub async fn plot_farm_allow_upsert(
15319        &mut self,
15320        plot_id: uuid::Uuid,
15321        character_id: Option<uuid::Uuid>,
15322        character_name: String,
15323        tax_discount_bps: u32,
15324    ) -> anyhow::Result<()> {
15325        self.seq += 1;
15326        self.session
15327            .submit_intent(Intent::PlotFarmAllowUpsert {
15328                entity_id: self.state.entity_id,
15329                plot_id,
15330                character_id,
15331                character_name,
15332                tax_discount_bps,
15333                seq: self.seq,
15334            })
15335            .await?;
15336        self.state.intents_sent += 1;
15337        Ok(())
15338    }
15339
15340    pub async fn plot_farm_allow_remove(
15341        &mut self,
15342        plot_id: uuid::Uuid,
15343        character_id: uuid::Uuid,
15344    ) -> anyhow::Result<()> {
15345        self.seq += 1;
15346        self.session
15347            .submit_intent(Intent::PlotFarmAllowRemove {
15348                entity_id: self.state.entity_id,
15349                plot_id,
15350                character_id,
15351                seq: self.seq,
15352            })
15353            .await?;
15354        self.state.intents_sent += 1;
15355        Ok(())
15356    }
15357
15358    pub fn open_farm_access_panel(&mut self) {
15359        let Some(plot) = self.state.my_plot_under_player() else {
15360            self.state
15361                .push_log("Stand on your deed plot to manage farm access");
15362            return;
15363        };
15364        self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
15365        self.state.farm_access_index = 0;
15366        self.state.show_farm_access = true;
15367    }
15368
15369    pub fn close_farm_access_panel(&mut self) {
15370        self.state.show_farm_access = false;
15371        self.state.farm_access_name_draft.clear();
15372        self.state.farm_access_index = 0;
15373    }
15374
15375    pub fn farm_access_move(&mut self, delta: i32) {
15376        let n = self.farm_access_row_count().max(1);
15377        let idx = self.state.farm_access_index as i32 + delta;
15378        self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
15379    }
15380
15381    pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
15382        let Some(plot) = self.state.my_plot_under_player() else {
15383            return vec![FarmAccessRow::PublicToggle];
15384        };
15385        let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
15386        for g in &plot.farm_allow {
15387            rows.push(FarmAccessRow::AllowRemove {
15388                character_id: g.character_id,
15389                label: if g.character_label.trim().is_empty() {
15390                    g.character_id.to_string()[..8].to_string()
15391                } else {
15392                    g.character_label.clone()
15393                },
15394                tax_discount_bps: g.tax_discount_bps,
15395            });
15396        }
15397        for e in &self.state.entities {
15398            if e.id == self.state.entity_id || e.label.trim().is_empty() {
15399                continue;
15400            }
15401            if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
15402                continue;
15403            }
15404            if self
15405                .state
15406                .npcs
15407                .iter()
15408                .any(|n| n.id == e.label || n.label == e.label)
15409            {
15410                continue;
15411            }
15412            if plot
15413                .farm_allow
15414                .iter()
15415                .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
15416            {
15417                continue;
15418            }
15419            rows.push(FarmAccessRow::NearbyAdd {
15420                name: e.label.clone(),
15421            });
15422        }
15423        rows
15424    }
15425
15426    pub fn farm_access_row_count(&self) -> usize {
15427        self.farm_access_rows().len().max(1)
15428    }
15429
15430    pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
15431        let Some(plot) = self.state.my_plot_under_player().cloned() else {
15432            self.close_farm_access_panel();
15433            return Ok(());
15434        };
15435        let rows = self.farm_access_rows();
15436        let Some(row) = rows.get(self.state.farm_access_index) else {
15437            return Ok(());
15438        };
15439        match row {
15440            FarmAccessRow::PublicToggle => {
15441                self.set_plot_farm_public(
15442                    plot.plot_id,
15443                    !plot.farm_public,
15444                    plot.public_tax_discount_bps,
15445                )
15446                .await
15447            }
15448            FarmAccessRow::PublicDiscount => Ok(()),
15449            FarmAccessRow::AllowRemove { character_id, .. } => {
15450                self.plot_farm_allow_remove(plot.plot_id, *character_id)
15451                    .await
15452            }
15453            FarmAccessRow::NearbyAdd { name } => {
15454                let disc = self
15455                    .state
15456                    .farm_access_discount_bps
15457                    .max(plot.public_tax_discount_bps);
15458                self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
15459                    .await
15460            }
15461        }
15462    }
15463
15464    pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
15465        let Some(plot) = self.state.my_plot_under_player().cloned() else {
15466            return Ok(());
15467        };
15468        let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
15469        self.state.farm_access_discount_bps = next;
15470        self.state.farm_access_index = 1;
15471        self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
15472            .await
15473    }
15474
15475    /// Till the cell under you on a farmable plot (`c`).
15476    pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
15477        if self.state.farmable_plot_under_player().is_none() {
15478            anyhow::bail!("stand on a farmable plot to cultivate");
15479        }
15480        let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
15481            let (px, py) = self.state.player_position();
15482            if self
15483                .state
15484                .terrain_at(px, py)
15485                .is_some_and(|k| k == TerrainKindView::Tilled)
15486            {
15487                anyhow::bail!("already tilled — stand on bare soil and press c");
15488            }
15489            anyhow::bail!("cannot till this cell — move onto soil on your plot");
15490        };
15491        self.cultivate_at(tx, ty).await
15492    }
15493
15494    /// Plant seeds on empty tilled soil under you (`p`).
15495    pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
15496        if self.state.farmable_plot_under_player().is_none() {
15497            anyhow::bail!("stand on a farmable plot to plant");
15498        }
15499        if !self.state.underfoot_free_tilled_plant_slot() {
15500            anyhow::bail!("stand on empty tilled soil and press p");
15501        }
15502        let seeds = self.state.farm_seed_entries();
15503        if seeds.is_empty() {
15504            anyhow::bail!("no seeds in inventory — buy seeds from Eli");
15505        }
15506        if seeds.len() == 1 {
15507            return self.plant_seeds(seeds[0].0.clone(), 1).await;
15508        }
15509        self.open_plant_menu();
15510        Ok(())
15511    }
15512
15513    /// Open the wall/roof build picker on the owned plot underfoot (`B`).
15514    pub fn open_plot_build_menu(&mut self) -> anyhow::Result<()> {
15515        let Some(plot) = self.state.my_plot_under_player() else {
15516            anyhow::bail!("stand on your plot to build");
15517        };
15518        if plot.building_id.is_some() {
15519            anyhow::bail!("this plot already has a building");
15520        }
15521        let building_now = self
15522            .state
15523            .timed_channel
15524            .as_ref()
15525            .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
15526        if !building_now && self.state.building_materials.is_empty() {
15527            anyhow::bail!("no building materials loaded — wait a moment and try again");
15528        }
15529        self.state.show_plot_build_menu = true;
15530        self.state.show_craft_menu = false;
15531        self.state.show_shop_menu = false;
15532        self.state.shop_catalog = None;
15533        self.state.show_stats = false;
15534        self.state.show_inventory_menu = false;
15535        self.state.plot_build_focus_wall = true;
15536        let walls = self.state.plot_build_wall_options().len();
15537        let roofs = self.state.plot_build_roof_options().len();
15538        if walls > 0 {
15539            self.state.plot_build_wall_index = self.state.plot_build_wall_index.min(walls - 1);
15540        } else {
15541            self.state.plot_build_wall_index = 0;
15542        }
15543        if roofs > 0 {
15544            self.state.plot_build_roof_index = self.state.plot_build_roof_index.min(roofs - 1);
15545        } else {
15546            self.state.plot_build_roof_index = 0;
15547        }
15548        Ok(())
15549    }
15550
15551    pub fn close_plot_build_menu(&mut self) {
15552        self.state.show_plot_build_menu = false;
15553    }
15554
15555    pub fn plot_build_menu_move(&mut self, delta: i32) {
15556        let walls = self.state.plot_build_wall_options();
15557        let roofs = self.state.plot_build_roof_options();
15558        if self.state.plot_build_focus_wall {
15559            if walls.is_empty() {
15560                return;
15561            }
15562            let n = walls.len() as i32;
15563            let cur = self.state.plot_build_wall_index as i32;
15564            self.state.plot_build_wall_index = ((cur + delta).rem_euclid(n)) as usize;
15565        } else {
15566            if roofs.is_empty() {
15567                return;
15568            }
15569            let n = roofs.len() as i32;
15570            let cur = self.state.plot_build_roof_index as i32;
15571            self.state.plot_build_roof_index = ((cur + delta).rem_euclid(n)) as usize;
15572        }
15573    }
15574
15575    pub fn plot_build_menu_toggle_focus(&mut self) {
15576        self.state.plot_build_focus_wall = !self.state.plot_build_focus_wall;
15577    }
15578
15579    /// Confirm selection from the B menu.
15580    pub async fn plot_build_menu_confirm(&mut self) -> anyhow::Result<()> {
15581        let wall = self
15582            .state
15583            .plot_build_selected_wall()
15584            .ok_or_else(|| anyhow::anyhow!("pick a wall material"))?
15585            .id
15586            .clone();
15587        let roof = self
15588            .state
15589            .plot_build_selected_roof()
15590            .ok_or_else(|| anyhow::anyhow!("pick a roof material"))?
15591            .id
15592            .clone();
15593        // Keep the B menu open so it switches to the in-progress panel.
15594        self.start_plot_build(&wall, &roof).await
15595    }
15596
15597    /// Cancel an in-progress plot build (materials returned).
15598    pub async fn plot_build_menu_cancel_build(&mut self) -> anyhow::Result<()> {
15599        self.seq += 1;
15600        self.session
15601            .submit_intent(Intent::CancelPlotBuild {
15602                entity_id: self.state.entity_id,
15603                seq: self.seq,
15604            })
15605            .await?;
15606        self.state.intents_sent += 1;
15607        Ok(())
15608    }
15609
15610    /// Start timed shell craft on the plot's solid tilled rectangle.
15611    pub async fn start_plot_build(
15612        &mut self,
15613        wall_material_id: &str,
15614        roof_material_id: &str,
15615    ) -> anyhow::Result<()> {
15616        let Some(plot) = self.state.my_plot_under_player() else {
15617            anyhow::bail!("stand on your plot to build");
15618        };
15619        if plot.building_id.is_some() {
15620            anyhow::bail!("this plot already has a building");
15621        }
15622        let plot_id = plot.plot_id;
15623        self.seq += 1;
15624        self.session
15625            .submit_intent(Intent::StartPlotBuild {
15626                entity_id: self.state.entity_id,
15627                plot_id,
15628                wall_material_id: wall_material_id.to_string(),
15629                roof_material_id: roof_material_id.to_string(),
15630                seq: self.seq,
15631            })
15632            .await?;
15633        self.state.intents_sent += 1;
15634        Ok(())
15635    }
15636
15637    /// Lock/unlock nearest exterior door when you hold its key (`l` / `L`).
15638    pub async fn toggle_nearby_door_lock(&mut self) -> anyhow::Result<()> {
15639        let (px, py) = self.state.player_position();
15640        let mut best: Option<(f32, String, bool)> = None;
15641        for d in &self.state.doors {
15642            if d.lock_id.is_none() {
15643                continue;
15644            }
15645            let dist = (d.x - px).hypot(d.y - py);
15646            if dist > DOOR_INTERACTION_RADIUS_M {
15647                continue;
15648            }
15649            if best.as_ref().is_none_or(|(bd, _, _)| dist < *bd) {
15650                best = Some((dist, d.id.clone(), d.locked));
15651            }
15652        }
15653        let Some((_, door_id, locked_now)) = best else {
15654            anyhow::bail!("no lockable door nearby");
15655        };
15656        let locked = !locked_now;
15657        self.seq += 1;
15658        self.session
15659            .submit_intent(Intent::SetDoorLocked {
15660                entity_id: self.state.entity_id,
15661                door_id,
15662                locked,
15663                seq: self.seq,
15664            })
15665            .await?;
15666        self.state.intents_sent += 1;
15667        Ok(())
15668    }
15669
15670    /// Enter through a nearby open player-building door (`Enter`).
15671    pub async fn enter_nearby_open_door(&mut self) -> anyhow::Result<()> {
15672        if !self.state.is_alive() {
15673            anyhow::bail!("you are dead");
15674        }
15675        if self.state.effective_inside_building().is_some() {
15676            anyhow::bail!("already inside");
15677        }
15678        let (px, py) = self.state.player_position();
15679        let mut best: Option<(f32, String)> = None;
15680        for d in &self.state.doors {
15681            if !d.open || d.locked {
15682                continue;
15683            }
15684            let player_house = self
15685                .state
15686                .buildings
15687                .iter()
15688                .find(|b| b.id == d.building_id)
15689                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15690            if !player_house {
15691                continue;
15692            }
15693            let dist = (d.x - px).hypot(d.y - py);
15694            if dist > DOOR_INTERACTION_RADIUS_M {
15695                continue;
15696            }
15697            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15698                best = Some((dist, d.id.clone()));
15699            }
15700        }
15701        let Some((_, door_id)) = best else {
15702            anyhow::bail!("no open house door nearby — open with f first");
15703        };
15704        self.seq += 1;
15705        self.session
15706            .submit_intent(Intent::EnterBuildingDoor {
15707                entity_id: self.state.entity_id,
15708                door_id,
15709                seq: self.seq,
15710            })
15711            .await?;
15712        self.state.intents_sent += 1;
15713        Ok(())
15714    }
15715
15716    /// Exit through a nearby exterior portal from inside a player house (`Enter`).
15717    /// Works even when the door is closed or locked.
15718    pub async fn exit_nearby_building_door(&mut self) -> anyhow::Result<()> {
15719        if !self.state.is_alive() {
15720            anyhow::bail!("you are dead");
15721        }
15722        let Some(bid) = self.state.effective_inside_building() else {
15723            anyhow::bail!("not inside a building");
15724        };
15725        let (px, py) = self.state.player_position();
15726        let mut best: Option<(f32, String)> = None;
15727        for d in &self.state.doors {
15728            if d.building_id != bid || d.portal.is_none() {
15729                continue;
15730            }
15731            let player_house = self
15732                .state
15733                .buildings
15734                .iter()
15735                .find(|b| b.id == d.building_id)
15736                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15737            if !player_house {
15738                continue;
15739            }
15740            let dist = (d.x - px).hypot(d.y - py);
15741            if dist > 1.5 {
15742                continue;
15743            }
15744            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15745                best = Some((dist, d.id.clone()));
15746            }
15747        }
15748        let Some((_, door_id)) = best else {
15749            anyhow::bail!("stand by the door to exit");
15750        };
15751        self.seq += 1;
15752        self.session
15753            .submit_intent(Intent::ExitBuildingDoor {
15754                entity_id: self.state.entity_id,
15755                door_id,
15756                seq: self.seq,
15757            })
15758            .await?;
15759        self.state.intents_sent += 1;
15760        Ok(())
15761    }
15762
15763    /// Confirm interior layout for your player building (copper charged on server).
15764    pub async fn confirm_interior_edit(
15765        &mut self,
15766        building_id: String,
15767        rooms: Vec<flatland_protocol::InteriorRoomEdit>,
15768        room_doors: Vec<flatland_protocol::InteriorRoomDoorEdit>,
15769    ) -> anyhow::Result<()> {
15770        self.seq += 1;
15771        self.session
15772            .submit_intent(Intent::ConfirmInteriorEdit {
15773                entity_id: self.state.entity_id,
15774                building_id,
15775                rooms,
15776                room_doors,
15777                seq: self.seq,
15778            })
15779            .await?;
15780        self.state.intents_sent += 1;
15781        Ok(())
15782    }
15783
15784    pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
15785        if !self.state.is_alive() {
15786            anyhow::bail!("you are dead");
15787        }
15788        self.seq += 1;
15789        self.session
15790            .submit_intent(Intent::Cultivate {
15791                entity_id: self.state.entity_id,
15792                x,
15793                y,
15794                seq: self.seq,
15795            })
15796            .await?;
15797        self.state.intents_sent += 1;
15798        Ok(())
15799    }
15800
15801    pub async fn plant_seeds(
15802        &mut self,
15803        seed_template_id: String,
15804        quantity: u32,
15805    ) -> anyhow::Result<()> {
15806        if !self.state.is_alive() {
15807            anyhow::bail!("you are dead");
15808        }
15809        self.seq += 1;
15810        self.session
15811            .submit_intent(Intent::PlantSeeds {
15812                entity_id: self.state.entity_id,
15813                seed_template_id: seed_template_id.clone(),
15814                quantity,
15815                seq: self.seq,
15816            })
15817            .await?;
15818        self.state.intents_sent += 1;
15819        self.state
15820            .push_log(format!("Planting {quantity}× {seed_template_id}…"));
15821        Ok(())
15822    }
15823
15824    pub fn open_plant_menu(&mut self) {
15825        if self.state.farm_seed_entries().is_empty() {
15826            self.state.push_log("No seeds in inventory to plant");
15827            return;
15828        }
15829        self.state.show_plant_menu = true;
15830        self.state.plant_menu_index = 0;
15831        self.state.plant_quantity = 1;
15832        self.state.clamp_plant_menu();
15833    }
15834
15835    pub fn close_plant_menu(&mut self) {
15836        self.state.show_plant_menu = false;
15837    }
15838
15839    pub fn plant_menu_move(&mut self, delta: i32) {
15840        let n = self.state.farm_seed_entries().len();
15841        if n == 0 {
15842            return;
15843        }
15844        let idx = self.state.plant_menu_index as i32 + delta;
15845        self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
15846        self.state.clamp_plant_menu();
15847    }
15848
15849    pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
15850        let next = self.state.plant_quantity as i32 + delta;
15851        self.state.plant_quantity = next.max(1) as u32;
15852        self.state.clamp_plant_menu();
15853    }
15854
15855    pub fn plant_menu_set_quantity_max(&mut self) {
15856        if let Some((_, max, _)) = self.state.plant_menu_selection() {
15857            self.state.plant_quantity = max;
15858        }
15859        self.state.clamp_plant_menu();
15860    }
15861
15862    pub fn plant_menu_set_quantity_min(&mut self) {
15863        self.state.plant_quantity = 1;
15864        self.state.clamp_plant_menu();
15865    }
15866
15867    pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
15868        let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
15869            self.close_plant_menu();
15870            anyhow::bail!("no seeds to plant");
15871        };
15872        self.close_plant_menu();
15873        self.plant_seeds(seed, qty).await?;
15874        self.state.push_log(format!("Planted {qty}× {label}"));
15875        Ok(())
15876    }
15877
15878    /// Activate the ability or consumable bound to hotbar slot `1`–`9`.
15879    /// Heals prefer T2/self; other abilities prefer T1. Consumables need no target.
15880    pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
15881        if !self.state.is_alive() {
15882            anyhow::bail!("you are dead");
15883        }
15884        let binding = self
15885            .state
15886            .hotbar_ability(slot)
15887            .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
15888            .to_string();
15889        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
15890            let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
15891            if qty == 0 {
15892                anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
15893            }
15894            return self.use_item(template_id).await;
15895        }
15896        let ability_id = binding;
15897        if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
15898            return self
15899                .cast_ability(&ability_id, Some(self.state.entity_id))
15900                .await;
15901        }
15902        let is_heal = ability_id == "heal_touch"
15903            || self
15904                .state
15905                .ability_meta
15906                .get(&ability_id)
15907                .map(|meta| meta.is_heal)
15908                .unwrap_or(false);
15909        let target = if is_heal {
15910            Some(
15911                self.state
15912                    .target_for_slot(2)
15913                    .unwrap_or(self.state.entity_id),
15914            )
15915        } else {
15916            self.state
15917                .target_for_slot(1)
15918                .or_else(|| self.state.target_for_slot(2))
15919        };
15920        let Some(target_id) = target else {
15921            anyhow::bail!("no target — Tab to select, then press the hotbar key");
15922        };
15923        self.cast_ability(&ability_id, Some(target_id)).await
15924    }
15925
15926    /// Bind or clear a hotbar slot (`1`–`9`) via [`Intent::SetHotbarSlot`].
15927    /// `ability_id` may be a learned ability or `item:<template_id>` for consumables.
15928    pub async fn set_hotbar_slot(
15929        &mut self,
15930        slot: u8,
15931        ability_id: Option<&str>,
15932    ) -> anyhow::Result<()> {
15933        if !self.state.is_alive() {
15934            anyhow::bail!("you are dead");
15935        }
15936        if !(1..=9).contains(&slot) {
15937            anyhow::bail!("hotbar slot must be 1–9");
15938        }
15939        let ability_id = ability_id
15940            .map(str::trim)
15941            .filter(|id| !id.is_empty())
15942            .map(str::to_string);
15943        self.seq += 1;
15944        self.session
15945            .submit_intent(Intent::SetHotbarSlot {
15946                entity_id: self.state.entity_id,
15947                slot,
15948                ability_id: ability_id.clone(),
15949                seq: self.seq,
15950            })
15951            .await?;
15952        self.state.intents_sent += 1;
15953        let idx = (slot - 1) as usize;
15954        if self.state.hotbar.len() < 9 {
15955            self.state.hotbar.resize(9, None);
15956        }
15957        if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
15958            *slot_mut = ability_id.clone();
15959        }
15960        match ability_id {
15961            Some(id) => {
15962                let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
15963                    format!("use {tid}")
15964                } else {
15965                    id
15966                };
15967                self.state.push_log(format!("Hotbar {slot} → {label}"))
15968            }
15969            None => self.state.push_log(format!("Hotbar {slot} cleared")),
15970        }
15971        Ok(())
15972    }
15973
15974    pub fn npc_verb_options(&self) -> Vec<NpcVerbChoice> {
15975        self.state.npc_verb_options()
15976    }
15977
15978    pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
15979        let Some(npc_id) = self.state.npc_verb_target.clone() else {
15980            return Ok(());
15981        };
15982        let options = self.npc_verb_options();
15983        let choice = options
15984            .get(self.state.npc_verb_index)
15985            .cloned()
15986            .unwrap_or_else(GameState::talk_choice);
15987        match choice.action {
15988            NpcVerbAction::QuestGive { quest_id } => {
15989                self.submit_npc_quest_turn_in(&npc_id, Some(&quest_id))
15990                    .await?;
15991                self.state.show_npc_verb_menu = false;
15992            }
15993            NpcVerbAction::Talk => {
15994                self.open_npc_talk(&npc_id, None).await?;
15995            }
15996            NpcVerbAction::QuestTalk { quest_id } => {
15997                self.open_npc_talk(&npc_id, Some(&quest_id)).await?;
15998            }
15999            NpcVerbAction::Trade
16000            | NpcVerbAction::Bank
16001            | NpcVerbAction::Storage
16002            | NpcVerbAction::Market => {
16003                self.seq += 1;
16004                self.session
16005                    .submit_intent(Intent::Interact {
16006                        entity_id: self.state.entity_id,
16007                        target_id: npc_id,
16008                        seq: self.seq,
16009                    })
16010                    .await?;
16011                self.state.intents_sent += 1;
16012            }
16013        }
16014        Ok(())
16015    }
16016
16017    async fn open_npc_talk(&mut self, npc_id: &str, quest_id: Option<&str>) -> anyhow::Result<()> {
16018        self.seq += 1;
16019        self.session
16020            .submit_intent(Intent::NpcTalkOpen {
16021                entity_id: self.state.entity_id,
16022                npc_id: npc_id.to_string(),
16023                quest_id: quest_id.map(str::to_string),
16024                seq: self.seq,
16025            })
16026            .await?;
16027        self.state.intents_sent += 1;
16028        Ok(())
16029    }
16030
16031    async fn submit_npc_quest_turn_in(
16032        &mut self,
16033        npc_id: &str,
16034        quest_id: Option<&str>,
16035    ) -> anyhow::Result<()> {
16036        let catalog_ref = self.state.npc_quest_catalog_ref(npc_id);
16037        let pending: Vec<(String, u32, String)> = self
16038            .state
16039            .quest_log
16040            .iter()
16041            .filter(|q| {
16042                q.status == flatland_protocol::QuestStatusView::Active
16043                    && quest_id.is_none_or(|id| q.quest_id == id)
16044            })
16045            .flat_map(|q| q.objectives.iter())
16046            .filter(|o| {
16047                !o.done
16048                    && o.kind == "give_item"
16049                    && o.npc_ref.as_deref() == Some(catalog_ref.as_str())
16050            })
16051            .filter_map(|o| {
16052                let template = o.item_template.clone()?;
16053                let remaining = o.required.saturating_sub(o.current);
16054                if remaining == 0 {
16055                    return None;
16056                }
16057                Some((template, remaining, o.label.clone()))
16058            })
16059            .collect();
16060        if pending.is_empty() {
16061            self.state.push_log("Nothing to turn in here.");
16062            return Ok(());
16063        }
16064        let mut sent = 0u32;
16065        for (template, remaining, label) in pending {
16066            let held = self.state.count_inventory_template(&template);
16067            let qty = remaining.min(held);
16068            if qty == 0 {
16069                self.state.push_log(format!("Need {label}"));
16070                continue;
16071            }
16072            self.seq += 1;
16073            self.session
16074                .submit_intent(Intent::QuestGiveItem {
16075                    entity_id: self.state.entity_id,
16076                    npc_id: npc_id.to_string(),
16077                    template_id: template,
16078                    quantity: qty,
16079                    seq: self.seq,
16080                })
16081                .await?;
16082            self.state.intents_sent += 1;
16083            sent += 1;
16084        }
16085        if sent > 0 {
16086            self.state.push_log("Turning in quest items.");
16087        }
16088        Ok(())
16089    }
16090
16091    pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
16092        let Some(chat) = self.state.npc_chat.clone() else {
16093            return Ok(());
16094        };
16095        let message = chat.input.trim().to_string();
16096        if message.is_empty() || chat.pending {
16097            return Ok(());
16098        }
16099        if let Some(c) = self.state.npc_chat.as_mut() {
16100            c.lines.push(format!("You: {message}"));
16101            c.input.clear();
16102            c.pending = true;
16103        }
16104        self.seq += 1;
16105        self.session
16106            .submit_intent(Intent::NpcTalkSay {
16107                entity_id: self.state.entity_id,
16108                npc_id: chat.npc_id,
16109                message,
16110                seq: self.seq,
16111            })
16112            .await?;
16113        self.state.intents_sent += 1;
16114        Ok(())
16115    }
16116
16117    pub async fn npc_talk_topic(&mut self, index: usize) -> anyhow::Result<()> {
16118        let topic = self
16119            .state
16120            .npc_chat
16121            .as_ref()
16122            .and_then(|c| c.suggested_topics.get(index))
16123            .cloned();
16124        let Some(topic) = topic else {
16125            return Ok(());
16126        };
16127        if let Some(c) = self.state.npc_chat.as_mut() {
16128            if c.pending {
16129                return Ok(());
16130            }
16131            c.input = topic;
16132        }
16133        self.npc_talk_send().await
16134    }
16135
16136    pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
16137        let return_to_verbs = self.state.npc_verb_target.is_some();
16138        let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
16139            self.state.show_npc_chat = false;
16140            if return_to_verbs {
16141                self.state.show_npc_verb_menu = true;
16142            }
16143            return Ok(());
16144        };
16145        self.seq += 1;
16146        self.session
16147            .submit_intent(Intent::NpcTalkClose {
16148                entity_id: self.state.entity_id,
16149                npc_id,
16150                seq: self.seq,
16151            })
16152            .await?;
16153        self.state.intents_sent += 1;
16154        self.state.show_npc_chat = false;
16155        self.state.npc_chat = None;
16156        if return_to_verbs {
16157            self.state.show_npc_verb_menu = true;
16158            self.state.npc_verb_notice = None;
16159        }
16160        Ok(())
16161    }
16162
16163    /// Esc/back inside Talk, Trade, quest-offer, or the verb menu — pop one layer, not the whole session.
16164    pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
16165        if self.state.show_quest_offer
16166            && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
16167        {
16168            self.quest_offer_decline();
16169            return Ok(());
16170        }
16171        if self.state.show_npc_chat {
16172            return self.npc_talk_close().await;
16173        }
16174        if self.state.show_shop_menu {
16175            return self.back_from_shop_menu().await;
16176        }
16177        if self.state.bank_panel.is_some() {
16178            if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
16179                self.bank_transfer_back();
16180                return Ok(());
16181            }
16182            return self.close_bank_panel().await;
16183        }
16184        if self.state.storage_panel.is_some() {
16185            if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
16186                self.storage_ui_back();
16187                return Ok(());
16188            }
16189            return self.close_storage_panel().await;
16190        }
16191        if self.state.market_panel.is_some() {
16192            if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
16193                self.market_ui_back();
16194                return Ok(());
16195            }
16196            if self.state.market_buy_confirm.is_some() {
16197                self.state.market_buy_confirm = None;
16198                return Ok(());
16199            }
16200            return self.close_market_panel().await;
16201        }
16202        if self.state.show_npc_verb_menu {
16203            self.state.show_npc_verb_menu = false;
16204            self.state.npc_verb_target = None;
16205        }
16206        Ok(())
16207    }
16208
16209    pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
16210        self.seq += 1;
16211        self.session
16212            .submit_intent(Intent::TestDamage {
16213                entity_id: self.state.entity_id,
16214                amount,
16215                seq: self.seq,
16216            })
16217            .await?;
16218        self.state.intents_sent += 1;
16219        Ok(())
16220    }
16221
16222    pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
16223        self.cycle_combat_target_slot(1, reverse).await
16224    }
16225
16226    pub async fn cycle_combat_target_slot(
16227        &mut self,
16228        slot_index: u8,
16229        reverse: bool,
16230    ) -> anyhow::Result<()> {
16231        if !self.state.is_alive() {
16232            anyhow::bail!("you are dead");
16233        }
16234        let candidates = self.state.candidates_for_slot(slot_index);
16235        if candidates.is_empty() {
16236            anyhow::bail!("no targets nearby");
16237        }
16238        let current = self.state.target_for_slot(slot_index);
16239        let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
16240        let next_idx = match idx {
16241            None => 0,
16242            Some(i) if reverse => {
16243                if i == 0 {
16244                    candidates.len() - 1
16245                } else {
16246                    i - 1
16247                }
16248            }
16249            Some(i) => (i + 1) % candidates.len(),
16250        };
16251        if idx == Some(next_idx) && candidates.len() == 1 {
16252            self.clear_combat_target_slot(slot_index).await?;
16253            return Ok(());
16254        }
16255        let (target_id, label) = candidates[next_idx].clone();
16256        self.set_combat_target_slot(slot_index, target_id, &label)
16257            .await
16258    }
16259
16260    pub async fn set_combat_target_slot(
16261        &mut self,
16262        slot_index: u8,
16263        target_id: EntityId,
16264        label: &str,
16265    ) -> anyhow::Result<()> {
16266        if !self.state.is_alive() {
16267            anyhow::bail!("you are dead");
16268        }
16269        self.seq += 1;
16270        self.session
16271            .submit_intent(Intent::SetTargetSlot {
16272                entity_id: self.state.entity_id,
16273                slot_index,
16274                target_id,
16275                seq: self.seq,
16276            })
16277            .await?;
16278        self.state.intents_sent += 1;
16279        if slot_index == 1 {
16280            self.state.combat_target = Some(target_id);
16281            self.state.combat_target_label = Some(label.to_string());
16282        }
16283        self.state
16284            .push_log(format!("Slot {slot_index} target: {label}"));
16285        Ok(())
16286    }
16287
16288    pub async fn set_combat_target(
16289        &mut self,
16290        target_id: EntityId,
16291        label: &str,
16292    ) -> anyhow::Result<()> {
16293        self.set_combat_target_slot(1, target_id, label).await
16294    }
16295
16296    pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
16297        if slot_index == 1 && self.state.combat_target.is_none() {
16298            return Ok(());
16299        }
16300        self.seq += 1;
16301        self.session
16302            .submit_intent(Intent::ClearTargetSlot {
16303                entity_id: self.state.entity_id,
16304                slot_index,
16305                seq: self.seq,
16306            })
16307            .await?;
16308        if slot_index == 1 {
16309            self.state.combat_target = None;
16310            self.state.combat_target_label = None;
16311        }
16312        self.state.intents_sent += 1;
16313        self.state
16314            .push_log(format!("Slot {slot_index} target cleared"));
16315        Ok(())
16316    }
16317
16318    pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
16319        self.clear_combat_target_slot(1).await
16320    }
16321
16322    pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
16323        if !self.state.is_alive() {
16324            anyhow::bail!("you are dead");
16325        }
16326        self.seq += 1;
16327        self.session
16328            .submit_intent(Intent::AdvanceRotation {
16329                entity_id: self.state.entity_id,
16330                slot_index,
16331                seq: self.seq,
16332            })
16333            .await?;
16334        self.state.intents_sent += 1;
16335        Ok(())
16336    }
16337
16338    pub async fn assign_slot_preset(
16339        &mut self,
16340        slot_index: u8,
16341        preset_id: &str,
16342    ) -> anyhow::Result<()> {
16343        if !self.state.is_alive() {
16344            anyhow::bail!("you are dead");
16345        }
16346        self.seq += 1;
16347        self.session
16348            .submit_intent(Intent::AssignSlotPreset {
16349                entity_id: self.state.entity_id,
16350                slot_index,
16351                preset_id: preset_id.to_string(),
16352                seq: self.seq,
16353            })
16354            .await?;
16355        self.state.intents_sent += 1;
16356        if let Some(slot) = self
16357            .state
16358            .combat_slots
16359            .iter_mut()
16360            .find(|s| s.slot_index == slot_index)
16361        {
16362            slot.preset_id = Some(preset_id.to_string());
16363            if let Some(preset) = self
16364                .state
16365                .rotation_presets
16366                .iter()
16367                .find(|p| p.id == preset_id)
16368            {
16369                slot.preset_label = Some(preset.label.clone());
16370                slot.rotation = preset.abilities.clone();
16371                slot.rotation_index = 0;
16372            }
16373        }
16374        self.state
16375            .push_log(format!("T{slot_index} loadout → {preset_id}"));
16376        Ok(())
16377    }
16378
16379    pub async fn cast_ability(
16380        &mut self,
16381        ability_id: &str,
16382        target_id: Option<EntityId>,
16383    ) -> anyhow::Result<()> {
16384        if !self.state.is_alive() {
16385            anyhow::bail!("you are dead");
16386        }
16387        let allows_ground = self.state.ability_allows_ground(ability_id);
16388        let requires_ground = self.state.ability_requires_ground(ability_id);
16389        if requires_ground && self.state.ground_target.is_none() {
16390            anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
16391        }
16392        let (resolved_target_id, target_point) = if allows_ground {
16393            if let Some((x, y, z)) = self.state.ground_target {
16394                (
16395                    target_id.unwrap_or(self.state.entity_id),
16396                    Some(flatland_protocol::AimPoint { x, y, z }),
16397                )
16398            } else {
16399                (
16400                    target_id
16401                        .or_else(|| self.state.target_for_slot(2))
16402                        .or_else(|| self.state.target_for_slot(1))
16403                        .unwrap_or(self.state.entity_id),
16404                    None,
16405                )
16406            }
16407        } else {
16408            (
16409                target_id
16410                    .or_else(|| self.state.target_for_slot(2))
16411                    .or_else(|| self.state.target_for_slot(1))
16412                    .unwrap_or(self.state.entity_id),
16413                None,
16414            )
16415        };
16416        self.seq += 1;
16417        self.session
16418            .submit_intent(Intent::Cast {
16419                entity_id: self.state.entity_id,
16420                ability_id: ability_id.to_string(),
16421                target_id: resolved_target_id,
16422                target_point,
16423                seq: self.seq,
16424            })
16425            .await?;
16426        self.state.intents_sent += 1;
16427        match target_point {
16428            Some(point) => self.state.push_log(format!(
16429                "Cast {ability_id} → ({:.1}, {:.1})",
16430                point.x, point.y
16431            )),
16432            None => self
16433                .state
16434                .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
16435        }
16436        Ok(())
16437    }
16438
16439    pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
16440        self.seq += 1;
16441        self.session
16442            .submit_intent(Intent::UpsertRotationPreset {
16443                entity_id: self.state.entity_id,
16444                preset: preset.clone(),
16445                seq: self.seq,
16446            })
16447            .await?;
16448        self.state.intents_sent += 1;
16449        if let Some(existing) = self
16450            .state
16451            .rotation_presets
16452            .iter_mut()
16453            .find(|p| p.id == preset.id)
16454        {
16455            *existing = preset.clone();
16456        } else {
16457            self.state.rotation_presets.push(preset.clone());
16458        }
16459        for slot in &mut self.state.combat_slots {
16460            if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
16461                slot.preset_label = Some(preset.label.clone());
16462                slot.rotation = preset.abilities.clone();
16463            }
16464        }
16465        self.state
16466            .push_log(format!("Saved rotation: {}", preset.label));
16467        Ok(())
16468    }
16469
16470    pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
16471        self.seq += 1;
16472        self.session
16473            .submit_intent(Intent::DeleteRotationPreset {
16474                entity_id: self.state.entity_id,
16475                preset_id: preset_id.to_string(),
16476                seq: self.seq,
16477            })
16478            .await?;
16479        self.state.intents_sent += 1;
16480        self.state.rotation_presets.retain(|p| p.id != preset_id);
16481        for slot in &mut self.state.combat_slots {
16482            if slot.preset_id.as_deref() == Some(preset_id) {
16483                slot.preset_id = None;
16484                slot.preset_label = None;
16485                slot.rotation.clear();
16486                slot.rotation_index = 0;
16487            }
16488        }
16489        self.state
16490            .push_log(format!("Deleted rotation: {preset_id}"));
16491        Ok(())
16492    }
16493
16494    pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
16495        if !self.state.is_alive() {
16496            anyhow::bail!("you are dead");
16497        }
16498        let enabled = !self
16499            .state
16500            .combat_slots
16501            .iter()
16502            .find(|s| s.slot_index == slot_index)
16503            .map(|s| s.auto_enabled)
16504            .unwrap_or(false);
16505        self.seq += 1;
16506        self.session
16507            .submit_intent(Intent::SetAutoAttack {
16508                entity_id: self.state.entity_id,
16509                slot_index,
16510                enabled,
16511                seq: self.seq,
16512            })
16513            .await?;
16514        if slot_index == 1 {
16515            self.state.auto_attack = enabled;
16516        }
16517        self.state.intents_sent += 1;
16518        self.state.push_log(format!(
16519            "T{slot_index} auto {}",
16520            if enabled { "ON" } else { "OFF" }
16521        ));
16522        Ok(())
16523    }
16524
16525    pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
16526        if !self.state.connected {
16527            anyhow::bail!("not connected");
16528        }
16529        if !self.state.is_alive() {
16530            anyhow::bail!("you are dead");
16531        }
16532        let (px, py) = self.state.player_position();
16533        if self
16534            .state
16535            .ground_drops
16536            .iter()
16537            .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
16538        {
16539            anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
16540        }
16541        self.seq += 1;
16542        self.session
16543            .submit_intent(Intent::Pickup {
16544                entity_id: self.state.entity_id,
16545                drop_id: None,
16546                seq: self.seq,
16547            })
16548            .await?;
16549        self.state.intents_sent += 1;
16550        self.state.push_audio(crate::social::AudioCue::LootPickup);
16551        Ok(())
16552    }
16553
16554    pub async fn dodge(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16555        if !self.state.is_alive() {
16556            anyhow::bail!("you are dead");
16557        }
16558        // Only currently held WASD counts — sticky last_move would skip smart dodge after walking.
16559        self.seq += 1;
16560        self.session
16561            .submit_intent(Intent::Dodge {
16562                entity_id: self.state.entity_id,
16563                forward,
16564                strafe,
16565                seq: self.seq,
16566            })
16567            .await?;
16568        self.state.intents_sent += 1;
16569        self.state.push_log("Dodge!");
16570        self.state.push_audio(crate::social::AudioCue::CombatDodge);
16571        Ok(())
16572    }
16573
16574    pub async fn lunge(&mut self) -> anyhow::Result<()> {
16575        if !self.state.is_alive() {
16576            anyhow::bail!("you are dead");
16577        }
16578        let (forward, strafe) = self.last_move_axes();
16579        self.seq += 1;
16580        self.session
16581            .submit_intent(Intent::Lunge {
16582                entity_id: self.state.entity_id,
16583                forward,
16584                strafe,
16585                seq: self.seq,
16586            })
16587            .await?;
16588        self.state.intents_sent += 1;
16589        self.state.push_log("Lunge!");
16590        Ok(())
16591    }
16592
16593    pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16594        if !self.state.is_alive() {
16595            anyhow::bail!("you are dead");
16596        }
16597        self.seq += 1;
16598        self.session
16599            .submit_intent(Intent::DirectionalJump {
16600                entity_id: self.state.entity_id,
16601                forward,
16602                strafe,
16603                seq: self.seq,
16604            })
16605            .await?;
16606        self.state.intents_sent += 1;
16607        self.state.push_log("Jump!");
16608        Ok(())
16609    }
16610
16611    /// Remembered WASD axes for lunge when not currently moving.
16612    pub fn last_move_axes(&self) -> (f32, f32) {
16613        (self.last_move_forward, self.last_move_strafe)
16614    }
16615
16616    pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
16617        if !self.state.is_alive() {
16618            anyhow::bail!("you are dead");
16619        }
16620        self.seq += 1;
16621        self.session
16622            .submit_intent(Intent::Block {
16623                entity_id: self.state.entity_id,
16624                enabled,
16625                seq: self.seq,
16626            })
16627            .await?;
16628        self.state.intents_sent += 1;
16629        if enabled {
16630            self.state.push_log("Blocking");
16631            self.state.push_audio(crate::social::AudioCue::CombatBlock);
16632        }
16633        Ok(())
16634    }
16635
16636    pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
16637        if !self.state.is_alive() {
16638            anyhow::bail!("you are dead");
16639        }
16640        self.seq += 1;
16641        self.session
16642            .submit_intent(Intent::EquipMainhand {
16643                entity_id: self.state.entity_id,
16644                template_id,
16645                instance_id: None,
16646                seq: self.seq,
16647            })
16648            .await?;
16649        self.state.intents_sent += 1;
16650        Ok(())
16651    }
16652
16653    /// Equip paperdoll: activate selected slot (unequip filled, or equip first matching candidate).
16654    pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
16655        let idx = self.state.equip_menu_index;
16656        let slots = equip_paperdoll_rows(&self.state);
16657        let Some(row) = slots.get(idx) else {
16658            return Ok(());
16659        };
16660        match row {
16661            EquipPaperdollRow::Body { slot, filled } => {
16662                if *filled {
16663                    self.equip_worn(*slot, None).await
16664                } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
16665                    self.equip_worn(*slot, Some(inst)).await
16666                } else {
16667                    self.state
16668                        .push_log(format!("No item for {}", body_slot_label(*slot)));
16669                    Ok(())
16670                }
16671            }
16672            EquipPaperdollRow::Mainhand { filled } => {
16673                if *filled {
16674                    self.unequip_mainhand().await
16675                } else if let Some(tid) = first_inventory_weapon(&self.state) {
16676                    self.equip_mainhand(Some(tid)).await
16677                } else {
16678                    self.state.push_log("No weapon in inventory".to_string());
16679                    Ok(())
16680                }
16681            }
16682            EquipPaperdollRow::Offhand { filled, locked } => {
16683                if *locked {
16684                    self.state
16685                        .push_log("Offhand locked — two-handed weapon equipped".to_string());
16686                    Ok(())
16687                } else if *filled {
16688                    self.unequip_offhand().await
16689                } else if let Some(tid) = first_inventory_offhand(&self.state) {
16690                    self.equip_offhand(Some(tid)).await
16691                } else {
16692                    self.state
16693                        .push_log("No offhand item in inventory".to_string());
16694                    Ok(())
16695                }
16696            }
16697        }
16698    }
16699
16700    pub async fn say(
16701        &mut self,
16702        channel: flatland_protocol::ChatChannel,
16703        text: &str,
16704    ) -> anyhow::Result<()> {
16705        self.say_to(channel, text, None).await
16706    }
16707
16708    pub async fn say_to(
16709        &mut self,
16710        channel: flatland_protocol::ChatChannel,
16711        text: &str,
16712        to_entity: Option<EntityId>,
16713    ) -> anyhow::Result<()> {
16714        self.seq += 1;
16715        self.session
16716            .submit_intent(Intent::Say {
16717                entity_id: self.state.entity_id,
16718                channel,
16719                text: text.to_string(),
16720                to_entity,
16721                seq: self.seq,
16722            })
16723            .await?;
16724        self.state.intents_sent += 1;
16725        Ok(())
16726    }
16727
16728    pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
16729        let Some(peer) = self.state.player_verbs.target_entity else {
16730            return Ok(());
16731        };
16732        let label = self.state.player_verbs.target_label.clone();
16733        let choice = crate::social::PlayerVerbState::options()
16734            .get(self.state.player_verbs.index)
16735            .copied()
16736            .unwrap_or("Whisper");
16737        self.state.player_verbs.close();
16738        match choice {
16739            "Trade" => {
16740                // Only request — peer must accept (Y in chat). Do not auto-respond;
16741                // TradeRespond requires a pending inbound request from the peer.
16742                self.seq += 1;
16743                self.session
16744                    .submit_intent(Intent::TradeRequest {
16745                        entity_id: self.state.entity_id,
16746                        peer_entity_id: peer,
16747                        seq: self.seq,
16748                    })
16749                    .await?;
16750                self.state.intents_sent += 1;
16751                self.state.social_chat.push_system(format!(
16752                    "Trade request sent to {label} — waiting for accept"
16753                ));
16754            }
16755            "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
16756            _ => self.state.social_chat.focus_nearby(),
16757        }
16758        Ok(())
16759    }
16760
16761    pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
16762        let Some(pending) = self.state.social_chat.pending_trade.take() else {
16763            return Ok(());
16764        };
16765        self.seq += 1;
16766        self.session
16767            .submit_intent(Intent::TradeRespond {
16768                entity_id: self.state.entity_id,
16769                peer_entity_id: pending.from_entity,
16770                accept,
16771                seq: self.seq,
16772            })
16773            .await?;
16774        self.state.intents_sent += 1;
16775        if accept {
16776            self.state
16777                .social_chat
16778                .push_system(format!("Accepted trade with {}", pending.from_name));
16779        } else {
16780            self.state
16781                .social_chat
16782                .push_system(format!("Declined trade with {}", pending.from_name));
16783        }
16784        Ok(())
16785    }
16786
16787    pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
16788        let text = self.state.social_chat.buffer.trim().to_string();
16789        if text.is_empty() {
16790            return Ok(());
16791        }
16792        self.state.social_chat.buffer.clear();
16793        if crate::social::is_chat_slash_line(&text) {
16794            match crate::social::parse_chat_slash(&text) {
16795                Some(cmd) => return self.apply_chat_slash(cmd).await,
16796                None => {
16797                    self.state.social_chat.push_system(format!(
16798                        "Unknown command — {}",
16799                        crate::social::chat_slash_help_text()
16800                    ));
16801                    return Ok(());
16802                }
16803            }
16804        }
16805        let thread = self.state.social_chat.thread;
16806        let channel = thread.channel();
16807        let to = thread.to_entity();
16808        if let Some(peer) = to {
16809            let label = self.state.social_chat.peer_label.clone();
16810            self.state
16811                .social_chat
16812                .remember_whisper_peer(peer, &label, channel);
16813        }
16814        self.say_to(channel, &text, to).await
16815    }
16816
16817    async fn apply_chat_slash(
16818        &mut self,
16819        cmd: crate::social::ChatSlashCommand,
16820    ) -> anyhow::Result<()> {
16821        use crate::social::{chat_slash_help_text, ChatSlashCommand};
16822        match cmd {
16823            ChatSlashCommand::Help => {
16824                self.state
16825                    .social_chat
16826                    .push_system(chat_slash_help_text().to_string());
16827                Ok(())
16828            }
16829            ChatSlashCommand::Nearby { message } => {
16830                self.state.social_chat.focus_nearby();
16831                self.state
16832                    .social_chat
16833                    .push_system("Nearby speech — everyone close can hear");
16834                if let Some(msg) = message {
16835                    self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
16836                        .await
16837                } else {
16838                    Ok(())
16839                }
16840            }
16841            ChatSlashCommand::Reply { message } => {
16842                let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16843                    self.state
16844                        .social_chat
16845                        .push_system("No one to reply to — wait for a whisper, or /whisper Name");
16846                    return Ok(());
16847                };
16848                let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
16849                self.state
16850                    .social_chat
16851                    .set_whisper_thread(peer.entity_id, &peer.label, stone);
16852                self.state.social_chat.push_system(format!(
16853                    "Replying to {} — type and Enter · /nearby",
16854                    peer.label
16855                ));
16856                if let Some(msg) = message {
16857                    self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
16858                } else {
16859                    Ok(())
16860                }
16861            }
16862            ChatSlashCommand::Whisper { name, message } => {
16863                let (peer_id, label, stone) = if let Some(name) = name {
16864                    match self.resolve_whisper_target(&name) {
16865                        Ok(t) => t,
16866                        Err(err) => {
16867                            self.state.social_chat.push_system(err);
16868                            return Ok(());
16869                        }
16870                    }
16871                } else {
16872                    let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16873                        self.state.social_chat.push_system(
16874                            "Usage: /whisper Name [message] · or /reply after someone whispers you",
16875                        );
16876                        return Ok(());
16877                    };
16878                    (
16879                        peer.entity_id,
16880                        peer.label,
16881                        peer.channel == flatland_protocol::ChatChannel::WhisperStone,
16882                    )
16883                };
16884                self.state
16885                    .social_chat
16886                    .set_whisper_thread(peer_id, &label, stone);
16887                let channel = if stone {
16888                    flatland_protocol::ChatChannel::WhisperStone
16889                } else {
16890                    flatland_protocol::ChatChannel::Whisper
16891                };
16892                if let Some(msg) = message {
16893                    self.state
16894                        .social_chat
16895                        .push_system(format!("Whisper → {label}"));
16896                    self.say_to(channel, &msg, Some(peer_id)).await
16897                } else {
16898                    self.state.social_chat.push_system(format!(
16899                        "Whispering {label} — type and Enter · Esc / /nearby cancels"
16900                    ));
16901                    Ok(())
16902                }
16903            }
16904        }
16905    }
16906
16907    /// Resolve `/whisper Name` against AOI players (label match).
16908    fn resolve_whisper_target(&self, name: &str) -> Result<(EntityId, String, bool), String> {
16909        let needle = name.trim().to_ascii_lowercase();
16910        if needle.is_empty() {
16911            return Err("Usage: /whisper Name [message]".into());
16912        }
16913        let mut candidates: Vec<(EntityId, String)> = self
16914            .state
16915            .entities
16916            .iter()
16917            .filter(|e| e.id != self.state.entity_id)
16918            .filter(|e| !e.label.trim().is_empty())
16919            .filter(|e| e.vitals.is_some())
16920            .filter(|e| !self.state.npcs.iter().any(|n| n.id == e.id.to_string()))
16921            .filter(|e| !self.state.hired_workers.iter().any(|w| w.entity_id == e.id))
16922            .map(|e| (e.id, e.label.clone()))
16923            .collect();
16924
16925        // Also allow matching the last whisper peer by name even if they left AOI briefly.
16926        if let Some(last) = &self.state.social_chat.last_whisper_peer {
16927            if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
16928                candidates.push((last.entity_id, last.label.clone()));
16929            }
16930        }
16931
16932        let exact: Vec<_> = candidates
16933            .iter()
16934            .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
16935            .cloned()
16936            .collect();
16937        let pool = if exact.len() == 1 {
16938            exact
16939        } else if exact.len() > 1 {
16940            return Err(format!(
16941                "Several players named '{name}' nearby — move closer and try again"
16942            ));
16943        } else {
16944            let starts: Vec<_> = candidates
16945                .iter()
16946                .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
16947                .cloned()
16948                .collect();
16949            if starts.len() == 1 {
16950                starts
16951            } else if starts.len() > 1 {
16952                let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
16953                return Err(format!(
16954                    "Ambiguous name '{name}' — matches: {}",
16955                    names.join(", ")
16956                ));
16957            } else {
16958                let contains: Vec<_> = candidates
16959                    .iter()
16960                    .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
16961                    .cloned()
16962                    .collect();
16963                if contains.len() == 1 {
16964                    contains
16965                } else if contains.is_empty() {
16966                    return Err(format!(
16967                        "No player matching '{name}' in range — get closer or check the spelling"
16968                    ));
16969                } else {
16970                    let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
16971                    return Err(format!(
16972                        "Ambiguous name '{name}' — matches: {}",
16973                        names.join(", ")
16974                    ));
16975                }
16976            }
16977        };
16978
16979        let (id, label) = pool.into_iter().next().unwrap();
16980        let stone = self
16981            .state
16982            .social_chat
16983            .last_whisper_peer
16984            .as_ref()
16985            .is_some_and(|p| {
16986                p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone
16987            });
16988        Ok((id, label, stone))
16989    }
16990
16991    pub async fn trade_present_selected(
16992        &mut self,
16993        item_instance_id: uuid::Uuid,
16994    ) -> anyhow::Result<()> {
16995        self.trade_present_quantity(item_instance_id, None).await
16996    }
16997
16998    pub async fn trade_present_quantity(
16999        &mut self,
17000        item_instance_id: uuid::Uuid,
17001        quantity: Option<u32>,
17002    ) -> anyhow::Result<()> {
17003        self.seq += 1;
17004        self.session
17005            .submit_intent(Intent::TradePresent {
17006                entity_id: self.state.entity_id,
17007                item_instance_id,
17008                quantity,
17009                seq: self.seq,
17010            })
17011            .await?;
17012        self.state.intents_sent += 1;
17013        self.state.trade_ui.qty_entry = None;
17014        self.state.trade_ui.picking_inventory = false;
17015        Ok(())
17016    }
17017
17018    /// Confirm the trade quantity prompt (or present whole stack when qty==1).
17019    pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
17020        if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
17021            let qty = self.state.trade_ui.present_quantity();
17022            return self
17023                .trade_present_quantity(entry.item_instance_id, qty)
17024                .await;
17025        }
17026        if !self.state.trade_ui.picking_inventory {
17027            return Ok(());
17028        }
17029        let stacks = self.state.trade_presentable_stacks();
17030        let Some(stack) = stacks.get(self.state.trade_ui.inventory_index).copied() else {
17031            return Ok(());
17032        };
17033        let Some(id) = stack.item_instance_id else {
17034            return Ok(());
17035        };
17036        let label = stack
17037            .display_name
17038            .clone()
17039            .unwrap_or_else(|| stack.template_id.clone());
17040        if stack.quantity <= 1 {
17041            self.trade_present_quantity(id, Some(1)).await
17042        } else {
17043            self.state
17044                .trade_ui
17045                .begin_qty_entry(id, label, stack.quantity);
17046            Ok(())
17047        }
17048    }
17049
17050    pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
17051        self.seq += 1;
17052        self.session
17053            .submit_intent(Intent::TradeSetReady {
17054                entity_id: self.state.entity_id,
17055                ready,
17056                seq: self.seq,
17057            })
17058            .await?;
17059        self.state.intents_sent += 1;
17060        Ok(())
17061    }
17062
17063    pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
17064        self.seq += 1;
17065        self.session
17066            .submit_intent(Intent::TradeCancel {
17067                entity_id: self.state.entity_id,
17068                seq: self.seq,
17069            })
17070            .await?;
17071        self.state.intents_sent += 1;
17072        self.state.trade_ui.close();
17073        Ok(())
17074    }
17075
17076    pub async fn destroy_whisper_stone(
17077        &mut self,
17078        item_instance_id: uuid::Uuid,
17079    ) -> anyhow::Result<()> {
17080        self.seq += 1;
17081        self.session
17082            .submit_intent(Intent::DestroyWhisperStone {
17083                entity_id: self.state.entity_id,
17084                item_instance_id,
17085                seq: self.seq,
17086            })
17087            .await?;
17088        self.state.intents_sent += 1;
17089        Ok(())
17090    }
17091
17092    pub async fn stop(&mut self) -> anyhow::Result<()> {
17093        self.seq += 1;
17094        self.session
17095            .submit_intent(Intent::Stop {
17096                entity_id: self.state.entity_id,
17097                seq: self.seq,
17098            })
17099            .await?;
17100        self.state.intents_sent += 1;
17101        Ok(())
17102    }
17103
17104    pub fn disconnect(&self) {
17105        self.session.disconnect();
17106    }
17107}
17108
17109fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
17110    let dx = ax - bx;
17111    let dy = ay - by;
17112    (dx * dx + dy * dy).sqrt()
17113}
17114
17115#[cfg(test)]
17116mod tests {
17117    use std::collections::BTreeMap;
17118
17119    use super::*;
17120    use flatland_protocol::{
17121        BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
17122    };
17123
17124    fn sample_state() -> GameState {
17125        let mut state = GameState {
17126            session_id: 1,
17127            entity_id: 1,
17128            character_id: None,
17129            tick: 0,
17130            chunk_rev: 0,
17131            content_rev: 0,
17132            publish_rev: 0,
17133            entities: vec![EntityState {
17134                id: 1,
17135                label: "You".into(),
17136                transform: Transform {
17137                    position: WorldCoord::surface(128.0, 128.0),
17138                    yaw: 0.0,
17139                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
17140                },
17141                vitals: None,
17142                attributes: None,
17143                skills: None,
17144                inside_building: None,
17145                tile_id: None,
17146                paperdoll_ref: None,
17147                draw_scale: 1.0,
17148                presentation_state: None,
17149                sprite_mode: None,
17150                progression_xp: None,
17151                combat_cues: vec![],
17152                statuses: vec![],
17153            }],
17154            player: None,
17155            resource_nodes: vec![ResourceNodeView {
17156                id: "oak-1".into(),
17157                label: "Oak".into(),
17158                x: 130.0,
17159                y: 128.0,
17160                z: 0.0,
17161                item_template: "oak_log".into(),
17162                state: ResourceNodeState::Available,
17163                blocking: true,
17164                blocking_radius_m: 0.8,
17165                harvest_off: false,
17166                tile_id: None,
17167                yaw: 0.0,
17168                pitch: 0.0,
17169                roll: 0.0,
17170                draw_scale: 1.0,
17171                sprite_mode: None,
17172                growth_progress: None,
17173                presentation_state: None,
17174                channel_start_tick: None,
17175                channel_end_tick: None,
17176                harvest_drop_templates: vec![],
17177            }],
17178            harvest_route_nodes: vec![],
17179            ground_drops: vec![],
17180            placed_containers: vec![],
17181            buildings: vec![BuildingView {
17182                id: "broker-hut".into(),
17183                label: "Broker".into(),
17184                x: 148.0,
17185                y: 118.0,
17186                width_m: 8.0,
17187                depth_m: 6.0,
17188                interior_blueprint: Some("broker_hut".into()),
17189                tags: vec![],
17190                market_boundary_zone_ids: vec![],
17191                market_max_volume: None,
17192                wall_set: None,
17193                roof_set: None,
17194            }],
17195            doors: vec![flatland_protocol::DoorView {
17196                id: "door-1".into(),
17197                building_id: "broker-hut".into(),
17198                x: 148.0,
17199                y: 118.0,
17200                open: false,
17201                portal: Some("front".into()),
17202                locked: false,
17203                accessible: true,
17204                lock_id: None,
17205            }],
17206            interior_map: None,
17207            npcs: vec![],
17208            blueprints: vec![],
17209            building_materials: vec![],
17210            world_x0: 0.0,
17211            world_y0: 0.0,
17212            world_width_m: 256.0,
17213            world_height_m: 256.0,
17214            terrain_zones: Vec::new(),
17215            z_platforms: Vec::new(),
17216            z_transitions: Vec::new(),
17217            z_bands_outdoor_backup: None,
17218            world_clock: flatland_protocol::WorldClock::default(),
17219            inventory: std::collections::HashMap::new(),
17220            inventory_hints: std::collections::HashMap::new(),
17221            item_catalog: std::collections::HashMap::new(),
17222            logs: VecDeque::new(),
17223            intents_sent: 0,
17224            ticks_received: 0,
17225            connected: true,
17226            disconnect_reason: None,
17227            show_stats: false,
17228            hud_log_hidden: false,
17229            show_equip_menu: false,
17230            equip_menu_index: 0,
17231            show_craft_menu: false,
17232            show_plot_build_menu: false,
17233            plot_build_focus_wall: true,
17234            plot_build_wall_index: 0,
17235            plot_build_roof_index: 0,
17236            craft_menu_index: 0,
17237            craft_batch_quantity: 1,
17238            craft_tab: CraftTab::Ready,
17239            craft_filter: String::new(),
17240            craft_filter_focused: false,
17241            craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
17242            show_shop_menu: false,
17243            shop_catalog: None,
17244            bank_panel: None,
17245            bank_menu_index: 0,
17246            bank_ui_mode: BankUiMode::Menu,
17247            storage_panel: None,
17248            market_panel: None,
17249            market_menu_index: 0,
17250            market_filter: String::new(),
17251            market_filter_focused: false,
17252            market_category_filter: None,
17253            market_buy_confirm: None,
17254            market_ui_mode: MarketUiMode::Browse,
17255            storage_menu_index: 0,
17256            storage_ui_mode: StorageUiMode::Menu,
17257            shop_tab: ShopTab::default(),
17258            shop_menu_index: 0,
17259            shop_quantity: 1,
17260            shop_trade_log: VecDeque::new(),
17261            show_npc_verb_menu: false,
17262            npc_verb_target: None,
17263            npc_verb_index: 0,
17264            npc_verb_notice: None,
17265            player_verbs: crate::social::PlayerVerbState::default(),
17266            social_chat: crate::social::SocialChatState::default(),
17267            trade_ui: crate::social::TradeUiState::default(),
17268            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
17269            show_npc_chat: false,
17270            npc_chat: None,
17271            show_inventory_menu: false,
17272            inventory_menu_index: 0,
17273            inventory_tab: InventoryTab::OnPerson,
17274            inventory_filter: String::new(),
17275            inventory_filter_focused: false,
17276            show_move_picker: false,
17277            show_rename_prompt: false,
17278            rename_plot_id: None,
17279            highlighted_plot_id: None,
17280            show_worker_rename: false,
17281            rename_buffer: String::new(),
17282            move_picker_index: 0,
17283            move_picker: None,
17284            show_grant_picker: false,
17285            grant_picker_index: 0,
17286            grant_picker: None,
17287            show_destroy_picker: false,
17288            destroy_confirm_pending: false,
17289            destroy_picker: None,
17290            show_deconstruct_picker: false,
17291            deconstruct_confirm_pending: false,
17292            deconstruct_picker: None,
17293            combat_target: None,
17294            combat_target_label: None,
17295            ground_target: None,
17296            combat_fx: Vec::new(),
17297            ground_hazards: Vec::new(),
17298            property_zones: Vec::new(),
17299            tax_zones: Vec::new(),
17300            growth_zones: Vec::new(),
17301            biome_zones: Vec::new(),
17302            terrain_kind_nav: Vec::new(),
17303            property_plots: Vec::new(),
17304            property_plot_settings: None,
17305            claim_mode: None,
17306            relocate_mode: None,
17307            sell_plot_confirm: None,
17308            sell_plot_armed_at: None,
17309            show_plant_menu: false,
17310            plant_menu_index: 0,
17311            show_farm_access: false,
17312            farm_access_name_draft: String::new(),
17313            farm_access_discount_bps: 0,
17314            farm_access_index: 0,
17315            plant_quantity: 1,
17316            in_combat: false,
17317            auto_attack: true,
17318            combat_has_los: false,
17319            attack_cd_ticks: 0,
17320            gcd_ticks: 0,
17321            weapon_ability_id: "unarmed".into(),
17322            mainhand_template_id: None,
17323            mainhand_label: None,
17324            mainhand_instance_id: None,
17325            offhand_template_id: None,
17326            offhand_label: None,
17327            offhand_instance_id: None,
17328            mainhand_hand_slots: 1,
17329            defense: None,
17330            worn: BTreeMap::new(),
17331            carry_mass: 0.0,
17332            carry_mass_max: 0.0,
17333            encumbrance: flatland_protocol::EncumbranceState::Light,
17334            move_speed_mps: 0.0,
17335            move_speed_mult: 0.0,
17336            inventory_stacks: Vec::new(),
17337            keychain_stacks: Vec::new(),
17338            whisper_pouch_stacks: Vec::new(),
17339            combat_target_detail: None,
17340            statuses: Vec::new(),
17341            cast_progress: None,
17342            timed_channel: None,
17343            plot_build_offer: None,
17344            ability_cooldowns: Vec::new(),
17345            blocking_active: false,
17346            max_target_slots: 1,
17347            combat_slots: Vec::new(),
17348            rotation_presets: Vec::new(),
17349            known_abilities: Vec::new(),
17350            ability_meta: std::collections::HashMap::new(),
17351            ability_mastery: std::collections::HashMap::new(),
17352            hotbar: vec![None; 9],
17353            max_abilities_per_rotation: 0,
17354            show_loadout_menu: false,
17355            show_keychain_menu: false,
17356            keychain_menu_index: 0,
17357            show_rotation_editor: false,
17358            loadout_menu_index: 0,
17359            loadout_hotbar_slot: 1,
17360            loadout_ability_index: 0,
17361            loadout_focus_presets: false,
17362            rotation_editor: RotationEditorState::default(),
17363            harvest_in_progress: false,
17364            harvest_started_at: None,
17365            pending_craft_ack: None,
17366            craft_channel_blueprint_id: None,
17367            craft_channel_seen: false,
17368            pending_worker_job_ack: None,
17369            attending_worker_instance_id: None,
17370            quest_log: Vec::new(),
17371            interactables: Vec::new(),
17372            ledger: None,
17373            career: None,
17374            character_sheet_tab: CharacterSheetTab::Character,
17375            ledger_period: LedgerPeriod::Day,
17376            show_quest_offer: false,
17377            pending_quest_offers: Vec::new(),
17378            quest_offer_index: 0,
17379            show_quest_menu: false,
17380            quest_menu_index: 0,
17381            quest_withdraw_confirm: false,
17382            hired_workers: Vec::new(),
17383            show_workers_menu: false,
17384            workers_menu_index: 0,
17385            worker_dismiss_confirmation: None,
17386            workers_menu_compact: false,
17387            worker_step_display: BTreeMap::new(),
17388            worker_error_display: BTreeMap::new(),
17389            worker_health_ring_until: BTreeMap::new(),
17390            pending_worker_hire_since: None,
17391            show_worker_give_picker: false,
17392            worker_give_picker_index: 0,
17393            worker_give_picker: None,
17394            show_worker_give_target_picker: false,
17395            worker_give_target_picker_index: 0,
17396            worker_give_target_picker: None,
17397            show_worker_take_picker: false,
17398            worker_take_picker_index: 0,
17399            worker_take_picker: None,
17400            show_worker_teach_picker: false,
17401            worker_teach_picker_index: 0,
17402            worker_teach_picker: None,
17403            worker_route_editor: None,
17404            progression_curve: None,
17405        };
17406        state.player = state.entities.first().cloned();
17407        state
17408    }
17409
17410    #[test]
17411    fn template_display_name_uses_item_catalog_for_uuid_ids() {
17412        let mut state = sample_state();
17413        let id = "7ac81ed5-e3d3-4b7f-a7fb-11d6c34a0995";
17414        assert_eq!(state.template_display_name(id), "Unknown item");
17415        state.item_catalog.insert(
17416            id.into(),
17417            ItemCatalogEntryView {
17418                template_id: id.into(),
17419                display_name: "Emerald".into(),
17420                category: "resource".into(),
17421                seed_for: None,
17422            },
17423        );
17424        assert_eq!(state.template_display_name(id), "Emerald");
17425    }
17426
17427    #[test]
17428    fn whisper_cancels_when_peer_walks_out_of_range() {
17429        let mut state = sample_state();
17430        state.player = state.entities.first().cloned();
17431        let mut peer = state.entities[0].clone();
17432        peer.id = 2;
17433        peer.label = "Ada".into();
17434        peer.transform.position = WorldCoord::surface(129.0, 128.0); // 1m — in range
17435        state.entities.push(peer.clone());
17436        state.social_chat.focus_whisper(2, "Ada");
17437        state.refresh_whisper_range();
17438        assert!(matches!(
17439            state.social_chat.thread,
17440            crate::social::ChatThreadKind::Whisper { peer: 2 }
17441        ));
17442
17443        peer.transform.position = WorldCoord::surface(132.0, 128.0); // 4m — out of range
17444        state.entities[1] = peer;
17445        state.refresh_whisper_range();
17446        assert_eq!(
17447            state.social_chat.thread,
17448            crate::social::ChatThreadKind::Nearby
17449        );
17450        assert!(!state.social_chat.input_focused);
17451    }
17452
17453    #[test]
17454    fn probe_use_world_hired_worker_manage() {
17455        let mut state = sample_state();
17456        state
17457            .hired_workers
17458            .push(flatland_protocol::HiredWorkerView {
17459                instance_id: "worker-1".into(),
17460                entity_id: 42,
17461                def_id: "worker_laborer".into(),
17462                label: "Sam".into(),
17463                x: 129.0,
17464                y: 128.0,
17465                z: 0.0,
17466                mode: flatland_protocol::WorkerModeView::JobLoop,
17467                state: flatland_protocol::WorkerStateView::Working,
17468                step_label: "cultivate".into(),
17469                vitals: flatland_protocol::WorkerVitalsSummary {
17470                    health_pct: 100.0,
17471                    stamina_pct: 100.0,
17472                    mana_pct: 100.0,
17473                    hunger_pct: 100.0,
17474                    thirst_pct: 100.0,
17475                },
17476                carry_pct: 0.0,
17477                last_error: None,
17478                wage_copper_per_interval: 1,
17479                effective_wage_copper: 1,
17480                wage_meters_walked: 0.0,
17481                lodging_container_id: None,
17482                route: None,
17483                route_stop_index: None,
17484                known_blueprint_ids: Vec::new(),
17485                level: 1,
17486                worker_xp: 0.0,
17487                inventory: Vec::new(),
17488                equipment: flatland_protocol::WorkerEquipmentView::default(),
17489                issue_hint: None,
17490                has_blocking_issue: false,
17491                harvest_node_issues: Vec::new(),
17492            });
17493        let probe = state.probe_use_world();
17494        let primary = probe.primary.expect("primary");
17495        assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17496        assert_eq!(primary.id, "worker-1");
17497        assert!(primary.hint_line().contains("Manage"));
17498        assert!(primary.hint_line().contains("Sam"));
17499        assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17500    }
17501
17502    fn sample_hired_worker(x: f32, y: f32) -> flatland_protocol::HiredWorkerView {
17503        flatland_protocol::HiredWorkerView {
17504            instance_id: "worker-1".into(),
17505            entity_id: 42,
17506            def_id: "worker_laborer".into(),
17507            label: "Sam".into(),
17508            x,
17509            y,
17510            z: 0.0,
17511            mode: flatland_protocol::WorkerModeView::JobLoop,
17512            state: flatland_protocol::WorkerStateView::Working,
17513            step_label: "follow".into(),
17514            vitals: flatland_protocol::WorkerVitalsSummary {
17515                health_pct: 100.0,
17516                stamina_pct: 100.0,
17517                mana_pct: 100.0,
17518                hunger_pct: 100.0,
17519                thirst_pct: 100.0,
17520            },
17521            carry_pct: 0.0,
17522            last_error: None,
17523            wage_copper_per_interval: 1,
17524            effective_wage_copper: 1,
17525            wage_meters_walked: 0.0,
17526            lodging_container_id: None,
17527            route: None,
17528            route_stop_index: None,
17529            known_blueprint_ids: Vec::new(),
17530            level: 1,
17531            worker_xp: 0.0,
17532            inventory: Vec::new(),
17533            equipment: flatland_protocol::WorkerEquipmentView::default(),
17534            issue_hint: None,
17535            has_blocking_issue: false,
17536            harvest_node_issues: Vec::new(),
17537        }
17538    }
17539
17540    #[test]
17541    fn probe_harvest_beats_closer_hired_worker() {
17542        let mut state = sample_state();
17543        state.resource_nodes[0].x = 129.0;
17544        state.resource_nodes[0].y = 128.0;
17545        state.hired_workers.push(sample_hired_worker(128.2, 128.0));
17546        let probe = state.probe_use_world();
17547        let primary = probe.primary.expect("primary");
17548        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
17549        assert_eq!(primary.id, "oak-1");
17550        assert!(state.harvestable_node_in_range());
17551        assert_eq!(
17552            state.nearest_interact_target().as_deref(),
17553            Some("worker-1"),
17554            "harvest is not Interact — worker remains the interact target"
17555        );
17556    }
17557
17558    #[test]
17559    fn probe_door_beats_closer_hired_worker() {
17560        let mut state = sample_state();
17561        state.doors[0].x = 129.2;
17562        state.doors[0].y = 128.0;
17563        state.hired_workers.push(sample_hired_worker(128.3, 128.0));
17564        let probe = state.probe_use_world();
17565        let primary = probe.primary.expect("primary");
17566        assert!(
17567            matches!(
17568                primary.kind,
17569                crate::UseWorldKind::EnterDoor
17570                    | crate::UseWorldKind::OpenDoor
17571                    | crate::UseWorldKind::CloseDoor
17572                    | crate::UseWorldKind::ExitDoor
17573            ),
17574            "door should win over closer worker, got {:?}",
17575            primary.kind
17576        );
17577        assert_eq!(primary.id, "door-1");
17578        assert_eq!(state.nearest_interact_target().as_deref(), Some("door-1"));
17579    }
17580
17581    #[test]
17582    fn probe_door_beats_worker_on_latch_approach() {
17583        // Worker parked ~0.9 m in front of the door: player is in worker `f` range
17584        // (1.5 m) but 2.2 m from the latch (outside the 2 m door radius). `f` must
17585        // still Open/Enter, not Manage — otherwise companions block the house.
17586        let mut state = sample_state();
17587        state.entities[0].transform.position = WorldCoord::surface(128.0, 127.8);
17588        state.player = state.entities.first().cloned();
17589        state.doors[0].x = 128.0;
17590        state.doors[0].y = 130.0;
17591        state.hired_workers.push(sample_hired_worker(128.0, 129.1));
17592        let worker_dist = ((128.0_f32) - 128.0).hypot(129.1 - 127.8);
17593        let door_dist = ((128.0_f32) - 128.0).hypot(130.0 - 127.8);
17594        assert!(
17595            worker_dist <= 1.5 && door_dist > 2.0,
17596            "fixture must be worker-in / door-out of the old radii, got worker={worker_dist:.2} door={door_dist:.2}"
17597        );
17598        let probe = state.probe_use_world();
17599        let primary = probe.primary.expect("primary");
17600        assert!(
17601            matches!(
17602                primary.kind,
17603                crate::UseWorldKind::EnterDoor
17604                    | crate::UseWorldKind::OpenDoor
17605                    | crate::UseWorldKind::CloseDoor
17606                    | crate::UseWorldKind::ExitDoor
17607            ),
17608            "door must win over latch-blocking worker, got {:?}",
17609            primary.kind
17610        );
17611        assert_eq!(primary.id, "door-1");
17612        assert_eq!(state.nearest_interact_target().as_deref(), Some("door-1"));
17613    }
17614
17615    #[test]
17616    fn probe_indoor_exit_door_beats_lodging_chest_pickup() {
17617        // MadSin-style house: exit door + lodging beds within chest pickup radius.
17618        // `f` / probe must prefer the door (cascade stage 0) over ChestPickup (stage 2).
17619        let mut state = sample_state();
17620        state.entities[0].inside_building = Some("player_house".into());
17621        state.entities[0].transform.position = WorldCoord::surface(5.0, 2.0);
17622        state.player = state.entities.first().cloned();
17623        state.buildings = vec![BuildingView {
17624            id: "player_house".into(),
17625            label: "MadSin's house".into(),
17626            x: 100.0,
17627            y: 100.0,
17628            width_m: 10.0,
17629            depth_m: 8.0,
17630            interior_blueprint: Some("player_house".into()),
17631            tags: vec!["player_built".into()],
17632            market_boundary_zone_ids: vec![],
17633            market_max_volume: None,
17634            wall_set: None,
17635            roof_set: None,
17636        }];
17637        state.doors = vec![flatland_protocol::DoorView {
17638            id: "house_exit".into(),
17639            building_id: "player_house".into(),
17640            x: 5.0,
17641            y: 1.0,
17642            open: true,
17643            portal: Some("front".into()),
17644            locked: false,
17645            accessible: true,
17646            lock_id: None,
17647        }];
17648        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17649            id: "lodging_bed".into(),
17650            template_id: "camp_bed".into(),
17651            display_name: "Camp bed".into(),
17652            x: 5.5,
17653            y: 2.4,
17654            z: 0.0,
17655            locked: false,
17656            accessible: true,
17657            owner_character_id: None,
17658            contents: vec![],
17659            lock_id: None,
17660            capacity_volume: None,
17661            item_instance_id: Some(uuid::Uuid::from_u128(99)),
17662            tile_id: None,
17663            worker_lodging_capacity: Some(1),
17664            blocking: false,
17665            blocking_radius_m: 0.0,
17666            building_id: Some("player_house".into()),
17667        }];
17668        // Worker assigned to the bed but standing far away — occupancy only.
17669        let mut worker = sample_hired_worker(40.0, 40.0);
17670        worker.lodging_container_id = Some("lodging_bed".into());
17671        state.hired_workers.push(worker);
17672
17673        let probe = state.probe_use_world();
17674        let primary = probe.primary.expect("primary");
17675        assert!(
17676            matches!(
17677                primary.kind,
17678                crate::UseWorldKind::ExitDoor
17679                    | crate::UseWorldKind::OpenDoor
17680                    | crate::UseWorldKind::CloseDoor
17681                    | crate::UseWorldKind::EnterDoor
17682            ),
17683            "indoor exit must beat lodging ChestPickup, got {:?}",
17684            primary.kind
17685        );
17686        assert_eq!(primary.id, "house_exit");
17687        assert_eq!(primary.kind.cascade_stage(), 0);
17688        assert!(
17689            probe
17690                .candidates
17691                .iter()
17692                .any(|c| c.kind == crate::UseWorldKind::ChestPickup && c.in_range),
17693            "lodging bed should still be an in-range chest candidate"
17694        );
17695        assert_eq!(
17696            state.nearest_interact_target().as_deref(),
17697            Some("house_exit"),
17698            "use_nearest interact path should target the door"
17699        );
17700        assert!(state.lodging_is_occupied("lodging_bed"));
17701    }
17702
17703    #[test]
17704    fn probe_worker_when_no_resource_or_door_in_range() {
17705        let mut state = sample_state();
17706        // oak stays at 130,128 (2m, out of harvest); door stays at hut (~22m)
17707        state.hired_workers.push(sample_hired_worker(129.0, 128.0));
17708        let probe = state.probe_use_world();
17709        let primary = probe.primary.expect("primary");
17710        assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17711        assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17712        assert!(!state.harvestable_node_in_range());
17713    }
17714
17715    #[test]
17716    fn market_clerk_verb_options_include_market() {
17717        let mut state = sample_state();
17718        state.npcs.push(flatland_protocol::NpcView {
17719            id: "mira_market".into(),
17720            label: "Mira".into(),
17721            role: "market_clerk".into(),
17722            x: 129.0,
17723            y: 128.0,
17724            building_id: Some("town_market".into()),
17725            entity_id: None,
17726            life_state: None,
17727            hp_pct: None,
17728            can_trade: false,
17729            buy_templates: vec![],
17730            tile_id: None,
17731            behavior_state: None,
17732            presentation_state: None,
17733            sprite_mode: None,
17734            paperdoll_ref: None,
17735            draw_scale: 1.0,
17736            yaw: None,
17737            perception_fov_deg: None,
17738            perception_sight_m: None,
17739            perception_hear_m: None,
17740            quest_verbs: Vec::new(),
17741        });
17742        state.npc_verb_target = Some("mira_market".into());
17743        assert_eq!(
17744            state
17745                .npc_verb_options()
17746                .iter()
17747                .map(|v| v.label.as_str())
17748                .collect::<Vec<_>>(),
17749            vec!["Market", "Talk"]
17750        );
17751    }
17752
17753    #[test]
17754    fn butcher_verb_options_include_turn_in_for_give_item() {
17755        let mut state = sample_state();
17756        state.npcs.push(flatland_protocol::NpcView {
17757            id: "town_butcher_1".into(),
17758            label: "Brutus".into(),
17759            role: "butcher".into(),
17760            x: 129.0,
17761            y: 128.0,
17762            building_id: None,
17763            entity_id: None,
17764            life_state: None,
17765            hp_pct: None,
17766            can_trade: true,
17767            buy_templates: vec!["raw_venison".into()],
17768            tile_id: None,
17769            behavior_state: None,
17770            presentation_state: None,
17771            sprite_mode: None,
17772            paperdoll_ref: None,
17773            draw_scale: 1.0,
17774            yaw: None,
17775            perception_fov_deg: None,
17776            perception_sight_m: None,
17777            perception_hear_m: None,
17778            quest_verbs: Vec::new(),
17779        });
17780        state.quest_log.push(flatland_protocol::QuestLogEntry {
17781            quest_id: "deer_threat".into(),
17782            title: "Deer threat".into(),
17783            description: String::new(),
17784            status: flatland_protocol::QuestStatusView::Active,
17785            current_step_id: Some("deliver".into()),
17786            current_step_title: "Deliver venison".into(),
17787            current_step_index: 0,
17788            objectives: vec![flatland_protocol::QuestObjectiveProgress {
17789                label: "Give 3 Raw venison to Brutus".into(),
17790                current: 0,
17791                required: 3,
17792                done: false,
17793                kind: "give_item".into(),
17794                npc_ref: Some("town_butcher_1".into()),
17795                item_template: Some("raw_venison".into()),
17796                blueprint_id: None,
17797                building_id: None,
17798            }],
17799            current_step_reward: flatland_protocol::QuestRewardView::default(),
17800            completion_reward: flatland_protocol::QuestRewardView::default(),
17801            steps: Vec::new(),
17802            is_tracked: true,
17803            can_withdraw: true,
17804        });
17805        state.npc_verb_target = Some("town_butcher_1".into());
17806        assert_eq!(
17807            state
17808                .npc_verb_options()
17809                .iter()
17810                .map(|v| v.label.as_str())
17811                .collect::<Vec<_>>(),
17812            vec!["Turn in: Deer threat", "Talk", "Trade"]
17813        );
17814    }
17815
17816    #[test]
17817    fn ada_verb_options_include_quest_offer() {
17818        let mut state = sample_state();
17819        state.npcs.push(flatland_protocol::NpcView {
17820            id: "ada_broker".into(),
17821            label: "Ada".into(),
17822            role: "broker".into(),
17823            x: 129.0,
17824            y: 128.0,
17825            building_id: None,
17826            entity_id: None,
17827            life_state: None,
17828            hp_pct: None,
17829            can_trade: true,
17830            buy_templates: vec![],
17831            tile_id: None,
17832            behavior_state: None,
17833            presentation_state: None,
17834            sprite_mode: None,
17835            paperdoll_ref: Some("ada_broker".into()),
17836            draw_scale: 1.0,
17837            yaw: None,
17838            perception_fov_deg: None,
17839            perception_sight_m: None,
17840            perception_hear_m: None,
17841            quest_verbs: vec![flatland_protocol::NpcQuestVerb {
17842                quest_id: "ada_goblin_hunt".into(),
17843                label: "Ask about goblins".into(),
17844                kind: flatland_protocol::NpcQuestVerb::KIND_OFFER.into(),
17845            }],
17846        });
17847        state.npc_verb_target = Some("ada_broker".into());
17848        assert_eq!(
17849            state
17850                .npc_verb_options()
17851                .iter()
17852                .map(|v| v.label.as_str())
17853                .collect::<Vec<_>>(),
17854            vec!["Ask about goblins", "Talk", "Trade"]
17855        );
17856    }
17857
17858    #[test]
17859    fn market_list_excludes_currency_stacks() {
17860        let mut state = sample_state();
17861        state.inventory_stacks = vec![
17862            flatland_protocol::ItemStack {
17863                template_id: "copper_coin".into(),
17864                quantity: 50,
17865                item_instance_id: Some(uuid::Uuid::from_u128(10)),
17866                display_name: Some("Copper Coin".into()),
17867                ..Default::default()
17868            },
17869            flatland_protocol::ItemStack {
17870                template_id: "oak_log".into(),
17871                quantity: 2,
17872                item_instance_id: Some(uuid::Uuid::from_u128(11)),
17873                display_name: Some("Oak Log".into()),
17874                ..Default::default()
17875            },
17876            flatland_protocol::ItemStack {
17877                template_id: "whisper_stone".into(),
17878                quantity: 1,
17879                item_instance_id: Some(uuid::Uuid::from_u128(12)),
17880                display_name: Some("Whisper Stone".into()),
17881                category: Some("quest".into()),
17882                listable: Some(false),
17883                ..Default::default()
17884            },
17885        ];
17886        let opts = state.market_list_item_options(&MarketListSourceKind::Person);
17887        assert_eq!(opts.len(), 1);
17888        assert!(opts[0].label.contains("Oak"));
17889    }
17890
17891    #[test]
17892    fn market_browse_filters_by_category_and_search() {
17893        let mut state = sample_state();
17894        state.market_panel = Some(flatland_protocol::MarketPanel {
17895            npc_id: "mira_market".into(),
17896            npc_label: "Mira".into(),
17897            building_id: "town_market".into(),
17898            building_label: "Town Market".into(),
17899            used_volume: 0.0,
17900            max_volume: 100.0,
17901            listings: vec![
17902                flatland_protocol::MarketListingView {
17903                    listing_id: uuid::Uuid::from_u128(1),
17904                    seller_character_id: uuid::Uuid::from_u128(2),
17905                    seller_label: "Ada".into(),
17906                    hall_building_id: "town_market".into(),
17907                    hall_label: "Town Market".into(),
17908                    template_id: "oak_log".into(),
17909                    display_name: "Oak Log".into(),
17910                    category: "resource".into(),
17911                    quantity: 3,
17912                    unit_price_copper: 10,
17913                    line_total_copper: 30,
17914                    npc_price: false,
17915                    npc_dump_unit_copper: None,
17916                    mine: false,
17917                },
17918                flatland_protocol::MarketListingView {
17919                    listing_id: uuid::Uuid::from_u128(3),
17920                    seller_character_id: uuid::Uuid::from_u128(2),
17921                    seller_label: "Ada".into(),
17922                    hall_building_id: "town_market".into(),
17923                    hall_label: "Town Market".into(),
17924                    template_id: "short_sword".into(),
17925                    display_name: "Short Sword".into(),
17926                    category: "weapon".into(),
17927                    quantity: 1,
17928                    unit_price_copper: 100,
17929                    line_total_copper: 100,
17930                    npc_price: false,
17931                    npc_dump_unit_copper: None,
17932                    mine: false,
17933                },
17934            ],
17935            tax_bps: 0,
17936            tax_flat_copper: 0,
17937            list_vaults: vec![],
17938        });
17939        assert_eq!(state.market_filtered_listing_indices().len(), 2);
17940        state.market_category_filter = Some("Weapons");
17941        let weapons = state.market_filtered_listing_indices();
17942        assert_eq!(weapons.len(), 1);
17943        assert_eq!(
17944            state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
17945            "Short Sword"
17946        );
17947        state.market_category_filter = None;
17948        state.market_filter = "oak".into();
17949        let oak = state.market_filtered_listing_indices();
17950        assert_eq!(oak.len(), 1);
17951        assert_eq!(
17952            state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
17953            "Oak Log"
17954        );
17955    }
17956
17957    #[test]
17958    fn market_list_source_includes_person_and_vaults() {
17959        let mut state = sample_state();
17960        let item_id = uuid::Uuid::from_u128(1);
17961        state.inventory_stacks = vec![flatland_protocol::ItemStack {
17962            template_id: "oak_log".into(),
17963            quantity: 2,
17964            item_instance_id: Some(item_id),
17965            display_name: Some("Oak Log".into()),
17966            ..Default::default()
17967        }];
17968        state.market_panel = Some(flatland_protocol::MarketPanel {
17969            npc_id: "mira_market".into(),
17970            npc_label: "Mira".into(),
17971            building_id: "town_market".into(),
17972            building_label: "Town Market".into(),
17973            used_volume: 0.0,
17974            max_volume: 100.0,
17975            listings: vec![],
17976            tax_bps: 0,
17977            tax_flat_copper: 0,
17978            list_vaults: vec![flatland_protocol::MarketListVault {
17979                building_id: "town_storage".into(),
17980                building_label: "Town Storage".into(),
17981                contents: vec![flatland_protocol::ItemStack {
17982                    template_id: "lumber".into(),
17983                    quantity: 1,
17984                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
17985                    display_name: Some("Lumber".into()),
17986                    ..Default::default()
17987                }],
17988            }],
17989        });
17990        let sources = state.market_list_source_options();
17991        assert_eq!(sources.len(), 2);
17992        assert!(matches!(sources[0].0, MarketListSourceKind::Person));
17993        assert!(matches!(
17994            sources[1].0,
17995            MarketListSourceKind::TownStorage { .. }
17996        ));
17997        assert!(sources[1].1.contains("Town Storage"));
17998    }
17999
18000    #[test]
18001    fn npc_market_dump_estimate_from_town_storage_vault() {
18002        let mut state = sample_state();
18003        state.market_panel = Some(flatland_protocol::MarketPanel {
18004            npc_id: "mira_market".into(),
18005            npc_label: "Mira".into(),
18006            building_id: "town_market".into(),
18007            building_label: "Town Market".into(),
18008            used_volume: 0.0,
18009            max_volume: 100.0,
18010            listings: vec![],
18011            tax_bps: 0,
18012            tax_flat_copper: 0,
18013            list_vaults: vec![flatland_protocol::MarketListVault {
18014                building_id: "town_storage".into(),
18015                building_label: "Town Storage".into(),
18016                contents: vec![flatland_protocol::ItemStack {
18017                    template_id: "lumber".into(),
18018                    quantity: 3,
18019                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
18020                    display_name: Some("Lumber".into()),
18021                    base_value_copper: Some(20),
18022                    ..Default::default()
18023                }],
18024            }],
18025        });
18026        assert_eq!(
18027            state.npc_market_dump_unit_estimate("lumber"),
18028            Some(9),
18029            "vault stack base_value should enable NPC price estimate"
18030        );
18031    }
18032
18033    #[test]
18034    fn probe_use_world_npc_beats_nearby_loot() {
18035        let mut state = sample_state();
18036        state.npcs.push(flatland_protocol::NpcView {
18037            id: "ada".into(),
18038            label: "Ada".into(),
18039            role: "broker".into(),
18040            x: 129.0,
18041            y: 128.0,
18042            building_id: None,
18043            entity_id: None,
18044            life_state: None,
18045            hp_pct: None,
18046            can_trade: true,
18047            buy_templates: vec!["lumber".into()],
18048            tile_id: None,
18049            behavior_state: None,
18050            presentation_state: None,
18051            sprite_mode: None,
18052            paperdoll_ref: None,
18053            draw_scale: 1.0,
18054            yaw: None,
18055            perception_fov_deg: None,
18056            perception_sight_m: None,
18057            perception_hear_m: None,
18058            quest_verbs: Vec::new(),
18059        });
18060        state.ground_drops.push(flatland_protocol::GroundDropView {
18061            id: "d1".into(),
18062            template_id: "lumber".into(),
18063            quantity: 1,
18064            x: 128.5,
18065            y: 128.0,
18066            z: 0.0,
18067            tile_id: None,
18068            display_name: None,
18069            yaw: 0.0,
18070            pitch: 0.0,
18071            roll: 0.0,
18072            draw_scale: 1.0,
18073            item_instance_id: None,
18074            props: Default::default(),
18075            status_bindings: Vec::new(),
18076        });
18077        let probe = state.probe_use_world();
18078        let primary = probe.primary.expect("primary");
18079        assert_eq!(primary.kind, crate::UseWorldKind::Npc);
18080        assert_eq!(primary.id, "ada");
18081    }
18082
18083    #[test]
18084    fn probe_use_world_harvest_when_in_range() {
18085        let state = sample_state(); // oak at 130,128 — player 128,128 → dist 2 > 1.5
18086        let probe = state.probe_use_world();
18087        assert!(
18088            probe.primary.is_none(),
18089            "oak is 2m away, out of harvest range"
18090        );
18091        assert!(probe
18092            .candidates
18093            .iter()
18094            .any(|c| c.kind == crate::UseWorldKind::Harvest));
18095
18096        let mut state = sample_state();
18097        state.resource_nodes[0].x = 129.0;
18098        let probe = state.probe_use_world();
18099        let primary = probe.primary.expect("primary");
18100        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
18101    }
18102
18103    #[test]
18104    fn probe_use_world_door_uses_building_label() {
18105        let mut state = sample_state();
18106        state.doors[0].x = 129.0;
18107        state.doors[0].y = 128.0;
18108        let probe = state.probe_use_world();
18109        let primary = probe.primary.expect("primary");
18110        assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
18111        assert_eq!(primary.label, "Broker");
18112        assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
18113    }
18114
18115    #[test]
18116    fn empty_entity_tick_preserves_welcome_snapshot() {
18117        let mut state = sample_state();
18118        state.inventory.insert("carrot".into(), 3);
18119        let delta = TickDelta {
18120            tick: 1,
18121            entities: vec![],
18122            resource_nodes: vec![],
18123            ground_drops: vec![],
18124            placed_containers: vec![],
18125            buildings: vec![],
18126            doors: vec![],
18127            interior_map: None,
18128            npcs: vec![],
18129            inventory: vec![],
18130            blueprints: vec![],
18131            building_materials: vec![],
18132            world_clock: flatland_protocol::WorldClock::default(),
18133            combat: None,
18134            quest_log: vec![],
18135            hired_workers: Vec::new(),
18136            interactables: vec![],
18137            ledger: None,
18138            career: None,
18139            combat_fx: Vec::new(),
18140            ground_hazards: Vec::new(),
18141            property_plots: Vec::new(),
18142            terrain_overlays: Vec::new(),
18143        };
18144
18145        state.apply_tick_fields(&delta, 1);
18146
18147        assert_eq!(state.entities.len(), 1);
18148        assert!(state.player.is_some());
18149        assert_eq!(state.inventory.get("carrot"), Some(&3));
18150        assert_eq!(state.resource_nodes.len(), 1);
18151    }
18152
18153    #[test]
18154    fn tick_preserves_world_layers_when_delta_omits_them() {
18155        let mut state = sample_state();
18156        let delta = TickDelta {
18157            tick: 1,
18158            entities: state.entities.clone(),
18159            resource_nodes: vec![],
18160            ground_drops: vec![],
18161            placed_containers: vec![],
18162            buildings: vec![],
18163            doors: vec![],
18164            interior_map: None,
18165            npcs: vec![],
18166            inventory: vec![],
18167            blueprints: vec![],
18168            building_materials: vec![],
18169            world_clock: flatland_protocol::WorldClock::default(),
18170            combat: None,
18171            quest_log: vec![],
18172            hired_workers: Vec::new(),
18173            interactables: vec![],
18174            ledger: None,
18175            career: None,
18176            combat_fx: Vec::new(),
18177            ground_hazards: Vec::new(),
18178            property_plots: Vec::new(),
18179            terrain_overlays: Vec::new(),
18180        };
18181
18182        state.apply_tick_fields(&delta, 1);
18183
18184        assert_eq!(state.resource_nodes.len(), 1);
18185        assert_eq!(state.buildings.len(), 1);
18186        assert_eq!(state.doors.len(), 1);
18187    }
18188
18189    #[test]
18190    fn tick_updates_resource_nodes_when_server_sends_them() {
18191        let mut state = sample_state();
18192        let delta = TickDelta {
18193            tick: 1,
18194            entities: state.entities.clone(),
18195            resource_nodes: vec![ResourceNodeView {
18196                id: "oak-1".into(),
18197                label: "Oak".into(),
18198                x: 130.0,
18199                y: 128.0,
18200                z: 0.0,
18201                item_template: "oak_log".into(),
18202                state: ResourceNodeState::Cooldown,
18203                blocking: true,
18204                blocking_radius_m: 0.8,
18205                harvest_off: false,
18206                tile_id: None,
18207                yaw: 0.0,
18208                pitch: 0.0,
18209                roll: 0.0,
18210                draw_scale: 1.0,
18211                sprite_mode: None,
18212                growth_progress: None,
18213                presentation_state: None,
18214                channel_start_tick: None,
18215                channel_end_tick: None,
18216                harvest_drop_templates: vec![],
18217            }],
18218            buildings: vec![],
18219            doors: vec![],
18220            interior_map: None,
18221            npcs: vec![],
18222            inventory: vec![],
18223            blueprints: vec![],
18224            building_materials: vec![],
18225            world_clock: flatland_protocol::WorldClock::default(),
18226            ground_drops: vec![],
18227            placed_containers: vec![],
18228            combat: None,
18229            quest_log: vec![],
18230            hired_workers: Vec::new(),
18231            interactables: vec![],
18232            ledger: None,
18233            career: None,
18234            combat_fx: Vec::new(),
18235            ground_hazards: Vec::new(),
18236            property_plots: Vec::new(),
18237            terrain_overlays: Vec::new(),
18238        };
18239
18240        state.apply_tick_fields(&delta, 1);
18241
18242        assert!(matches!(
18243            state.resource_nodes[0].state,
18244            ResourceNodeState::Cooldown
18245        ));
18246    }
18247
18248    #[test]
18249    fn harvest_picker_keeps_welcome_nodes_after_aoi_tick() {
18250        let mut state = sample_state();
18251        let nearby = state.resource_nodes[0].clone();
18252        let mut far = nearby.clone();
18253        far.id = "far-oak".into();
18254        far.label = "Far Oak".into();
18255        far.x = 200.0;
18256        far.y = 200.0;
18257        state.replace_harvest_route_nodes(&[nearby.clone(), far.clone()]);
18258
18259        let delta = TickDelta {
18260            tick: 1,
18261            entities: state.entities.clone(),
18262            resource_nodes: vec![nearby],
18263            ground_drops: vec![],
18264            placed_containers: vec![],
18265            buildings: vec![],
18266            doors: vec![],
18267            interior_map: None,
18268            npcs: vec![],
18269            inventory: vec![],
18270            blueprints: vec![],
18271            building_materials: vec![],
18272            world_clock: flatland_protocol::WorldClock::default(),
18273            combat: None,
18274            quest_log: vec![],
18275            hired_workers: Vec::new(),
18276            interactables: vec![],
18277            ledger: None,
18278            career: None,
18279            combat_fx: Vec::new(),
18280            ground_hazards: Vec::new(),
18281            property_plots: Vec::new(),
18282            terrain_overlays: Vec::new(),
18283        };
18284        state.apply_tick_fields(&delta, 1);
18285
18286        assert_eq!(state.resource_nodes.len(), 1);
18287        let ids: Vec<_> = state
18288            .route_editor_node_candidates()
18289            .into_iter()
18290            .map(|n| n.id)
18291            .collect();
18292        assert!(ids.contains(&"oak-1".to_string()), "got {ids:?}");
18293        assert!(ids.contains(&"far-oak".to_string()), "got {ids:?}");
18294    }
18295
18296    #[test]
18297    fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
18298        let mut state = GameState {
18299            session_id: 1,
18300            entity_id: 1,
18301            character_id: None,
18302            tick: 0,
18303            chunk_rev: 0,
18304            content_rev: 0,
18305            publish_rev: 0,
18306            entities: vec![EntityState {
18307                id: 1,
18308                label: "You".into(),
18309                transform: Transform {
18310                    position: WorldCoord::surface(4.5, 2.0),
18311                    yaw: 0.0,
18312                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
18313                },
18314                vitals: None,
18315                attributes: None,
18316                skills: None,
18317                inside_building: Some("broker_hut".into()),
18318                tile_id: None,
18319                paperdoll_ref: None,
18320                draw_scale: 1.0,
18321                presentation_state: None,
18322                sprite_mode: None,
18323                progression_xp: None,
18324                combat_cues: vec![],
18325                statuses: vec![],
18326            }],
18327            player: None,
18328            resource_nodes: vec![],
18329            harvest_route_nodes: vec![],
18330            ground_drops: vec![],
18331            placed_containers: vec![],
18332            buildings: vec![BuildingView {
18333                id: "broker_hut".into(),
18334                label: "Broker".into(),
18335                x: 158.0,
18336                y: 124.0,
18337                width_m: 8.0,
18338                depth_m: 6.0,
18339                interior_blueprint: Some("broker_hut".into()),
18340                tags: vec![],
18341                market_boundary_zone_ids: vec![],
18342                market_max_volume: None,
18343                wall_set: None,
18344                roof_set: None,
18345            }],
18346            doors: vec![flatland_protocol::DoorView {
18347                id: "broker_hut_exit".into(),
18348                building_id: "broker_hut".into(),
18349                x: 4.3,
18350                y: 0.9,
18351                open: true,
18352                portal: Some("front".into()),
18353                locked: false,
18354                accessible: true,
18355                lock_id: None,
18356            }],
18357            interior_map: None,
18358            npcs: vec![flatland_protocol::NpcView {
18359                id: "ada_broker".into(),
18360                label: "Ada".into(),
18361                x: 4.5,
18362                y: 2.0,
18363                building_id: Some("broker_hut".into()),
18364                role: "broker".into(),
18365                entity_id: None,
18366                life_state: None,
18367                hp_pct: None,
18368                can_trade: true,
18369                buy_templates: vec!["lumber".into()],
18370                tile_id: None,
18371                behavior_state: None,
18372                presentation_state: None,
18373                sprite_mode: None,
18374                paperdoll_ref: None,
18375                draw_scale: 1.0,
18376                yaw: None,
18377                perception_fov_deg: None,
18378                perception_sight_m: None,
18379                perception_hear_m: None,
18380                quest_verbs: Vec::new(),
18381            }],
18382            blueprints: vec![],
18383            building_materials: vec![],
18384            world_x0: 0.0,
18385            world_y0: 0.0,
18386            world_width_m: 256.0,
18387            world_height_m: 256.0,
18388            terrain_zones: Vec::new(),
18389            z_platforms: Vec::new(),
18390            z_transitions: Vec::new(),
18391            z_bands_outdoor_backup: None,
18392            world_clock: flatland_protocol::WorldClock::default(),
18393            inventory: std::collections::HashMap::new(),
18394            inventory_hints: std::collections::HashMap::new(),
18395            item_catalog: std::collections::HashMap::new(),
18396            logs: VecDeque::new(),
18397            intents_sent: 0,
18398            ticks_received: 0,
18399            connected: true,
18400            disconnect_reason: None,
18401            show_stats: false,
18402            hud_log_hidden: false,
18403            show_equip_menu: false,
18404            equip_menu_index: 0,
18405            show_craft_menu: false,
18406            show_plot_build_menu: false,
18407            plot_build_focus_wall: true,
18408            plot_build_wall_index: 0,
18409            plot_build_roof_index: 0,
18410            craft_menu_index: 0,
18411            craft_batch_quantity: 1,
18412            craft_tab: CraftTab::Ready,
18413            craft_filter: String::new(),
18414            craft_filter_focused: false,
18415            craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
18416            show_shop_menu: false,
18417            shop_catalog: None,
18418            bank_panel: None,
18419            bank_menu_index: 0,
18420            bank_ui_mode: BankUiMode::Menu,
18421            storage_panel: None,
18422            market_panel: None,
18423            market_menu_index: 0,
18424            market_filter: String::new(),
18425            market_filter_focused: false,
18426            market_category_filter: None,
18427            market_buy_confirm: None,
18428            market_ui_mode: MarketUiMode::Browse,
18429            storage_menu_index: 0,
18430            storage_ui_mode: StorageUiMode::Menu,
18431            shop_tab: ShopTab::default(),
18432            shop_menu_index: 0,
18433            shop_quantity: 1,
18434            shop_trade_log: VecDeque::new(),
18435            show_npc_verb_menu: false,
18436            npc_verb_target: None,
18437            npc_verb_index: 0,
18438            npc_verb_notice: None,
18439            player_verbs: crate::social::PlayerVerbState::default(),
18440            social_chat: crate::social::SocialChatState::default(),
18441            trade_ui: crate::social::TradeUiState::default(),
18442            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
18443            show_npc_chat: false,
18444            npc_chat: None,
18445            show_inventory_menu: false,
18446            inventory_menu_index: 0,
18447            inventory_tab: InventoryTab::OnPerson,
18448            inventory_filter: String::new(),
18449            inventory_filter_focused: false,
18450            show_move_picker: false,
18451            show_rename_prompt: false,
18452            rename_plot_id: None,
18453            highlighted_plot_id: None,
18454            show_worker_rename: false,
18455            rename_buffer: String::new(),
18456            move_picker_index: 0,
18457            move_picker: None,
18458            show_grant_picker: false,
18459            grant_picker_index: 0,
18460            grant_picker: None,
18461            show_destroy_picker: false,
18462            destroy_confirm_pending: false,
18463            destroy_picker: None,
18464            show_deconstruct_picker: false,
18465            deconstruct_confirm_pending: false,
18466            deconstruct_picker: None,
18467            combat_target: None,
18468            combat_target_label: None,
18469            ground_target: None,
18470            combat_fx: Vec::new(),
18471            ground_hazards: Vec::new(),
18472            property_zones: Vec::new(),
18473            tax_zones: Vec::new(),
18474            growth_zones: Vec::new(),
18475            biome_zones: Vec::new(),
18476            terrain_kind_nav: Vec::new(),
18477            property_plots: Vec::new(),
18478            property_plot_settings: None,
18479            claim_mode: None,
18480            relocate_mode: None,
18481            sell_plot_confirm: None,
18482            sell_plot_armed_at: None,
18483            show_plant_menu: false,
18484            plant_menu_index: 0,
18485            show_farm_access: false,
18486            farm_access_name_draft: String::new(),
18487            farm_access_discount_bps: 0,
18488            farm_access_index: 0,
18489            plant_quantity: 1,
18490            in_combat: false,
18491            auto_attack: true,
18492            combat_has_los: false,
18493            attack_cd_ticks: 0,
18494            gcd_ticks: 0,
18495            weapon_ability_id: "unarmed".into(),
18496            mainhand_template_id: None,
18497            mainhand_label: None,
18498            mainhand_instance_id: None,
18499            offhand_template_id: None,
18500            offhand_label: None,
18501            offhand_instance_id: None,
18502            mainhand_hand_slots: 1,
18503            defense: None,
18504            worn: BTreeMap::new(),
18505            carry_mass: 0.0,
18506            carry_mass_max: 0.0,
18507            encumbrance: flatland_protocol::EncumbranceState::Light,
18508            move_speed_mps: 0.0,
18509            move_speed_mult: 0.0,
18510            inventory_stacks: Vec::new(),
18511            keychain_stacks: Vec::new(),
18512            whisper_pouch_stacks: Vec::new(),
18513            combat_target_detail: None,
18514            statuses: Vec::new(),
18515            cast_progress: None,
18516            timed_channel: None,
18517            plot_build_offer: None,
18518            ability_cooldowns: Vec::new(),
18519            blocking_active: false,
18520            max_target_slots: 1,
18521            combat_slots: Vec::new(),
18522            rotation_presets: Vec::new(),
18523            known_abilities: Vec::new(),
18524            ability_meta: std::collections::HashMap::new(),
18525            ability_mastery: std::collections::HashMap::new(),
18526            hotbar: vec![None; 9],
18527            max_abilities_per_rotation: 0,
18528            show_loadout_menu: false,
18529            show_keychain_menu: false,
18530            keychain_menu_index: 0,
18531            show_rotation_editor: false,
18532            loadout_menu_index: 0,
18533            loadout_hotbar_slot: 1,
18534            loadout_ability_index: 0,
18535            loadout_focus_presets: false,
18536            rotation_editor: RotationEditorState::default(),
18537            harvest_in_progress: false,
18538            harvest_started_at: None,
18539            pending_craft_ack: None,
18540            craft_channel_blueprint_id: None,
18541            craft_channel_seen: false,
18542            pending_worker_job_ack: None,
18543            attending_worker_instance_id: None,
18544            quest_log: Vec::new(),
18545            interactables: Vec::new(),
18546            ledger: None,
18547            career: None,
18548            character_sheet_tab: CharacterSheetTab::Character,
18549            ledger_period: LedgerPeriod::Day,
18550            show_quest_offer: false,
18551            pending_quest_offers: Vec::new(),
18552            quest_offer_index: 0,
18553            show_quest_menu: false,
18554            quest_menu_index: 0,
18555            quest_withdraw_confirm: false,
18556            hired_workers: Vec::new(),
18557            show_workers_menu: false,
18558            workers_menu_index: 0,
18559            worker_dismiss_confirmation: None,
18560            workers_menu_compact: false,
18561            worker_step_display: BTreeMap::new(),
18562            worker_error_display: BTreeMap::new(),
18563            worker_health_ring_until: BTreeMap::new(),
18564            pending_worker_hire_since: None,
18565            show_worker_give_picker: false,
18566            worker_give_picker_index: 0,
18567            worker_give_picker: None,
18568            show_worker_give_target_picker: false,
18569            worker_give_target_picker_index: 0,
18570            worker_give_target_picker: None,
18571            show_worker_take_picker: false,
18572            worker_take_picker_index: 0,
18573            worker_take_picker: None,
18574            show_worker_teach_picker: false,
18575            worker_teach_picker_index: 0,
18576            worker_teach_picker: None,
18577            worker_route_editor: None,
18578            progression_curve: None,
18579        };
18580        state.player = state.entities.first().cloned();
18581        assert_eq!(
18582            state.nearest_interact_target().as_deref(),
18583            Some("ada_broker")
18584        );
18585    }
18586
18587    #[test]
18588    fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
18589        let mut state = sample_state();
18590        // Player is at (128, 128) per sample_state(). One chest just inside
18591        // CONTAINER_RANGE_M, one clearly beyond it.
18592        state.placed_containers = vec![
18593            flatland_protocol::PlacedContainerView {
18594                id: "near".into(),
18595                template_id: "wooden_chest_small".into(),
18596                display_name: "Wooden Chest".into(),
18597                x: 130.0,
18598                y: 128.0,
18599                z: 0.0,
18600                locked: true,
18601                accessible: true,
18602                owner_character_id: None,
18603                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
18604                lock_id: None,
18605                capacity_volume: None,
18606                item_instance_id: Some(uuid::Uuid::from_u128(1)),
18607                tile_id: None,
18608                worker_lodging_capacity: None,
18609                blocking: false,
18610                blocking_radius_m: 0.0,
18611                building_id: None,
18612            },
18613            flatland_protocol::PlacedContainerView {
18614                id: "far".into(),
18615                template_id: "wooden_chest_small".into(),
18616                display_name: "Distant Chest".into(),
18617                x: 128.0 + CONTAINER_RANGE_M + 5.0,
18618                y: 128.0,
18619                z: 0.0,
18620                locked: false,
18621                accessible: true,
18622                owner_character_id: None,
18623                contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
18624                lock_id: None,
18625                capacity_volume: None,
18626                item_instance_id: Some(uuid::Uuid::from_u128(2)),
18627                tile_id: None,
18628                worker_lodging_capacity: None,
18629                blocking: false,
18630                blocking_radius_m: 0.0,
18631                building_id: None,
18632            },
18633        ];
18634
18635        let nearby = state.nearby_containers();
18636        assert_eq!(
18637            nearby.len(),
18638            1,
18639            "far chest must not appear once out of range"
18640        );
18641        assert_eq!(nearby[0].view.id, "near");
18642        assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
18643        assert!(nearby[0].rows[0].is_chest_shell);
18644
18645        // The same chest, but locked and inaccessible (no key held), must hide
18646        // contents but still show the selectable chest shell row.
18647        state.placed_containers[0].accessible = false;
18648        let nearby = state.nearby_containers();
18649        assert_eq!(nearby.len(), 1);
18650        assert_eq!(nearby[0].rows.len(), 1);
18651        assert!(nearby[0].rows[0].is_chest_shell);
18652    }
18653
18654    #[test]
18655    fn chest_pickup_destinations_offer_person_and_worn_bag() {
18656        let mut state = sample_state();
18657        let back_id = uuid::Uuid::from_u128(42);
18658        state.worn.insert(
18659            BodySlot::Back,
18660            flatland_protocol::ItemStack {
18661                template_id: "travel_backpack".into(),
18662                quantity: 1,
18663                item_instance_id: Some(back_id),
18664                props: Default::default(),
18665                status_bindings: Vec::new(),
18666                contents: Vec::new(),
18667                display_name: Some("Travel Backpack".into()),
18668                category: Some("container".into()),
18669                base_mass: Some(2.5),
18670                base_volume: Some(12.0),
18671                capacity_volume: Some(80.0),
18672                stackable: Some(false),
18673                world_placeable: Some(false),
18674                worker_lodging_capacity: None,
18675                equip_slot: None,
18676                armor_physical: None,
18677                resists: vec![],
18678                hand_slots: None,
18679                listable: None,
18680                ..Default::default()
18681            },
18682        );
18683        let opts = state.chest_pickup_destinations("chest-1");
18684        assert!(matches!(
18685            opts.first().map(|o| &o.kind),
18686            Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
18687        ));
18688        assert!(opts.iter().any(|o| matches!(
18689            &o.kind,
18690            MoveOptionKind::PickupPlaced {
18691                nest_parent_instance_id: None,
18692                ..
18693            }
18694        )));
18695        assert!(opts.iter().any(|o| matches!(
18696            &o.kind,
18697            MoveOptionKind::PickupPlaced {
18698                nest_parent_instance_id: Some(id),
18699                ..
18700            } if *id == back_id
18701        )));
18702        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
18703    }
18704
18705    #[test]
18706    fn placed_container_public_label_hides_owner_custom_name() {
18707        let owner = uuid::Uuid::from_u128(99);
18708        let mut state = sample_state();
18709        state.character_id = Some(uuid::Uuid::from_u128(1));
18710        state.inventory_hints.insert(
18711            "wooden_chest_medium".into(),
18712            InventoryHint {
18713                display_name: "Medium Wooden Chest".into(),
18714                category: "container".into(),
18715                base_mass: None,
18716                base_volume: None,
18717                capacity_volume: None,
18718                stackable: false,
18719                listable: true,
18720                base_value_copper: None,
18721            },
18722        );
18723        let chest = flatland_protocol::PlacedContainerView {
18724            id: "c1".into(),
18725            template_id: "wooden_chest_medium".into(),
18726            display_name: "Barry's Loot #a3f2".into(),
18727            x: 128.0,
18728            y: 128.0,
18729            z: 0.0,
18730            locked: false,
18731            accessible: true,
18732            owner_character_id: Some(owner),
18733            contents: vec![],
18734            lock_id: None,
18735            capacity_volume: None,
18736            item_instance_id: None,
18737            tile_id: None,
18738            worker_lodging_capacity: None,
18739            blocking: false,
18740            blocking_radius_m: 0.0,
18741            building_id: None,
18742        };
18743        assert_eq!(
18744            state.placed_container_public_label(&chest),
18745            "Medium Wooden Chest"
18746        );
18747        state.character_id = Some(owner);
18748        assert_eq!(
18749            state.placed_container_public_label(&chest),
18750            "Barry's Loot #a3f2"
18751        );
18752    }
18753
18754    #[test]
18755    fn location_context_shows_crop_growth_percent_not_depleted() {
18756        let mut state = sample_state();
18757        state.player = state.entities.first().cloned();
18758        state.resource_nodes[0].label = "Carrot (growing)".into();
18759        state.resource_nodes[0].x = 128.2;
18760        state.resource_nodes[0].y = 128.0;
18761        state.resource_nodes[0].state = ResourceNodeState::Cooldown;
18762        state.resource_nodes[0].growth_progress = Some(0.47);
18763        let lines = state.location_context_lines();
18764        let line = lines
18765            .iter()
18766            .find(|l| l.text.contains("Carrot"))
18767            .map(|l| l.text.as_str())
18768            .unwrap_or("");
18769        assert!(
18770            line.contains("(growing, 47%)"),
18771            "expected growth percent, got: {line}"
18772        );
18773        assert!(
18774            !line.contains("depleted"),
18775            "growing crop should not show depleted: {line}"
18776        );
18777    }
18778
18779    #[test]
18780    fn resource_node_near_action_suffix_prefers_growth() {
18781        let node = ResourceNodeView {
18782            id: "crop".into(),
18783            label: "Wheat".into(),
18784            x: 0.0,
18785            y: 0.0,
18786            z: 0.0,
18787            item_template: "wheat".into(),
18788            state: ResourceNodeState::Cooldown,
18789            blocking: false,
18790            blocking_radius_m: 0.0,
18791            harvest_off: false,
18792            tile_id: None,
18793            yaw: 0.0,
18794            pitch: 0.0,
18795            roll: 0.0,
18796            draw_scale: 1.0,
18797            sprite_mode: None,
18798            growth_progress: Some(0.12),
18799            presentation_state: None,
18800            channel_start_tick: None,
18801            channel_end_tick: None,
18802            harvest_drop_templates: vec![],
18803        };
18804        assert_eq!(resource_node_near_action_suffix(&node), " (growing, 12%)");
18805    }
18806
18807    #[test]
18808    fn location_context_lists_nearby_resource_node() {
18809        let mut state = sample_state();
18810        state.player = state.entities.first().cloned();
18811        state.resource_nodes[0].x = 128.2;
18812        state.resource_nodes[0].y = 128.0;
18813        let lines = state.location_context_lines();
18814        assert!(
18815            lines
18816                .iter()
18817                .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
18818            "expected resource node in context: {:?}",
18819            lines
18820        );
18821    }
18822
18823    #[test]
18824    fn quest_board_usable_within_board_radius() {
18825        let mut state = sample_state();
18826        state.player = state.entities.first().cloned();
18827        state.interactables = vec![flatland_protocol::InteractableView {
18828            id: "board-1".into(),
18829            kind: "quest_board".into(),
18830            label: "Town Quest Board".into(),
18831            x: 130.5,
18832            y: 128.0,
18833            z: 0.0,
18834            board_id: Some("starter_town_board".into()),
18835        }];
18836        // ~2.5m away — outside the old 1.5m interact radius, inside the 3.0m board radius.
18837        assert_eq!(
18838            state.nearest_interact_target().as_deref(),
18839            Some("board-1"),
18840            "quest board should be selectable at ~2.5m"
18841        );
18842        let lines = state.location_context_lines();
18843        assert!(
18844            lines
18845                .iter()
18846                .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
18847            "HUD should advertise f when board is in range: {:?}",
18848            lines
18849        );
18850    }
18851
18852    #[test]
18853    fn quest_board_keeps_multiple_offers() {
18854        let mut state = sample_state();
18855        let offer = |id: &str, title: &str| flatland_protocol::QuestOffer {
18856            quest_id: id.into(),
18857            title: title.into(),
18858            description: format!("{title} desc"),
18859            step_count: 2,
18860        };
18861        state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18862        state.push_quest_offer(offer("daily_20695_1", "Town board: Deer threat"));
18863        state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18864        assert_eq!(state.pending_quest_offers.len(), 2);
18865        assert_eq!(
18866            state.selected_quest_offer().unwrap().quest_id,
18867            "ada_goblin_hunt"
18868        );
18869        state.move_quest_offer_selection(1);
18870        assert_eq!(
18871            state.selected_quest_offer().unwrap().quest_id,
18872            "daily_20695_1"
18873        );
18874        state.remove_quest_offer("daily_20695_1");
18875        assert_eq!(state.pending_quest_offers.len(), 1);
18876        assert!(state.show_quest_offer);
18877        state.remove_quest_offer("ada_goblin_hunt");
18878        assert!(!state.show_quest_offer);
18879        assert!(state.pending_quest_offers.is_empty());
18880    }
18881
18882    #[test]
18883    fn inventory_selectable_rows_excludes_equipped_shells_but_keeps_bag_contents() {
18884        let mut state = sample_state();
18885        state.worn.insert(
18886            BodySlot::Back,
18887            flatland_protocol::ItemStack {
18888                template_id: "travel_backpack".into(),
18889                quantity: 1,
18890                item_instance_id: Some(uuid::Uuid::from_u128(3)),
18891                props: Default::default(),
18892                status_bindings: Vec::new(),
18893                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
18894                display_name: None,
18895                category: None,
18896                base_mass: None,
18897                base_volume: None,
18898                capacity_volume: None,
18899                stackable: None,
18900                world_placeable: None,
18901                worker_lodging_capacity: None,
18902                equip_slot: None,
18903                armor_physical: None,
18904                resists: vec![],
18905                hand_slots: None,
18906                listable: None,
18907                ..Default::default()
18908            },
18909        );
18910        state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
18911        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18912            id: "chest-1".into(),
18913            template_id: "wooden_chest_small".into(),
18914            display_name: "Wooden Chest".into(),
18915            x: 129.0,
18916            y: 128.0,
18917            z: 0.0,
18918            locked: false,
18919            accessible: true,
18920            owner_character_id: None,
18921            contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
18922            lock_id: None,
18923            capacity_volume: None,
18924            item_instance_id: Some(uuid::Uuid::from_u128(4)),
18925            tile_id: None,
18926            worker_lodging_capacity: None,
18927            blocking: false,
18928            blocking_radius_m: 0.0,
18929            building_id: None,
18930        }];
18931
18932        state.inventory_tab = InventoryTab::OnPerson;
18933        let rows = state.inventory_selectable_rows();
18934        let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
18935        assert_eq!(
18936            sections,
18937            vec![
18938                InventorySection::Person, // iron_ore carried in backpack
18939                InventorySection::Person, // lumber
18940            ]
18941        );
18942        assert_eq!(rows[0].stack.template_id, "iron_ore");
18943        assert_eq!(rows[0].depth, 0);
18944        assert!(!rows[0].is_equip_shell);
18945        assert_eq!(rows[1].stack.template_id, "lumber");
18946
18947        let lines = state.inventory_browser_lines();
18948        assert!(lines.iter().any(|l| matches!(
18949            l,
18950            InventoryBrowserLine::Section(s) if s.contains("carried bags")
18951        )));
18952        assert!(lines.iter().any(|l| matches!(
18953            l,
18954            InventoryBrowserLine::Item { text, .. } if text.contains("iron_ore")
18955        )));
18956        assert!(!lines.iter().any(|l| matches!(
18957            l,
18958            InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
18959        )));
18960        assert!(!lines.iter().any(|l| matches!(
18961            l,
18962            InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
18963        )));
18964
18965        state.inventory_tab = InventoryTab::Nearby;
18966        let nearby_rows = state.inventory_selectable_rows();
18967        assert_eq!(nearby_rows.len(), 2);
18968        assert!(nearby_rows[0].is_chest_shell);
18969        assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
18970        let nearby_lines = state.inventory_browser_lines();
18971        assert!(nearby_lines.iter().any(|l| matches!(
18972            l,
18973            InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
18974        )));
18975    }
18976
18977    #[test]
18978    fn give_worker_notice_does_not_put_item_back_in_bag() {
18979        let mut state = sample_state();
18980        let id = uuid::Uuid::from_u128(42);
18981        let mut saw = flatland_protocol::ItemStack::simple("handsaw", 1);
18982        saw.item_instance_id = Some(id);
18983        saw.display_name = Some("Handsaw".into());
18984        state.sync_inventory_from_stacks(&[saw]);
18985        assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 1);
18986
18987        state.remove_carried_instance(id, None);
18988        assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 0);
18989        assert!(state.inventory_stacks.is_empty());
18990
18991        state.apply_interaction_notice(&flatland_protocol::InteractionNotice {
18992            target_id: "worker-1".into(),
18993            message: "Gave 1x Handsaw to Laborer".into(),
18994            coins_delta: 0,
18995            inventory_delta: vec![flatland_protocol::ItemStack::simple("handsaw", 1)],
18996        });
18997        assert_eq!(
18998            state.inventory.get("handsaw").copied().unwrap_or(0),
18999            0,
19000            "Gave notice must not restore the handed stack"
19001        );
19002    }
19003
19004    #[test]
19005    fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
19006        let mut state = sample_state();
19007        let back_id = uuid::Uuid::from_u128(5);
19008        state.worn.insert(
19009            BodySlot::Back,
19010            flatland_protocol::ItemStack {
19011                template_id: "travel_backpack".into(),
19012                quantity: 1,
19013                item_instance_id: Some(back_id),
19014                props: Default::default(),
19015                status_bindings: Vec::new(),
19016                contents: Vec::new(),
19017                display_name: None,
19018                category: Some("container".into()),
19019                base_mass: None,
19020                base_volume: None,
19021                capacity_volume: Some(80.0),
19022                stackable: None,
19023                world_placeable: None,
19024                worker_lodging_capacity: None,
19025                equip_slot: None,
19026                armor_physical: None,
19027                resists: vec![],
19028                hand_slots: None,
19029                listable: None,
19030                ..Default::default()
19031            },
19032        );
19033        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19034            id: "chest-1".into(),
19035            template_id: "wooden_chest_small".into(),
19036            display_name: "Wooden Chest".into(),
19037            x: 129.0,
19038            y: 128.0,
19039            z: 0.0,
19040            locked: false,
19041            accessible: true,
19042            owner_character_id: None,
19043            contents: Vec::new(),
19044            lock_id: None,
19045            capacity_volume: None,
19046            item_instance_id: Some(uuid::Uuid::from_u128(6)),
19047            tile_id: None,
19048            worker_lodging_capacity: None,
19049            blocking: false,
19050            blocking_radius_m: 0.0,
19051            building_id: None,
19052        }];
19053
19054        // Item currently sitting loose on the person (Root): backpack + nearby
19055        // chest should both be offered, plus Drop/Cancel, but not "Root" itself.
19056        let opts = state.move_destinations_for(
19057            &flatland_protocol::InventoryLocation::Root,
19058            None,
19059            None,
19060            "lumber",
19061        );
19062        assert!(!opts.iter().any(|o| matches!(
19063            &o.kind,
19064            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
19065        )));
19066        assert!(opts.iter().any(|o| matches!(
19067            &o.kind,
19068            MoveOptionKind::Move { location, parent_instance_id, .. }
19069                if *location == flatland_protocol::InventoryLocation::Worn {
19070                    slot: BodySlot::Back,
19071                } && *parent_instance_id == Some(back_id)
19072        )));
19073        assert!(opts.iter().any(|o| matches!(
19074            &o.kind,
19075            MoveOptionKind::Move { location, .. }
19076                if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
19077        )));
19078        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
19079        assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
19080        let backpack = opts
19081            .iter()
19082            .find(|o| {
19083                matches!(
19084                    &o.kind,
19085                    MoveOptionKind::Move {
19086                        location: flatland_protocol::InventoryLocation::Worn {
19087                            slot: BodySlot::Back,
19088                        },
19089                        parent_instance_id,
19090                    } if *parent_instance_id == Some(back_id)
19091                )
19092            })
19093            .expect("worn backpack destination");
19094        assert_eq!(backpack.volume, Some((0.0, 80.0)));
19095        assert_eq!(
19096            backpack.volume_usage_label().as_deref(),
19097            Some("vol 0/80 (80 free)")
19098        );
19099
19100        // Item currently inside the worn backpack: the backpack itself must be
19101        // excluded from its own destination list (can't move an item into the
19102        // container it's already in).
19103        let from_backpack = flatland_protocol::InventoryLocation::Worn {
19104            slot: BodySlot::Back,
19105        };
19106        let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
19107        assert!(!opts.iter().any(|o| matches!(
19108            &o.kind,
19109            MoveOptionKind::Move { location, parent_instance_id, .. }
19110                if *location == from_backpack && *parent_instance_id == Some(back_id)
19111        )));
19112        assert!(opts.iter().any(|o| matches!(
19113            &o.kind,
19114            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
19115        )));
19116    }
19117
19118    #[test]
19119    fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
19120        let mut state = sample_state();
19121        // Insert out of display order — BTreeMap iteration must still yield the
19122        // canonical Head/Body/Arms/Legs/Feet/Back/Waist order regardless.
19123        state.worn.insert(
19124            BodySlot::Waist,
19125            flatland_protocol::ItemStack {
19126                template_id: "simple_belt".into(),
19127                quantity: 1,
19128                item_instance_id: Some(uuid::Uuid::from_u128(10)),
19129                props: Default::default(),
19130                status_bindings: Vec::new(),
19131                contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
19132                display_name: None,
19133                category: Some("container".into()),
19134                base_mass: None,
19135                base_volume: None,
19136                capacity_volume: None,
19137                stackable: None,
19138                world_placeable: None,
19139                worker_lodging_capacity: None,
19140                equip_slot: None,
19141                armor_physical: None,
19142                resists: vec![],
19143                hand_slots: None,
19144                listable: None,
19145                ..Default::default()
19146            },
19147        );
19148        state.worn.insert(
19149            BodySlot::Head,
19150            flatland_protocol::ItemStack {
19151                template_id: "cloth_cap".into(),
19152                quantity: 1,
19153                item_instance_id: Some(uuid::Uuid::from_u128(11)),
19154                props: Default::default(),
19155                status_bindings: Vec::new(),
19156                contents: Vec::new(),
19157                display_name: None,
19158                category: Some("armor".into()),
19159                base_mass: None,
19160                base_volume: None,
19161                capacity_volume: None,
19162                stackable: None,
19163                world_placeable: None,
19164                worker_lodging_capacity: None,
19165                equip_slot: None,
19166                armor_physical: None,
19167                resists: vec![],
19168                hand_slots: None,
19169                listable: None,
19170                ..Default::default()
19171            },
19172        );
19173        state.worn.insert(
19174            BodySlot::Back,
19175            flatland_protocol::ItemStack {
19176                template_id: "travel_backpack".into(),
19177                quantity: 1,
19178                item_instance_id: Some(uuid::Uuid::from_u128(12)),
19179                props: Default::default(),
19180                status_bindings: Vec::new(),
19181                contents: Vec::new(),
19182                display_name: None,
19183                category: Some("container".into()),
19184                base_mass: None,
19185                base_volume: None,
19186                capacity_volume: None,
19187                stackable: None,
19188                world_placeable: None,
19189                worker_lodging_capacity: None,
19190                equip_slot: None,
19191                armor_physical: None,
19192                resists: vec![],
19193                hand_slots: None,
19194                listable: None,
19195                ..Default::default()
19196            },
19197        );
19198
19199        let rows = state.worn_rows();
19200        // Head, then Back, then Waist (+ nested pouch) — enum declaration order.
19201        assert_eq!(rows.len(), 4);
19202        assert_eq!(rows[0].stack.template_id, "cloth_cap");
19203        assert!(rows[0].is_equip_shell);
19204        assert_eq!(rows[1].stack.template_id, "travel_backpack");
19205        assert!(rows[1].is_equip_shell);
19206        assert_eq!(rows[2].stack.template_id, "simple_belt");
19207        assert!(rows[2].is_equip_shell);
19208        assert_eq!(rows[3].stack.template_id, "leather_pouch");
19209        assert_eq!(rows[3].depth, 1);
19210        assert!(!rows[3].is_equip_shell);
19211    }
19212
19213    #[test]
19214    fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
19215        let mut state = sample_state();
19216        state.worn.insert(
19217            BodySlot::Waist,
19218            flatland_protocol::ItemStack {
19219                template_id: "simple_belt".into(),
19220                quantity: 1,
19221                item_instance_id: Some(uuid::Uuid::from_u128(20)),
19222                props: Default::default(),
19223                status_bindings: Vec::new(),
19224                contents: Vec::new(),
19225                display_name: Some("Simple Belt".into()),
19226                category: Some("container".into()),
19227                base_mass: None,
19228                base_volume: None,
19229                capacity_volume: None,
19230                stackable: None,
19231                world_placeable: None,
19232                worker_lodging_capacity: None,
19233                equip_slot: None,
19234                armor_physical: None,
19235                resists: vec![],
19236                hand_slots: None,
19237                listable: None,
19238                ..Default::default()
19239            },
19240        );
19241        state.worn.insert(
19242            BodySlot::Head,
19243            flatland_protocol::ItemStack {
19244                template_id: "cloth_cap".into(),
19245                quantity: 1,
19246                item_instance_id: Some(uuid::Uuid::from_u128(21)),
19247                props: Default::default(),
19248                status_bindings: Vec::new(),
19249                contents: Vec::new(),
19250                display_name: Some("Cloth Cap".into()),
19251                category: Some("armor".into()),
19252                base_mass: None,
19253                base_volume: None,
19254                capacity_volume: None,
19255                stackable: None,
19256                world_placeable: None,
19257                worker_lodging_capacity: None,
19258                equip_slot: None,
19259                armor_physical: None,
19260                resists: vec![],
19261                hand_slots: None,
19262                listable: None,
19263                ..Default::default()
19264            },
19265        );
19266
19267        let opts = state.move_destinations_for(
19268            &flatland_protocol::InventoryLocation::Root,
19269            None,
19270            None,
19271            "leather_pouch",
19272        );
19273        assert!(
19274            opts.iter().any(|o| matches!(
19275                &o.kind,
19276                MoveOptionKind::Move { location, .. }
19277                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
19278            )),
19279            "belt loop must be offered when moving a pouch"
19280        );
19281        assert!(
19282            !opts.iter().any(|o| matches!(
19283                &o.kind,
19284                MoveOptionKind::Move { location, .. }
19285                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
19286            )),
19287            "armor slots can't hold other items and must not appear as move destinations"
19288        );
19289        let belt_opt = opts
19290            .iter()
19291            .find(|o| matches!(
19292                &o.kind,
19293                MoveOptionKind::Move { location, .. }
19294                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
19295            ))
19296            .unwrap();
19297        assert!(belt_opt.label.contains("belt loop"));
19298
19299        let opts = state.move_destinations_for(
19300            &flatland_protocol::InventoryLocation::Root,
19301            None,
19302            None,
19303            "lumber",
19304        );
19305        assert!(
19306            !opts.iter().any(|o| o.label.contains("belt loop")),
19307            "loose materials must not target the belt shell — only nested pouches"
19308        );
19309    }
19310
19311    #[test]
19312    fn move_destinations_for_offers_dimensional_pouch_on_belt() {
19313        let mut state = sample_state();
19314        let belt_id = uuid::Uuid::from_u128(30);
19315        let pouch_id = uuid::Uuid::from_u128(31);
19316        state.worn.insert(
19317            BodySlot::Waist,
19318            flatland_protocol::ItemStack {
19319                template_id: "simple_belt".into(),
19320                quantity: 1,
19321                item_instance_id: Some(belt_id),
19322                props: Default::default(),
19323                status_bindings: Vec::new(),
19324                world_placeable: None,
19325                worker_lodging_capacity: None,
19326                equip_slot: None,
19327                armor_physical: None,
19328                resists: vec![],
19329                hand_slots: None,
19330                contents: vec![flatland_protocol::ItemStack {
19331                    template_id: "dimensional_pouch".into(),
19332                    quantity: 1,
19333                    item_instance_id: Some(pouch_id),
19334                    props: Default::default(),
19335                    status_bindings: Vec::new(),
19336                    contents: Vec::new(),
19337                    display_name: Some("Dimensional Pouch".into()),
19338                    category: Some("container".into()),
19339                    base_mass: None,
19340                    base_volume: None,
19341                    capacity_volume: Some(200.0),
19342                    stackable: None,
19343                    world_placeable: None,
19344                    worker_lodging_capacity: None,
19345                    equip_slot: None,
19346                    armor_physical: None,
19347                    resists: vec![],
19348                    hand_slots: None,
19349                    listable: None,
19350                    ..Default::default()
19351                }],
19352                display_name: Some("Simple Belt".into()),
19353                category: Some("container".into()),
19354                base_mass: None,
19355                base_volume: None,
19356                capacity_volume: None,
19357                stackable: None,
19358                listable: None,
19359                ..Default::default()
19360            },
19361        );
19362
19363        let opts = state.move_destinations_for(
19364            &flatland_protocol::InventoryLocation::Root,
19365            None,
19366            None,
19367            "iron_ore",
19368        );
19369        assert!(
19370            opts.iter().any(|o| matches!(
19371                &o.kind,
19372                MoveOptionKind::Move {
19373                    location,
19374                    parent_instance_id,
19375                    ..
19376                } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
19377                    && *parent_instance_id == Some(pouch_id)
19378            )),
19379            "dimensional pouch clipped on belt must accept loose items"
19380        );
19381        assert!(
19382            opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
19383            "destination label should name the pouch"
19384        );
19385    }
19386
19387    #[test]
19388    fn container_volume_label_on_placed_chest_shell() {
19389        let mut state = sample_state();
19390        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19391            id: "chest-1".into(),
19392            template_id: "wooden_chest_small".into(),
19393            display_name: "Camp Chest".into(),
19394            x: 129.0,
19395            y: 128.0,
19396            z: 0.0,
19397            locked: false,
19398            accessible: true,
19399            owner_character_id: None,
19400            contents: vec![flatland_protocol::ItemStack {
19401                template_id: "iron_ore".into(),
19402                quantity: 2,
19403                item_instance_id: None,
19404                props: Default::default(),
19405                status_bindings: Vec::new(),
19406                contents: Vec::new(),
19407                display_name: None,
19408                category: None,
19409                base_mass: None,
19410                base_volume: Some(2.0),
19411                capacity_volume: None,
19412                stackable: None,
19413                world_placeable: None,
19414                worker_lodging_capacity: None,
19415                equip_slot: None,
19416                armor_physical: None,
19417                resists: vec![],
19418                hand_slots: None,
19419                listable: None,
19420                ..Default::default()
19421            }],
19422            lock_id: None,
19423            capacity_volume: Some(60.0),
19424            item_instance_id: Some(uuid::Uuid::from_u128(4)),
19425            tile_id: None,
19426            worker_lodging_capacity: None,
19427            blocking: false,
19428            blocking_radius_m: 0.0,
19429            building_id: None,
19430        }];
19431        let nearby = state.nearby_containers();
19432        let label = state.container_volume_label(&nearby[0].rows[0]);
19433        assert!(
19434            label.contains("vol 4/60"),
19435            "expected used/cap in label, got {label}"
19436        );
19437        assert!(
19438            label.contains("56 free"),
19439            "expected free space, got {label}"
19440        );
19441    }
19442
19443    #[test]
19444    fn move_destinations_for_include_placed_chest_volume() {
19445        let mut state = sample_state();
19446        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19447            id: "chest-1".into(),
19448            template_id: "wooden_chest_small".into(),
19449            display_name: "Camp Chest".into(),
19450            x: 129.0,
19451            y: 128.0,
19452            z: 0.0,
19453            locked: false,
19454            accessible: true,
19455            owner_character_id: None,
19456            contents: vec![flatland_protocol::ItemStack {
19457                template_id: "iron_ore".into(),
19458                quantity: 2,
19459                item_instance_id: None,
19460                props: Default::default(),
19461                status_bindings: Vec::new(),
19462                contents: Vec::new(),
19463                display_name: None,
19464                category: None,
19465                base_mass: None,
19466                base_volume: Some(2.0),
19467                capacity_volume: None,
19468                stackable: None,
19469                world_placeable: None,
19470                worker_lodging_capacity: None,
19471                equip_slot: None,
19472                armor_physical: None,
19473                resists: vec![],
19474                hand_slots: None,
19475                listable: None,
19476                ..Default::default()
19477            }],
19478            lock_id: None,
19479            capacity_volume: Some(60.0),
19480            item_instance_id: Some(uuid::Uuid::from_u128(4)),
19481            tile_id: None,
19482            worker_lodging_capacity: None,
19483            blocking: false,
19484            blocking_radius_m: 0.0,
19485            building_id: None,
19486        }];
19487        let opts = state.move_destinations_for(
19488            &flatland_protocol::InventoryLocation::Root,
19489            None,
19490            None,
19491            "lumber",
19492        );
19493        let chest = opts
19494            .iter()
19495            .find(|o| {
19496                matches!(
19497                    &o.kind,
19498                    MoveOptionKind::Move {
19499                        location: flatland_protocol::InventoryLocation::Placed { container_id },
19500                        ..
19501                    } if container_id == "chest-1"
19502                )
19503            })
19504            .expect("nearby chest destination");
19505        assert_eq!(chest.volume, Some((4.0, 60.0)));
19506        assert_eq!(
19507            chest.volume_usage_label().as_deref(),
19508            Some("vol 4/60 (56 free)")
19509        );
19510        assert!(opts
19511            .iter()
19512            .filter(|o| matches!(o.kind, MoveOptionKind::Drop | MoveOptionKind::Cancel))
19513            .all(|o| o.volume.is_none()));
19514    }
19515
19516    #[test]
19517    fn chest_pickup_destinations_include_worn_bag_volume() {
19518        let mut state = sample_state();
19519        let back_id = uuid::Uuid::from_u128(5);
19520        state.worn.insert(
19521            BodySlot::Back,
19522            flatland_protocol::ItemStack {
19523                template_id: "travel_backpack".into(),
19524                quantity: 1,
19525                item_instance_id: Some(back_id),
19526                props: Default::default(),
19527                status_bindings: Vec::new(),
19528                contents: Vec::new(),
19529                display_name: Some("Travel Backpack".into()),
19530                category: Some("container".into()),
19531                capacity_volume: Some(80.0),
19532                ..Default::default()
19533            },
19534        );
19535        let opts = state.chest_pickup_destinations("chest-1");
19536        let bag = opts
19537            .iter()
19538            .find(|o| {
19539                matches!(
19540                    &o.kind,
19541                    MoveOptionKind::PickupPlaced {
19542                        nest_parent_instance_id,
19543                        ..
19544                    } if *nest_parent_instance_id == Some(back_id)
19545                )
19546            })
19547            .expect("pickup into worn backpack");
19548        assert_eq!(bag.volume, Some((0.0, 80.0)));
19549        assert!(opts
19550            .iter()
19551            .filter(|o| matches!(
19552                o.kind,
19553                MoveOptionKind::RelocatePlaced { .. } | MoveOptionKind::Cancel
19554            ))
19555            .all(|o| o.volume.is_none()));
19556    }
19557
19558    #[test]
19559    fn key_pair_chest_label_from_placed_lock_id() {
19560        let mut state = sample_state();
19561        let owner = uuid::Uuid::from_u128(77);
19562        state.character_id = Some(owner);
19563        let lock = uuid::Uuid::from_u128(99).to_string();
19564        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19565            id: "chest-1".into(),
19566            template_id: "wooden_chest_small".into(),
19567            display_name: "Barry's Loot #a3f2".into(),
19568            x: 129.0,
19569            y: 128.0,
19570            z: 0.0,
19571            locked: true,
19572            accessible: true,
19573            owner_character_id: Some(owner),
19574            contents: Vec::new(),
19575            lock_id: Some(lock.clone()),
19576            capacity_volume: None,
19577            item_instance_id: Some(uuid::Uuid::from_u128(4)),
19578            tile_id: None,
19579            worker_lodging_capacity: None,
19580            blocking: false,
19581            blocking_radius_m: 0.0,
19582            building_id: None,
19583        }];
19584        let key_id = uuid::Uuid::from_u128(5);
19585        let key = flatland_protocol::ItemStack {
19586            template_id: KEY_TEMPLATE.into(),
19587            quantity: 1,
19588            item_instance_id: Some(key_id),
19589            props: BTreeMap::from([
19590                (PROP_OPENS_LOCK_ID.into(), lock),
19591                (
19592                    PROP_OPENS_CONTAINER_NAME.into(),
19593                    "Barry's Loot #a3f2".into(),
19594                ),
19595            ]),
19596            status_bindings: Vec::new(),
19597            contents: Vec::new(),
19598            display_name: Some("Container Key".into()),
19599            category: Some("key".into()),
19600            base_mass: None,
19601            base_volume: None,
19602            capacity_volume: None,
19603            stackable: None,
19604            world_placeable: None,
19605            worker_lodging_capacity: None,
19606            equip_slot: None,
19607            armor_physical: None,
19608            resists: vec![],
19609            hand_slots: None,
19610            listable: None,
19611            ..Default::default()
19612        };
19613        state.inventory_stacks = vec![key.clone()];
19614        assert_eq!(
19615            state.key_pair_chest_label(&key).as_deref(),
19616            Some("Barry's Loot #a3f2")
19617        );
19618        assert!(state.key_drop_blocked(&key));
19619    }
19620
19621    #[test]
19622    fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
19623        let mut state = sample_state();
19624        let lock = uuid::Uuid::from_u128(101).to_string();
19625        let key = flatland_protocol::ItemStack {
19626            template_id: KEY_TEMPLATE.into(),
19627            quantity: 1,
19628            item_instance_id: Some(uuid::Uuid::from_u128(7)),
19629            props: BTreeMap::from([
19630                (PROP_OPENS_LOCK_ID.into(), lock),
19631                (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
19632            ]),
19633            status_bindings: Vec::new(),
19634            contents: Vec::new(),
19635            display_name: None,
19636            category: Some("key".into()),
19637            base_mass: None,
19638            base_volume: None,
19639            capacity_volume: None,
19640            stackable: None,
19641            world_placeable: None,
19642            worker_lodging_capacity: None,
19643            equip_slot: None,
19644            armor_physical: None,
19645            resists: vec![],
19646            hand_slots: None,
19647            listable: None,
19648            ..Default::default()
19649        };
19650        state.placed_containers.clear();
19651        assert_eq!(
19652            state.key_pair_chest_label(&key).as_deref(),
19653            Some("Camp Stash")
19654        );
19655    }
19656
19657    #[test]
19658    fn key_drop_allowed_when_paired_chest_unlocked() {
19659        let mut state = sample_state();
19660        let lock = uuid::Uuid::from_u128(100).to_string();
19661        let key_id = uuid::Uuid::from_u128(6);
19662        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19663            id: "chest-1".into(),
19664            template_id: "wooden_chest_small".into(),
19665            display_name: "Camp Chest".into(),
19666            x: 129.0,
19667            y: 128.0,
19668            z: 0.0,
19669            locked: false,
19670            accessible: true,
19671            owner_character_id: None,
19672            contents: Vec::new(),
19673            lock_id: Some(lock.clone()),
19674            capacity_volume: None,
19675            item_instance_id: None,
19676            tile_id: None,
19677            worker_lodging_capacity: None,
19678            blocking: false,
19679            blocking_radius_m: 0.0,
19680            building_id: None,
19681        }];
19682        let key = flatland_protocol::ItemStack {
19683            template_id: KEY_TEMPLATE.into(),
19684            quantity: 1,
19685            item_instance_id: Some(key_id),
19686            props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
19687            status_bindings: Vec::new(),
19688            contents: Vec::new(),
19689            display_name: None,
19690            category: Some("key".into()),
19691            base_mass: None,
19692            base_volume: None,
19693            capacity_volume: None,
19694            stackable: None,
19695            world_placeable: None,
19696            worker_lodging_capacity: None,
19697            equip_slot: None,
19698            armor_physical: None,
19699            resists: vec![],
19700            hand_slots: None,
19701            listable: None,
19702            ..Default::default()
19703        };
19704        state.inventory_stacks = vec![key.clone()];
19705        assert!(!state.key_drop_blocked(&key));
19706        let opts = state.move_destinations_for(
19707            &flatland_protocol::InventoryLocation::Root,
19708            None,
19709            Some(key_id),
19710            KEY_TEMPLATE,
19711        );
19712        assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
19713    }
19714
19715    #[test]
19716    fn combat_hud_refreshes_progression_xp_when_entity_stale() {
19717        use flatland_protocol::{CombatHud, ProgressionCurve, ProgressionXp};
19718
19719        let mut state = sample_state();
19720        let curve = ProgressionCurve::default();
19721        let bootstrap =
19722            ProgressionXp::bootstrap_new(curve.baseline_display, curve.xp_base, curve.xp_growth);
19723        let mut fresh = bootstrap.clone();
19724        fresh.strength += 0.08;
19725        if let Some(player) = state.player.as_mut() {
19726            player.progression_xp = Some(bootstrap);
19727        }
19728
19729        let combat = CombatHud {
19730            progression_xp: Some(fresh.clone()),
19731            progression_baseline: curve.baseline_display,
19732            progression_xp_base: curve.xp_base,
19733            progression_xp_growth: curve.xp_growth,
19734            attributes: state.player.as_ref().and_then(|p| p.attributes),
19735            skills: state.player.as_ref().and_then(|p| p.skills.clone()),
19736            ..CombatHud::default()
19737        };
19738        state.apply_combat_hud(&combat);
19739
19740        let xp = state
19741            .player
19742            .as_ref()
19743            .and_then(|p| p.progression_xp.as_ref())
19744            .expect("xp");
19745        assert!((xp.strength - fresh.strength).abs() < 0.001);
19746        assert!(state.progression_curve.is_some());
19747    }
19748
19749    #[test]
19750    fn combat_hud_syncs_known_abilities_and_hotbar() {
19751        use flatland_protocol::CombatHud;
19752
19753        let mut state = sample_state();
19754        let combat = CombatHud {
19755            known_abilities: vec!["unarmed".into(), "fireball".into()],
19756            hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
19757            max_abilities_per_rotation: 4,
19758            ability_id: "short_sword_slash".into(),
19759            ..CombatHud::default()
19760        };
19761        state.apply_combat_hud(&combat);
19762
19763        assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
19764        assert_eq!(state.hotbar_ability(1), Some("fireball"));
19765        assert_eq!(state.hotbar_ability(2), None);
19766        assert_eq!(state.hotbar_ability(3), Some("unarmed"));
19767        assert_eq!(state.max_abilities_per_rotation, 4);
19768        let choices = state.loadout_ability_choices();
19769        assert!(choices.iter().any(|a| a == "short_sword_slash"));
19770        assert!(choices.iter().any(|a| a == "fireball"));
19771    }
19772
19773    #[test]
19774    fn loadout_hotbar_choices_include_inventory_consumables() {
19775        let mut state = sample_state();
19776        state.known_abilities = vec!["unarmed".into()];
19777        state.weapon_ability_id = "unarmed".into();
19778        state.inventory_stacks = vec![flatland_protocol::ItemStack {
19779            template_id: "empty_bottle".into(),
19780            quantity: 1,
19781            item_instance_id: Some(uuid::Uuid::from_u128(9)),
19782            display_name: Some("Glass Bottle of Water".into()),
19783            category: Some("container".into()),
19784            props: [
19785                ("serving".into(), "1".into()),
19786                ("liquid_vessel".into(), "1".into()),
19787                ("serving_holds".into(), "liquid".into()),
19788            ]
19789            .into_iter()
19790            .collect(),
19791            ..Default::default()
19792        }];
19793        state.inventory.insert("empty_bottle".into(), 1);
19794        state.inventory_hints.insert(
19795            "empty_bottle".into(),
19796            InventoryHint {
19797                display_name: "Glass Bottle".into(),
19798                category: "container".into(),
19799                ..Default::default()
19800            },
19801        );
19802
19803        let choices = state.loadout_hotbar_choices();
19804        assert!(choices.iter().any(|c| c.binding == "unarmed"));
19805        let water = choices
19806            .iter()
19807            .find(|c| c.binding == "item:empty_bottle")
19808            .expect("serving bottle binding");
19809        assert_eq!(water.meta.as_deref(), Some("use"));
19810        assert!(water.label.contains("Glass Bottle of Water"));
19811        assert_eq!(state.hotbar_slot_label(1), None, "unbound until set");
19812        state.hotbar = vec![None, None, None, None, Some("item:empty_bottle".into())];
19813        assert_eq!(
19814            state.hotbar_slot_label(5).as_deref(),
19815            Some("Glass Bottle×1")
19816        );
19817    }
19818
19819    #[test]
19820    fn loadout_hotbar_choices_exclude_blueprint_scrolls() {
19821        let mut state = sample_state();
19822        state.known_abilities = vec!["unarmed".into()];
19823        state.weapon_ability_id = "unarmed".into();
19824        state.inventory_stacks = vec![
19825            flatland_protocol::ItemStack {
19826                template_id: "carrot".into(),
19827                quantity: 2,
19828                display_name: Some("Wild Carrot".into()),
19829                category: Some("consumable".into()),
19830                ..Default::default()
19831            },
19832            flatland_protocol::ItemStack {
19833                template_id: "blueprint_dimensional_pouch".into(),
19834                quantity: 1,
19835                display_name: Some("Blueprint — Dimensional Pouch".into()),
19836                category: Some("consumable".into()),
19837                props: [("teaches_blueprint".into(), "craft_dimensional_pouch".into())]
19838                    .into_iter()
19839                    .collect(),
19840                ..Default::default()
19841            },
19842        ];
19843        state.inventory.insert("carrot".into(), 2);
19844        state
19845            .inventory
19846            .insert("blueprint_dimensional_pouch".into(), 1);
19847        state.inventory_hints.insert(
19848            "carrot".into(),
19849            InventoryHint {
19850                display_name: "Wild Carrot".into(),
19851                category: "consumable".into(),
19852                ..Default::default()
19853            },
19854        );
19855        state.inventory_hints.insert(
19856            "blueprint_dimensional_pouch".into(),
19857            InventoryHint {
19858                display_name: "Blueprint — Dimensional Pouch".into(),
19859                category: "consumable".into(),
19860                ..Default::default()
19861            },
19862        );
19863
19864        let choices = state.loadout_hotbar_choices();
19865        assert!(choices.iter().any(|c| c.binding == "item:carrot"));
19866        assert!(
19867            choices
19868                .iter()
19869                .all(|c| c.binding != "item:blueprint_dimensional_pouch"),
19870            "recipe scrolls must not appear on the hotbar picker: {choices:?}"
19871        );
19872    }
19873
19874    #[test]
19875    fn storage_store_options_excludes_hand_equipped() {
19876        let mut state = sample_state();
19877        let sword_id = uuid::Uuid::from_u128(11);
19878        let ore_id = uuid::Uuid::from_u128(22);
19879        state.inventory_stacks = vec![
19880            flatland_protocol::ItemStack {
19881                template_id: "short_sword".into(),
19882                quantity: 1,
19883                item_instance_id: Some(sword_id),
19884                display_name: Some("Short Sword".into()),
19885                category: Some("weapon".into()),
19886                ..Default::default()
19887            },
19888            flatland_protocol::ItemStack {
19889                template_id: "iron_ore".into(),
19890                quantity: 5,
19891                item_instance_id: Some(ore_id),
19892                display_name: Some("Iron Ore".into()),
19893                category: Some("resource".into()),
19894                ..Default::default()
19895            },
19896        ];
19897        state.mainhand_template_id = Some("short_sword".into());
19898        state.mainhand_instance_id = Some(sword_id);
19899
19900        let opts = state.storage_store_options();
19901        assert_eq!(opts.len(), 1);
19902        assert_eq!(opts[0].item_instance_id, ore_id);
19903        assert!(state.hand_equipped_instance_ids().contains(&sword_id));
19904    }
19905
19906    #[test]
19907    fn loose_consumable_move_picker_offers_use_and_storage() {
19908        let mut state = sample_state();
19909        let inst = uuid::Uuid::from_u128(77);
19910        state.inventory_stacks = vec![flatland_protocol::ItemStack {
19911            template_id: "carrot".into(),
19912            quantity: 2,
19913            item_instance_id: Some(inst),
19914            props: Default::default(),
19915            status_bindings: Vec::new(),
19916            contents: Vec::new(),
19917            display_name: Some("Wild Carrot".into()),
19918            category: Some("consumable".into()),
19919            base_mass: None,
19920            base_volume: None,
19921            capacity_volume: None,
19922            stackable: Some(true),
19923            world_placeable: None,
19924            worker_lodging_capacity: None,
19925            equip_slot: None,
19926            armor_physical: None,
19927            resists: vec![],
19928            hand_slots: None,
19929            listable: None,
19930            ..Default::default()
19931        }];
19932        state.inventory_hints.insert(
19933            "carrot".into(),
19934            InventoryHint {
19935                display_name: "Wild Carrot".into(),
19936                category: "consumable".into(),
19937                base_mass: Some(0.15),
19938                base_volume: Some(0.3),
19939                capacity_volume: None,
19940                stackable: true,
19941                listable: true,
19942                base_value_copper: None,
19943            },
19944        );
19945        state.show_inventory_menu = true;
19946        state.inventory_menu_index = 0;
19947
19948        let row = state.inventory_selected_row().expect("carrot row");
19949        let mut options = state.move_destinations_for(
19950            &row.from,
19951            row.from_parent_instance_id,
19952            row.stack.item_instance_id,
19953            &row.stack.template_id,
19954        );
19955        if row.from == flatland_protocol::InventoryLocation::Root
19956            && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
19957        {
19958            options.insert(
19959                0,
19960                MoveOption::action("Use (eat / drink)", MoveOptionKind::Use),
19961            );
19962        }
19963
19964        assert_eq!(
19965            options.first().map(|o| &o.label),
19966            Some(&"Use (eat / drink)".into())
19967        );
19968        assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
19969        assert!(options
19970            .iter()
19971            .any(|o| matches!(o.kind, MoveOptionKind::Drop)));
19972    }
19973
19974    #[test]
19975    fn inventory_category_group_order_is_stable() {
19976        assert_eq!(inventory_category_group("weapon").0, "Weapons");
19977        assert_eq!(inventory_category_group("armor").0, "Armor");
19978        assert_eq!(inventory_category_group("consumable").0, "Consumables");
19979        assert_eq!(inventory_category_group("liquid").0, "Consumables");
19980        assert_eq!(inventory_category_group("resource").0, "Resources");
19981        assert_eq!(inventory_category_group("container").0, "Containers");
19982        assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
19983        assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
19984    }
19985
19986    #[test]
19987    fn page_list_index_clamps_without_wrap() {
19988        assert_eq!(page_list_index(0, -1, 25), 0);
19989        assert_eq!(page_list_index(0, 1, 25), 10);
19990        assert_eq!(page_list_index(12, 1, 25), 22);
19991        assert_eq!(page_list_index(22, 1, 25), 24);
19992        assert_eq!(page_list_index(5, 1, 0), 0);
19993        assert_eq!(page_list_index(3, -1, 8), 0);
19994    }
19995
19996    #[test]
19997    fn inventory_filter_hides_non_matching_person_items() {
19998        let mut state = sample_state();
19999        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
20000        sword.display_name = Some("Iron Sword".into());
20001        sword.category = Some("weapon".into());
20002        let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
20003        herb.display_name = Some("Wild Herb".into());
20004        herb.category = Some("consumable".into());
20005        state.inventory_stacks = vec![sword, herb];
20006        state.inventory_tab = InventoryTab::OnPerson;
20007        state.inventory_filter = "sword".into();
20008
20009        let rows = state.inventory_selectable_rows();
20010        assert_eq!(rows.len(), 1);
20011        assert_eq!(rows[0].stack.template_id, "iron_sword");
20012
20013        let lines = state.inventory_browser_lines();
20014        assert!(lines.iter().any(|l| matches!(
20015            l,
20016            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
20017        )));
20018        assert!(!lines.iter().any(|l| matches!(
20019            l,
20020            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
20021        )));
20022    }
20023
20024    #[test]
20025    fn list_filter_chars_reject_mac_arrow_glyphs() {
20026        assert!(is_list_filter_char('a'));
20027        assert!(is_list_filter_char(' '));
20028        assert!(is_list_filter_char('-'));
20029        assert!(!is_list_filter_char('\u{F700}'));
20030        assert!(!is_list_filter_char('\u{F701}'));
20031        assert!(!is_list_filter_char('\n'));
20032    }
20033
20034    fn plank_blueprint() -> BlueprintView {
20035        BlueprintView {
20036            id: "plank".into(),
20037            label: "Plank".into(),
20038            craft_tier: 1,
20039            craft_ticks: 30,
20040            output: "wood_plank".into(),
20041            output_qty: 1,
20042            output_display_name: "Wood Plank".into(),
20043            station: None,
20044            category: None,
20045            inputs: vec![flatland_protocol::BlueprintIngredientView {
20046                template_id: "oak_log".into(),
20047                quantity: 1,
20048                consumed: true,
20049                display_name: "Oak Log".into(),
20050            }],
20051            required_tools: vec![],
20052            skill: None,
20053            failure_chance: 0.0,
20054            worker_train_copper: 0,
20055        }
20056    }
20057
20058    #[test]
20059    fn ready_tab_keeps_in_progress_craft_after_inputs_spent() {
20060        let mut state = sample_state();
20061        state.craft_tab = CraftTab::Ready;
20062        state.blueprints = vec![plank_blueprint()];
20063        // Materials already consumed at craft begin — would drop off Ready without the pin.
20064        state.inventory.clear();
20065        state.craft_channel_blueprint_id = Some("plank".into());
20066        state.timed_channel = Some(flatland_protocol::TimedChannelHud {
20067            label: "Crafting Plank".into(),
20068            channel: flatland_protocol::TimedChannelKind::Craft,
20069            ticks_remaining: 20,
20070            ticks_total: 30,
20071            ..Default::default()
20072        });
20073
20074        let idxs = state.craft_filtered_indices();
20075        assert_eq!(idxs, vec![0]);
20076        assert!(state.craft_blueprint_in_channel("plank"));
20077
20078        // Channel finished → clear pin → Ready empty.
20079        state.timed_channel = None;
20080        state.clear_craft_ready_pin();
20081        assert!(state.craft_filtered_indices().is_empty());
20082    }
20083
20084    #[test]
20085    fn ready_tab_keeps_last_batch_item_before_channel_hud_arrives() {
20086        let mut state = sample_state();
20087        state.craft_tab = CraftTab::Ready;
20088        state.blueprints = vec![plank_blueprint()];
20089        state.inventory.clear();
20090        state.craft_channel_blueprint_id = Some("plank".into());
20091        state.craft_channel_seen = false;
20092        state.timed_channel = None;
20093
20094        // Last batch item: inputs spent, HUD tick not here yet. Must stay on Ready.
20095        assert_eq!(state.craft_filtered_indices(), vec![0]);
20096        state.sync_craft_ready_pin();
20097        assert_eq!(state.craft_channel_blueprint_id.as_deref(), Some("plank"));
20098        assert_eq!(state.craft_filtered_indices(), vec![0]);
20099
20100        state.timed_channel = Some(flatland_protocol::TimedChannelHud {
20101            label: "Crafting Plank (3/3)".into(),
20102            channel: flatland_protocol::TimedChannelKind::Craft,
20103            ticks_remaining: 10,
20104            ticks_total: 30,
20105            ..Default::default()
20106        });
20107        state.sync_craft_ready_pin();
20108        assert!(state.craft_channel_seen);
20109        assert_eq!(state.craft_filtered_indices(), vec![0]);
20110
20111        // Channel ended after we saw it — last item finished.
20112        state.timed_channel = None;
20113        state.sync_craft_ready_pin();
20114        assert!(state.craft_channel_blueprint_id.is_none());
20115        assert!(state.craft_filtered_indices().is_empty());
20116    }
20117
20118    #[test]
20119    fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
20120        let mut state = sample_state();
20121        let id_a = uuid::Uuid::from_u128(0xa1);
20122        let id_b = uuid::Uuid::from_u128(0xb2);
20123        let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
20124        sword_a.display_name = Some("Iron Sword".into());
20125        sword_a.category = Some("weapon".into());
20126        sword_a.item_instance_id = Some(id_a);
20127        let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
20128        sword_b.display_name = Some("Iron Sword".into());
20129        sword_b.category = Some("weapon".into());
20130        sword_b.item_instance_id = Some(id_b);
20131        state.inventory_stacks = vec![sword_a, sword_b];
20132        state.inventory_tab = InventoryTab::OnPerson;
20133
20134        let lines = state.inventory_browser_lines();
20135        let items: Vec<_> = lines
20136            .iter()
20137            .filter_map(|l| match l {
20138                InventoryBrowserLine::Item {
20139                    title,
20140                    instance_tooltip,
20141                    ..
20142                } => Some((title.clone(), instance_tooltip.clone())),
20143                _ => None,
20144            })
20145            .collect();
20146        assert_eq!(items.len(), 2);
20147        for (title, tip) in &items {
20148            assert!(
20149                !title.contains('#'),
20150                "title should not show instance suffix: {title}"
20151            );
20152            assert!(
20153                tip.is_some(),
20154                "two identical rows should expose instance on hover"
20155            );
20156        }
20157
20158        state.inventory_stacks.pop();
20159        let lines = state.inventory_browser_lines();
20160        let one = lines.iter().find_map(|l| match l {
20161            InventoryBrowserLine::Item {
20162                title,
20163                instance_tooltip,
20164                ..
20165            } => Some((title.clone(), instance_tooltip.clone())),
20166            _ => None,
20167        });
20168        let (title, tip) = one.expect("one sword row");
20169        assert!(!title.contains('#'));
20170        assert!(tip.is_none(), "single row should not need instance tooltip");
20171    }
20172
20173    #[test]
20174    fn inventory_person_rows_group_by_category() {
20175        let mut state = sample_state();
20176        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
20177        sword.category = Some("weapon".into());
20178        sword.display_name = Some("Iron Sword".into());
20179        let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
20180        ore.category = Some("resource".into());
20181        ore.display_name = Some("Iron Ore".into());
20182        let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
20183        potion.category = Some("consumable".into());
20184        potion.display_name = Some("Health Potion".into());
20185        state.inventory_stacks = vec![ore, potion, sword];
20186        state.inventory_tab = InventoryTab::OnPerson;
20187
20188        let lines = state.inventory_browser_lines();
20189        let labels: Vec<&str> = lines
20190            .iter()
20191            .filter_map(|l| match l {
20192                InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
20193                _ => None,
20194            })
20195            .collect();
20196        assert!(
20197            labels.iter().any(|s| s.contains("Weapons")),
20198            "expected Weapons group: {labels:?}"
20199        );
20200        assert!(labels.iter().any(|s| s.contains("Consumables")));
20201        assert!(labels.iter().any(|s| s.contains("Resources")));
20202
20203        let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
20204        let consumable_pos = labels
20205            .iter()
20206            .position(|s| s.contains("Consumables"))
20207            .unwrap();
20208        let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
20209        assert!(weapon_pos < consumable_pos);
20210        assert!(consumable_pos < resource_pos);
20211    }
20212
20213    #[test]
20214    fn inventory_tab_cycle_resets_selection() {
20215        let mut state = sample_state();
20216        state.inventory_tab = InventoryTab::OnPerson;
20217        state.inventory_menu_index = 3;
20218        state.inventory_tab = state.inventory_tab.cycle(true);
20219        assert_eq!(state.inventory_tab, InventoryTab::Nearby);
20220        // Client method resets index; enum cycle alone does not — verify cycle labels.
20221        assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
20222        assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
20223        assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
20224        assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
20225    }
20226
20227    #[test]
20228    fn parse_bank_copper_amount_blank_and_zero_mean_all() {
20229        assert_eq!(parse_bank_copper_amount(""), Some(0));
20230        assert_eq!(parse_bank_copper_amount("  "), Some(0));
20231        assert_eq!(parse_bank_copper_amount("0"), Some(0));
20232        assert_eq!(parse_bank_copper_amount("250"), Some(250));
20233        assert_eq!(parse_bank_copper_amount("nope"), None);
20234    }
20235
20236    #[test]
20237    fn parse_storage_quantity_blank_and_zero_mean_all() {
20238        assert_eq!(parse_storage_quantity(""), Some(None));
20239        assert_eq!(parse_storage_quantity("  "), Some(None));
20240        assert_eq!(parse_storage_quantity("0"), Some(None));
20241        assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
20242        assert_eq!(parse_storage_quantity("nope"), None);
20243    }
20244
20245    #[test]
20246    fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
20247        assert!(worker_error_is_hud_noise("path stuck — repathing"));
20248        assert!(worker_error_is_hud_noise(
20249            "path stuck — nudged clear, repathing"
20250        ));
20251        assert!(worker_error_is_hud_noise(
20252            "returned to lodging after path failures"
20253        ));
20254        // Real problem: player may need to place lodging / fix assignment.
20255        assert!(!worker_error_is_hud_noise(
20256            "path stuck — no lodging to reset to"
20257        ));
20258        assert!(!worker_error_is_hud_noise("cannot reach Eli — idling"));
20259        assert!(!worker_error_is_hud_noise(
20260            "no path to Oak Tree — idling at lodging"
20261        ));
20262        assert!(worker_error_is_transient(
20263            "no path to Oak Tree — trying next node"
20264        ));
20265        assert!(worker_error_is_transient(
20266            "no path to Oak Tree — continuing route"
20267        ));
20268        assert!(worker_error_is_hud_noise(
20269            "path unreachable (plan failures 0, leg 0)"
20270        ));
20271        assert!(!worker_error_is_hud_noise(
20272            "path unreachable (plan failures 0, leg 24)"
20273        ));
20274        assert!(worker_error_is_transient("storage full; continuing route"));
20275        assert!(!worker_error_is_transient(
20276            "storage full (Food Bank) — free chest space or reassign deposit"
20277        ));
20278        assert!(!worker_error_is_hud_noise(
20279            "storage full (Food Bank) — free chest space or reassign deposit"
20280        ));
20281    }
20282
20283    #[test]
20284    fn leaving_building_restores_outdoor_z_bands() {
20285        use flatland_protocol::{InteriorMapView, ZPlatformView};
20286
20287        let mut state = sample_state();
20288        state.z_platforms.clear();
20289        state.z_transitions.clear();
20290        state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
20291        state.interior_map = Some(InteriorMapView {
20292            building_id: "broker_hut".into(),
20293            blueprint_id: "broker_hut".into(),
20294            background_color: "#000".into(),
20295            default_floor_color: None,
20296            floor_height_m: 3.0,
20297            z_platforms: vec![ZPlatformView {
20298                id: "floor_0".into(),
20299                z: 0.0,
20300                x0: 0.0,
20301                y0: 0.0,
20302                x1: 8.0,
20303                y1: 8.0,
20304            }],
20305            z_transitions: vec![],
20306            rooms: vec![],
20307            room_doors: vec![],
20308        });
20309        state.sync_interior_map_context();
20310        assert_eq!(
20311            state.z_platforms.len(),
20312            1,
20313            "indoors installs interior platforms"
20314        );
20315        assert!(state.z_bands_outdoor_backup.is_some());
20316
20317        state.player.as_mut().unwrap().inside_building = None;
20318        state.sync_interior_map_context();
20319        assert!(
20320            state.z_platforms.is_empty(),
20321            "leaving must restore outdoor bands (empty), not leave interior platforms"
20322        );
20323        assert!(state.z_bands_outdoor_backup.is_none());
20324        assert!(state.interior_map.is_none());
20325    }
20326
20327    #[test]
20328    fn resource_node_route_label_prefers_friendly_label_with_suffix() {
20329        let node = ResourceNodeView {
20330            id: "crop-carrot-1_copy10".into(),
20331            label: "crop-carrot-1_copy10".into(),
20332            x: 0.0,
20333            y: 0.0,
20334            z: 0.0,
20335            item_template: "carrot".into(),
20336            state: ResourceNodeState::Available,
20337            blocking: false,
20338            blocking_radius_m: 0.5,
20339            harvest_off: false,
20340            tile_id: None,
20341            yaw: 0.0,
20342            pitch: 0.0,
20343            roll: 0.0,
20344            draw_scale: 1.0,
20345            sprite_mode: None,
20346            growth_progress: None,
20347            presentation_state: None,
20348            channel_start_tick: None,
20349            channel_end_tick: None,
20350            harvest_drop_templates: vec![],
20351        };
20352        let label = super::resource_node_route_label(&node);
20353        assert!(label.starts_with("Carrot ("), "got {label}");
20354        assert!(label.ends_with(')'), "got {label}");
20355
20356        let mut named = node;
20357        named.label = "Sweet Pad".into();
20358        named.id = "crop-carrot-a3f2b1c0".into();
20359        assert_eq!(super::resource_node_route_label(&named), "Sweet Pad (b1c0)");
20360    }
20361
20362    #[test]
20363    fn plot_public_label_uses_owner_zone_and_label() {
20364        let plot = flatland_protocol::PropertyPlotView {
20365            plot_id: uuid::Uuid::nil(),
20366            property_zone_id: "zone_a".into(),
20367            zone_label: Some("Starter Town East 1".into()),
20368            deed_instance_id: uuid::Uuid::nil(),
20369            x0: 0.0,
20370            y0: 0.0,
20371            x1: 4.0,
20372            y1: 4.0,
20373            upkeep_copper_per_day: 1,
20374            arrears_days: 0,
20375            is_mine: true,
20376            may_farm: true,
20377            purchase_basis_copper: 0,
20378            farm_public: false,
20379            public_tax_discount_bps: 0,
20380            farm_allow: vec![],
20381            owner_character_id: None,
20382            owner_label: Some("Madsin".into()),
20383            building_id: None,
20384            plot_code: "xyz1234a".into(),
20385            label: "Food Pad".into(),
20386        };
20387        assert_eq!(
20388            super::plot_public_label(&plot),
20389            "Madsin — Starter Town East 1 — Food Pad"
20390        );
20391    }
20392
20393    #[test]
20394    fn plot_public_label_uses_size_when_label_and_code_blank() {
20395        let plot = flatland_protocol::PropertyPlotView {
20396            plot_id: uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap(),
20397            property_zone_id: String::new(),
20398            zone_label: None,
20399            deed_instance_id: uuid::Uuid::nil(),
20400            x0: 10.0,
20401            y0: 20.0,
20402            x1: 18.0,
20403            y1: 28.0,
20404            upkeep_copper_per_day: 1,
20405            arrears_days: 0,
20406            is_mine: true,
20407            may_farm: true,
20408            purchase_basis_copper: 0,
20409            farm_public: false,
20410            public_tax_discount_bps: 0,
20411            farm_allow: vec![],
20412            owner_character_id: None,
20413            owner_label: None,
20414            building_id: None,
20415            plot_code: String::new(),
20416            label: String::new(),
20417        };
20418        assert_eq!(super::plot_public_label(&plot), "Homestead — Plot (8×8 m)");
20419        assert!(!super::plot_public_label(&plot).contains("19fe35f"));
20420    }
20421
20422    #[test]
20423    fn plot_stop_label_prefers_view_over_hex() {
20424        let plot_id = uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap();
20425        let plot = flatland_protocol::PropertyPlotView {
20426            plot_id,
20427            property_zone_id: "zone_a".into(),
20428            zone_label: Some("Starter Town East".into()),
20429            deed_instance_id: uuid::Uuid::nil(),
20430            x0: 0.0,
20431            y0: 0.0,
20432            x1: 4.0,
20433            y1: 4.0,
20434            upkeep_copper_per_day: 1,
20435            arrears_days: 0,
20436            is_mine: true,
20437            may_farm: true,
20438            purchase_basis_copper: 0,
20439            farm_public: false,
20440            public_tax_discount_bps: 0,
20441            farm_allow: vec![],
20442            owner_character_id: None,
20443            owner_label: Some("Madsin".into()),
20444            building_id: None,
20445            plot_code: "xyz1234a".into(),
20446            label: "Food Pad".into(),
20447        };
20448        assert_eq!(
20449            super::plot_stop_label(&[plot.clone()], plot_id),
20450            "Madsin — Starter Town East — Food Pad"
20451        );
20452        let missing = uuid::Uuid::parse_str("1a001590-0000-4000-8000-000000000002").unwrap();
20453        assert_eq!(super::plot_stop_label(&[plot], missing), "plot 1a001590");
20454    }
20455}