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    None
1269}
1270
1271/// True when a worker `last_error` is transient noise (soft-skip chatter).
1272pub fn worker_error_is_transient(err: &str) -> bool {
1273    let e = err.to_ascii_lowercase();
1274    e.contains("continuing route") || e.starts_with("nothing to withdraw")
1275}
1276
1277/// True for routine worker recoveries that should not paint the status bar.
1278/// Still logged when they change (see [`GameState::apply_hired_workers`]).
1279pub fn worker_error_is_hud_noise(err: &str) -> bool {
1280    let e = err.to_ascii_lowercase();
1281    // Durable complete-or-idle park must stay visible on HUD and attention lines.
1282    if e.contains("idling") && (e.contains("cannot reach") || e.contains("unreachable")) {
1283        return false;
1284    }
1285    e.contains("returned to lodging after path")
1286        || e.contains("path failure")
1287        || e.contains("no path to")
1288        || e.contains("pathfinding")
1289        // Soft stuck recovery — worker keeps working / replans; not a player action item.
1290        || e.contains("repathing")
1291        || e.contains("nudged clear")
1292        // A* is not failing — stale latch used to paint this on Iron / Travel.
1293        || e.contains("path unreachable (plan failures 0, leg 0)")
1294        // Auto space rescue — informative in the log, not a red alert.
1295        || e.contains("auto-recovery")
1296        || e.contains("stranded at interior map coords")
1297}
1298
1299/// In-flight `SetWorkerJob` waiting for IntentAck (or a reject Interaction).
1300#[derive(Debug, Clone)]
1301pub struct PendingWorkerJobAck {
1302    pub seq: u32,
1303    pub worker_instance_id: String,
1304    pub worker_label: String,
1305    pub idle: bool,
1306    pub stop_count: usize,
1307    pub prev_route: Option<flatland_protocol::WorkerRouteView>,
1308    pub prev_mode: flatland_protocol::WorkerModeView,
1309    pub prev_step_label: String,
1310    pub prev_last_error: Option<String>,
1311}
1312
1313fn push_inventory_rows(
1314    rows: &mut Vec<InventoryRow>,
1315    depth: usize,
1316    stack: &flatland_protocol::ItemStack,
1317    from: &flatland_protocol::InventoryLocation,
1318    from_parent_instance_id: Option<uuid::Uuid>,
1319    section: InventorySection,
1320) {
1321    push_inventory_rows_filtered(
1322        rows,
1323        depth,
1324        stack,
1325        from,
1326        from_parent_instance_id,
1327        section,
1328        "",
1329    );
1330}
1331
1332fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
1333    if filter.is_empty() {
1334        return true;
1335    }
1336    let f = filter.to_ascii_lowercase();
1337    let name = stack
1338        .display_name
1339        .as_deref()
1340        .unwrap_or("")
1341        .to_ascii_lowercase();
1342    let tid = stack.template_id.to_ascii_lowercase();
1343    name.contains(&f)
1344        || tid.contains(&f)
1345        || stack
1346            .contents
1347            .iter()
1348            .any(|c| stack_matches_filter(c, filter))
1349}
1350
1351fn push_inventory_rows_filtered(
1352    rows: &mut Vec<InventoryRow>,
1353    depth: usize,
1354    stack: &flatland_protocol::ItemStack,
1355    from: &flatland_protocol::InventoryLocation,
1356    from_parent_instance_id: Option<uuid::Uuid>,
1357    section: InventorySection,
1358    filter: &str,
1359) {
1360    if !filter.is_empty() && !stack_matches_filter(stack, filter) {
1361        return;
1362    }
1363    let self_hit = filter.is_empty() || {
1364        let f = filter.to_ascii_lowercase();
1365        let name = stack
1366            .display_name
1367            .as_deref()
1368            .unwrap_or("")
1369            .to_ascii_lowercase();
1370        let tid = stack.template_id.to_ascii_lowercase();
1371        name.contains(&f) || tid.contains(&f)
1372    };
1373    rows.push(InventoryRow {
1374        depth,
1375        stack: stack.clone(),
1376        from: from.clone(),
1377        from_parent_instance_id,
1378        is_equip_shell: false,
1379        is_chest_shell: false,
1380        section,
1381    });
1382    for child in &stack.contents {
1383        if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
1384            push_inventory_rows_filtered(
1385                rows,
1386                depth + 1,
1387                child,
1388                from,
1389                stack.item_instance_id,
1390                section,
1391                if self_hit { "" } else { filter },
1392            );
1393        }
1394    }
1395}
1396
1397#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1398pub enum ShopTab {
1399    #[default]
1400    Buy,
1401    Sell,
1402}
1403
1404#[derive(Debug, Clone)]
1405pub struct NpcChatState {
1406    pub npc_id: String,
1407    pub npc_label: String,
1408    pub lines: Vec<String>,
1409    pub input: String,
1410    pub pending: bool,
1411    pub talk_depth: flatland_protocol::NpcTalkDepth,
1412    pub trade_allowed: bool,
1413    pub banner: Option<String>,
1414    pub suggested_topics: Vec<String>,
1415}
1416
1417impl Default for NpcChatState {
1418    fn default() -> Self {
1419        Self {
1420            npc_id: String::new(),
1421            npc_label: String::new(),
1422            lines: Vec::new(),
1423            input: String::new(),
1424            pending: false,
1425            talk_depth: flatland_protocol::NpcTalkDepth::Full,
1426            trade_allowed: true,
1427            banner: None,
1428            suggested_topics: Vec::new(),
1429        }
1430    }
1431}
1432
1433/// World XY for HUD, probes, and gfx: prefer live entity transform for wildlife combat bodies.
1434pub fn npc_world_xy(state: &GameState, npc: &NpcView) -> (f32, f32) {
1435    npc.entity_id
1436        .and_then(|eid| state.entities.iter().find(|e| e.id == eid))
1437        .map(|e| (e.transform.position.x, e.transform.position.y))
1438        .unwrap_or((npc.x, npc.y))
1439}
1440
1441#[derive(Debug, Clone)]
1442pub struct GameState {
1443    pub session_id: SessionId,
1444    pub entity_id: EntityId,
1445    /// Logged-in character — used to show owner-only container labels.
1446    pub character_id: Option<uuid::Uuid>,
1447    pub tick: Tick,
1448    pub chunk_rev: u64,
1449    pub content_rev: u64,
1450    pub publish_rev: u64,
1451    pub entities: Vec<EntityState>,
1452    pub player: Option<EntityState>,
1453    pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1454    /// Worker Harvest picker cache — region-wide nodes from welcome, kept across AOI ticks.
1455    pub harvest_route_nodes: Vec<flatland_protocol::ResourceNodeView>,
1456    pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1457    pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1458    pub buildings: Vec<BuildingView>,
1459    pub doors: Vec<DoorView>,
1460    pub interior_map: Option<InteriorMapView>,
1461    pub npcs: Vec<NpcView>,
1462    pub blueprints: Vec<BlueprintView>,
1463    /// Wall/roof packs for the B plot-build menu.
1464    pub building_materials: Vec<flatland_protocol::BuildingMaterialView>,
1465    /// Outdoor play AABB origin (meters).
1466    pub world_x0: f32,
1467    pub world_y0: f32,
1468    pub world_width_m: f32,
1469    pub world_height_m: f32,
1470    pub terrain_zones: Vec<TerrainZoneView>,
1471    pub z_platforms: Vec<ZPlatformView>,
1472    pub z_transitions: Vec<ZTransitionView>,
1473    /// Outdoor z-bands saved before an interior overwrite; restored when leaving.
1474    /// Tick deltas never refresh `z_platforms`, so without this, interior platforms stick.
1475    #[doc(hidden)]
1476    pub z_bands_outdoor_backup: Option<(Vec<ZPlatformView>, Vec<ZTransitionView>)>,
1477    pub world_clock: flatland_protocol::WorldClock,
1478    pub inventory: std::collections::HashMap<String, u32>,
1479    pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1480    /// Server item catalog (labels/categories). Survives inventory resync.
1481    pub item_catalog: std::collections::HashMap<String, ItemCatalogEntryView>,
1482    pub logs: VecDeque<String>,
1483    pub intents_sent: u64,
1484    pub ticks_received: u64,
1485    pub connected: bool,
1486    pub disconnect_reason: Option<String>,
1487    pub show_stats: bool,
1488    /// When true, the bottom system LOG dock is collapsed (sidebar gains the space).
1489    pub hud_log_hidden: bool,
1490    pub show_equip_menu: bool,
1491    pub equip_menu_index: usize,
1492    pub show_craft_menu: bool,
1493    pub craft_menu_index: usize,
1494    /// How many timed crafts to queue when confirming the craft menu.
1495    pub craft_batch_quantity: u32,
1496    /// Active craft list tab (Ready default).
1497    pub craft_tab: CraftTab,
1498    /// Search within the active craft tab (`/` to focus).
1499    pub craft_filter: String,
1500    pub craft_filter_focused: bool,
1501    /// Per-character favorites / recent (loaded when character id is known).
1502    pub craft_prefs: crate::craft_prefs::CraftCharacterPrefs,
1503    /// B — wall/roof material picker for plot buildings.
1504    pub show_plot_build_menu: bool,
1505    /// True = navigate walls; false = navigate roofs (Tab switches).
1506    pub plot_build_focus_wall: bool,
1507    pub plot_build_wall_index: usize,
1508    pub plot_build_roof_index: usize,
1509    pub show_shop_menu: bool,
1510    pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1511    pub bank_panel: Option<flatland_protocol::BankPanel>,
1512    pub bank_menu_index: usize,
1513    pub bank_ui_mode: BankUiMode,
1514    pub storage_panel: Option<flatland_protocol::StoragePanel>,
1515    pub market_panel: Option<flatland_protocol::MarketPanel>,
1516    /// Selected row among filtered market browse listings.
1517    pub market_menu_index: usize,
1518    /// Browse / list-pick text filter (`/` to focus).
1519    pub market_filter: String,
1520    pub market_filter_focused: bool,
1521    /// Category group filter (`None` = All). Values match [`inventory_category_group`] labels.
1522    pub market_category_filter: Option<&'static str>,
1523    /// Pending buy confirm: (listing_id, qty, unit_price, line_total, display_name).
1524    pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
1525    pub market_ui_mode: MarketUiMode,
1526    pub storage_menu_index: usize,
1527    pub storage_ui_mode: StorageUiMode,
1528    pub shop_tab: ShopTab,
1529    pub shop_menu_index: usize,
1530    pub shop_quantity: u32,
1531    /// Recent buy/sell lines while the shop panel is open (gfx dock).
1532    pub shop_trade_log: VecDeque<String>,
1533    pub show_npc_verb_menu: bool,
1534    pub npc_verb_target: Option<String>,
1535    pub npc_verb_index: usize,
1536    /// Last refuse/system line shown while the Talk/Trade verb dock is open.
1537    pub npc_verb_notice: Option<String>,
1538    /// Nearby player Speak / Whisper / Trade dock.
1539    pub player_verbs: crate::social::PlayerVerbState,
1540    pub social_chat: crate::social::SocialChatState,
1541    pub trade_ui: crate::social::TradeUiState,
1542    pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1543    pub show_npc_chat: bool,
1544    pub npc_chat: Option<NpcChatState>,
1545    pub show_inventory_menu: bool,
1546    pub inventory_menu_index: usize,
1547    pub inventory_tab: InventoryTab,
1548    pub inventory_filter: String,
1549    pub inventory_filter_focused: bool,
1550    pub show_move_picker: bool,
1551    pub move_picker_index: usize,
1552    pub move_picker: Option<MovePicker>,
1553    pub show_grant_picker: bool,
1554    pub grant_picker_index: usize,
1555    pub grant_picker: Option<GrantTargetPicker>,
1556    pub show_destroy_picker: bool,
1557    pub destroy_confirm_pending: bool,
1558    pub destroy_picker: Option<DestroyPicker>,
1559    /// Rename prompt for a selected container (`n` in inventory).
1560    pub show_rename_prompt: bool,
1561    /// When set, the rename prompt targets a property plot (deed or on-plot).
1562    pub rename_plot_id: Option<uuid::Uuid>,
1563    /// Plot AABB to emphasize on the map (farm picker / deed focus).
1564    pub highlighted_plot_id: Option<uuid::Uuid>,
1565    /// Rename prompt for a hired worker (`n` in workers menu).
1566    pub show_worker_rename: bool,
1567    pub rename_buffer: String,
1568    /// Slot-1 combat target (mirrors server after SetTarget).
1569    pub combat_target: Option<EntityId>,
1570    pub combat_target_label: Option<String>,
1571    /// Local free-aim ground target (world meters) set via Shift+click on open ground.
1572    /// Used as `Intent::Cast.target_point` for ground/either aim-mode abilities.
1573    pub ground_target: Option<(f32, f32, f32)>,
1574    /// Active combat footprints from the server (`plans/39`).
1575    pub combat_fx: Vec<flatland_protocol::CombatFx>,
1576    /// Lingering ground hazards (boss puddles) from the server.
1577    pub ground_hazards: Vec<flatland_protocol::GroundHazardView>,
1578    /// Authored crown property zones (claimable land) — plan 40.
1579    pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1580    /// Tax overlays for claim cost premium preview.
1581    pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1582    /// Growth / fertility overlays (farming, respawn).
1583    pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1584    /// Climate / biome overlays.
1585    pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1586    /// Terrain kind speeds / impassable flags for auto-nav (from content).
1587    pub terrain_kind_nav: Vec<flatland_protocol::TerrainKindNavView>,
1588    /// Claimed property plots near the observer.
1589    pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1590    /// Server knobs for client-side claim quotes.
1591    pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1592    /// Local claim-footprint editor (not server state).
1593    pub claim_mode: Option<ClaimModeState>,
1594    /// Local relocate ghost for a placed chest/lodging (not server state).
1595    pub relocate_mode: Option<RelocateModeState>,
1596    /// Second `f` within a short window confirms selling this plot back to the crown.
1597    pub sell_plot_confirm: Option<uuid::Uuid>,
1598    /// When `sell_plot_confirm` was armed (for double-tap window).
1599    pub sell_plot_armed_at: Option<Instant>,
1600    /// Choose seed type when planting on owned tilled soil (`f`).
1601    pub show_plant_menu: bool,
1602    pub plant_menu_index: usize,
1603    /// Deed-holder farm access panel (public toggle + allow-list).
1604    pub show_farm_access: bool,
1605    /// Draft name for adding a tenant from the farm-access panel.
1606    pub farm_access_name_draft: String,
1607    /// Public / grant discount draft (bps) while editing farm access.
1608    pub farm_access_discount_bps: u32,
1609    /// Selected row in the farm-access panel (0 = public toggle).
1610    pub farm_access_index: usize,
1611    pub plant_quantity: u32,
1612    pub in_combat: bool,
1613    pub auto_attack: bool,
1614    pub combat_has_los: bool,
1615    pub attack_cd_ticks: u64,
1616    pub gcd_ticks: u64,
1617    pub weapon_ability_id: String,
1618    pub mainhand_template_id: Option<String>,
1619    pub mainhand_label: Option<String>,
1620    pub mainhand_instance_id: Option<uuid::Uuid>,
1621    pub offhand_template_id: Option<String>,
1622    pub offhand_label: Option<String>,
1623    pub offhand_instance_id: Option<uuid::Uuid>,
1624    pub mainhand_hand_slots: u8,
1625    pub defense: Option<flatland_protocol::DefenseHud>,
1626    /// Worn body-slot items — armor, cloak, jewelry, backpack, belt.
1627    pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1628    pub carry_mass: f32,
1629    pub carry_mass_max: f32,
1630    pub encumbrance: flatland_protocol::EncumbranceState,
1631    /// Walk m/s after encumbrance / DEX (from combat HUD). `0` until first HUD.
1632    pub move_speed_mps: f32,
1633    /// Encumbrance+status walk multiplier (`1.0` unloaded). `0` until first HUD.
1634    pub move_speed_mult: f32,
1635    /// Full nested inventory stacks from the server (root only; worn are separate).
1636    pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1637    /// Keys stowed on the virtual keychain (zero carry mass).
1638    pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1639    /// Whisper stones in the pouch (zero carry mass).
1640    pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1641    /// Active status effects on the local player (buff/debuff strip).
1642    pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1643    pub combat_target_detail: Option<CombatTargetHud>,
1644    pub cast_progress: Option<CastProgressHud>,
1645    /// Till / plant / other non-combat timed channels on the local player.
1646    pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1647    /// Owned plot underfoot offer (pad + storage pool) for the B menu.
1648    pub plot_build_offer: Option<flatland_protocol::PlotBuildOfferHud>,
1649    pub ability_cooldowns: Vec<AbilityCooldownHud>,
1650    pub blocking_active: bool,
1651    pub max_target_slots: u8,
1652    pub combat_slots: Vec<CombatSlotHud>,
1653    pub rotation_presets: Vec<RotationPreset>,
1654    /// Learned abilities from combat HUD (usable casts).
1655    pub known_abilities: Vec<String>,
1656    /// Aim / blast metadata for known abilities (ground cast UX), keyed by ability id.
1657    pub ability_meta: std::collections::HashMap<String, flatland_protocol::AbilityMetaHud>,
1658    /// Per-ability mastery from combat HUD (tier / XP).
1659    pub ability_mastery: std::collections::HashMap<String, flatland_protocol::AbilityMasteryHud>,
1660    /// Server-persisted hotbar bindings (index 0 = key 1).
1661    pub hotbar: Vec<Option<String>>,
1662    /// Max abilities allowed in one rotation (mind score).
1663    pub max_abilities_per_rotation: u8,
1664    pub show_loadout_menu: bool,
1665    pub show_keychain_menu: bool,
1666    pub keychain_menu_index: usize,
1667    pub show_rotation_editor: bool,
1668    /// Selected rotation preset in the loadout menu.
1669    pub loadout_menu_index: usize,
1670    /// Selected hotbar slot (`1`–`9`) for bind/clear in the loadout menu.
1671    pub loadout_hotbar_slot: u8,
1672    /// Selected known-ability row in the loadout menu.
1673    pub loadout_ability_index: usize,
1674    /// When true, ↑/↓ navigate presets; when false, navigate known abilities.
1675    pub loadout_focus_presets: bool,
1676    pub rotation_editor: RotationEditorState,
1677    /// True after a harvest intent is accepted until result/reject/disconnect.
1678    pub harvest_in_progress: bool,
1679    /// Wall-clock start of the current harvest; clears stale client state on timeout.
1680    pub harvest_started_at: Option<Instant>,
1681    /// Craft log deferred until the server acks the craft intent.
1682    pub pending_craft_ack: Option<(u32, String, u32)>,
1683    /// Blueprint kept on the Ready tab while its craft channel runs (inputs
1684    /// are spent at begin, so `can_craft` goes false before progress finishes).
1685    pub craft_channel_blueprint_id: Option<String>,
1686    pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1687    pub interactables: Vec<flatland_protocol::InteractableView>,
1688    pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1689    pub career: Option<flatland_protocol::PlayerCareerView>,
1690    pub character_sheet_tab: CharacterSheetTab,
1691    pub ledger_period: LedgerPeriod,
1692    pub show_quest_offer: bool,
1693    pub pending_quest_offers: Vec<flatland_protocol::QuestOffer>,
1694    pub quest_offer_index: usize,
1695    pub show_quest_menu: bool,
1696    pub quest_menu_index: usize,
1697    pub quest_withdraw_confirm: bool,
1698    pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1699    pub show_workers_menu: bool,
1700    pub workers_menu_index: usize,
1701    pub worker_dismiss_confirmation: Option<WorkerDismissConfirmation>,
1702    /// Workers panel: one-line rows instead of full cards (more on screen).
1703    pub workers_menu_compact: bool,
1704    /// Coarse `step:` line held per worker so AOI ticks cannot thrash the menu.
1705    /// Public so out-of-crate tests can construct [`GameState`].
1706    pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1707    /// Held worker route errors (readable when server clears `last_error` each tick).
1708    pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1709    /// Wall-clock expiry for defender HP rings after their last observed hit.
1710    pub worker_health_ring_until: BTreeMap<EntityId, Instant>,
1711    /// Set after a hire request until its worker appears in a roster update.
1712    pub pending_worker_hire_since: Option<Instant>,
1713    /// Give-item sheet opened from the workers menu (`g`) — pick which item to give.
1714    pub show_worker_give_picker: bool,
1715    pub worker_give_picker_index: usize,
1716    pub worker_give_picker: Option<WorkerGivePicker>,
1717    /// Inventory `g` — pick which nearby worker receives the selected stack.
1718    pub show_worker_give_target_picker: bool,
1719    pub worker_give_target_picker_index: usize,
1720    pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1721    /// Take-item sheet opened from the workers menu (`i`) — pick which worker stack to take.
1722    pub show_worker_take_picker: bool,
1723    pub worker_take_picker_index: usize,
1724    pub worker_take_picker: Option<WorkerTakePicker>,
1725    /// Teach-blueprint sheet opened from the workers menu (`t`).
1726    pub show_worker_teach_picker: bool,
1727    pub worker_teach_picker_index: usize,
1728    pub worker_teach_picker: Option<WorkerTeachPicker>,
1729    /// Active harvest-route editor (`h` → `e` on a worker).
1730    pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1731    /// Awaiting IntentAck for the last route save (`SetWorkerJob`).
1732    pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1733    /// Worker currently paused via `AttendHiredWorker` (opened with `f`).
1734    pub attending_worker_instance_id: Option<String>,
1735    /// Server progression curve from the latest combat HUD (matches server-settings.yaml).
1736    pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1737}
1738
1739#[derive(Debug, Clone, PartialEq, Eq)]
1740pub enum NpcVerbAction {
1741    Talk,
1742    Trade,
1743    Bank,
1744    Storage,
1745    Market,
1746    QuestTalk { quest_id: String },
1747    QuestGive { quest_id: String },
1748}
1749
1750#[derive(Debug, Clone, PartialEq, Eq)]
1751pub struct NpcVerbChoice {
1752    pub label: String,
1753    pub action: NpcVerbAction,
1754}
1755
1756impl std::fmt::Display for NpcVerbChoice {
1757    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1758        f.write_str(&self.label)
1759    }
1760}
1761
1762impl GameState {
1763    pub fn selected_quest_offer(&self) -> Option<&flatland_protocol::QuestOffer> {
1764        self.pending_quest_offers.get(self.quest_offer_index)
1765    }
1766
1767    pub fn push_quest_offer(&mut self, offer: flatland_protocol::QuestOffer) {
1768        if self
1769            .pending_quest_offers
1770            .iter()
1771            .any(|existing| existing.quest_id == offer.quest_id)
1772        {
1773            self.show_quest_offer = true;
1774            return;
1775        }
1776        self.pending_quest_offers.push(offer);
1777        self.show_quest_offer = true;
1778    }
1779
1780    pub fn remove_quest_offer(&mut self, quest_id: &str) {
1781        self.pending_quest_offers
1782            .retain(|offer| offer.quest_id != quest_id);
1783        if self.pending_quest_offers.is_empty() {
1784            self.show_quest_offer = false;
1785            self.quest_offer_index = 0;
1786            return;
1787        }
1788        self.quest_offer_index = self
1789            .quest_offer_index
1790            .min(self.pending_quest_offers.len() - 1);
1791        self.show_quest_offer = true;
1792    }
1793
1794    pub fn clear_quest_offers(&mut self) {
1795        self.pending_quest_offers.clear();
1796        self.quest_offer_index = 0;
1797        self.show_quest_offer = false;
1798    }
1799
1800    pub fn move_quest_offer_selection(&mut self, delta: i32) {
1801        let n = self.pending_quest_offers.len();
1802        if n == 0 {
1803            self.quest_offer_index = 0;
1804            return;
1805        }
1806        let idx = self.quest_offer_index as i32;
1807        self.quest_offer_index = (idx + delta).rem_euclid(n as i32) as usize;
1808    }
1809
1810    pub fn push_log(&mut self, line: impl Into<String>) {
1811        self.logs.push_back(line.into());
1812        while self.logs.len() > MAX_LOG_LINES {
1813            self.logs.pop_front();
1814        }
1815    }
1816
1817    pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1818        self.shop_trade_log.push_back(line.into());
1819        while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1820            self.shop_trade_log.pop_front();
1821        }
1822    }
1823
1824    pub fn clear_shop_trade_log(&mut self) {
1825        self.shop_trade_log.clear();
1826    }
1827
1828    fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1829        if !self.show_shop_menu {
1830            return;
1831        }
1832        let msg = notice.message.trim();
1833        if msg.is_empty() {
1834            return;
1835        }
1836        if notice.coins_delta != 0
1837            || msg.starts_with("Bought ")
1838            || msg.starts_with("Sold ")
1839            || msg.contains("taught you how to craft")
1840            || msg.starts_with("need ")
1841        {
1842            self.push_shop_trade_log(msg);
1843        }
1844    }
1845
1846    pub fn is_alive(&self) -> bool {
1847        self.player
1848            .as_ref()
1849            .and_then(|p| p.vitals)
1850            .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1851            .unwrap_or(true)
1852    }
1853
1854    pub fn push_audio(&mut self, cue: crate::social::AudioCue) {
1855        self.social_chat.push_cue(cue);
1856    }
1857
1858    /// Rising/falling edges for combat and progression SFX (call after snapshot/tick apply).
1859    fn sync_gameplay_audio(&mut self) {
1860        use crate::social::AudioCue;
1861        use flatland_protocol::PrimaryAttributes;
1862
1863        let alive = self.is_alive();
1864        let casting = self.cast_progress.is_some();
1865        let telegraph = self.focus_attack_telegraph_active();
1866        let in_aoe = self.player_inside_spatial_telegraph();
1867        let quest_sig = self.quest_audio_signature();
1868        let entity_id = self.entity_id;
1869        let char_level = self
1870            .player
1871            .as_ref()
1872            .and_then(|p| p.attributes)
1873            .map(|a| {
1874                PrimaryAttributes::display(a.strength)
1875                    .saturating_add(PrimaryAttributes::display(a.dexterity))
1876                    .saturating_add(PrimaryAttributes::display(a.intelligence))
1877                    .saturating_add(PrimaryAttributes::display(a.stamina))
1878                    .saturating_add(PrimaryAttributes::display(a.vitality))
1879                    .saturating_add(PrimaryAttributes::display(a.wisdom))
1880                    .saturating_add(PrimaryAttributes::display(a.charisma))
1881            })
1882            .unwrap_or(0);
1883
1884        let fx_ids: Vec<u64> = self.combat_fx.iter().map(|fx| fx.id).collect();
1885        let mut hit_cues = Vec::new();
1886        {
1887            let seen = &self.social_chat.audio_seen_fx_ids;
1888            for fx in &self.combat_fx {
1889                if seen.contains(&fx.id) {
1890                    continue;
1891                }
1892                let Some(hit) = fx.hits.iter().find(|h| h.entity_id == entity_id) else {
1893                    continue;
1894                };
1895                if hit.outcome == CombatFxHitOutcome::Blocked {
1896                    hit_cues.push(AudioCue::CombatBlock);
1897                } else {
1898                    let heavy = matches!(
1899                        fx.kind,
1900                        CombatFxKind::Sphere | CombatFxKind::Cone | CombatFxKind::Beam
1901                    );
1902                    hit_cues.push(if heavy {
1903                        AudioCue::CombatHitHeavy
1904                    } else {
1905                        AudioCue::CombatHitLight
1906                    });
1907                }
1908            }
1909        }
1910
1911        let audio = &mut self.social_chat;
1912        if !audio.audio_bootstrapped {
1913            audio.audio_was_alive = alive;
1914            audio.audio_was_casting = casting;
1915            audio.audio_had_target_telegraph = telegraph;
1916            audio.audio_was_in_aoe = in_aoe;
1917            audio.audio_quest_sig = quest_sig;
1918            audio.audio_char_level = char_level;
1919            audio.audio_seen_fx_ids = fx_ids;
1920            audio.audio_bootstrapped = true;
1921            return;
1922        }
1923
1924        if telegraph && !audio.audio_had_target_telegraph {
1925            audio.push_cue(AudioCue::CombatTelegraphStart);
1926        } else if !telegraph && audio.audio_had_target_telegraph {
1927            audio.push_cue(AudioCue::CombatTelegraphImpact);
1928        }
1929        audio.audio_had_target_telegraph = telegraph;
1930
1931        if in_aoe && !audio.audio_was_in_aoe {
1932            audio.push_cue(AudioCue::CombatAoeWarn);
1933        }
1934        audio.audio_was_in_aoe = in_aoe;
1935
1936        if casting && !audio.audio_was_casting {
1937            audio.push_cue(AudioCue::AbilityCastSelf);
1938        }
1939        audio.audio_was_casting = casting;
1940
1941        if !alive && audio.audio_was_alive {
1942            audio.push_cue(AudioCue::PlayerDeath);
1943        }
1944        audio.audio_was_alive = alive;
1945
1946        if quest_sig != audio.audio_quest_sig && audio.audio_quest_sig != 0 {
1947            audio.push_cue(AudioCue::QuestUpdate);
1948        }
1949        audio.audio_quest_sig = quest_sig;
1950
1951        if char_level > audio.audio_char_level && audio.audio_char_level > 0 {
1952            audio.push_cue(AudioCue::LevelUp);
1953        }
1954        audio.audio_char_level = char_level;
1955
1956        for cue in hit_cues {
1957            audio.push_cue(cue);
1958        }
1959        audio.audio_seen_fx_ids = fx_ids;
1960    }
1961
1962    fn focus_attack_telegraph_active(&self) -> bool {
1963        let Some(tid) = self.combat_target else {
1964            return false;
1965        };
1966        self.entities
1967            .iter()
1968            .find(|e| e.id == tid)
1969            .map(|e| {
1970                e.combat_cues.iter().any(|c| {
1971                    matches!(c.kind, CombatCueKind::AttackTelegraph) && c.until_tick > self.tick
1972                })
1973            })
1974            .unwrap_or(false)
1975    }
1976
1977    fn player_inside_spatial_telegraph(&self) -> bool {
1978        let (px, py) = self.player_position();
1979        for e in &self.entities {
1980            for cue in &e.combat_cues {
1981                if !matches!(cue.kind, CombatCueKind::AttackTelegraph)
1982                    || cue.until_tick <= self.tick
1983                {
1984                    continue;
1985                }
1986                let Some(kind) = cue.telegraph_kind else {
1987                    continue;
1988                };
1989                let (ox, oy) = match (cue.origin_x, cue.origin_y) {
1990                    (Some(x), Some(y)) => (x, y),
1991                    _ => continue,
1992                };
1993                match kind {
1994                    CombatFxKind::Sphere => {
1995                        let r = cue.radius_m.unwrap_or(1.0);
1996                        let dx = px - ox;
1997                        let dy = py - oy;
1998                        if dx * dx + dy * dy <= r * r {
1999                            return true;
2000                        }
2001                    }
2002                    CombatFxKind::Cone | CombatFxKind::MeleeArc => {
2003                        let reach = cue.reach_m.unwrap_or(2.0);
2004                        let yaw = cue.yaw.unwrap_or(0.0);
2005                        let arc = cue.arc_deg.unwrap_or(90.0).to_radians();
2006                        let dx = px - ox;
2007                        let dy = py - oy;
2008                        let dist = (dx * dx + dy * dy).sqrt();
2009                        if dist > reach || dist < 0.05 {
2010                            continue;
2011                        }
2012                        let ang = dx.atan2(dy);
2013                        let mut delta = ang - yaw;
2014                        while delta > std::f32::consts::PI {
2015                            delta -= std::f32::consts::TAU;
2016                        }
2017                        while delta < -std::f32::consts::PI {
2018                            delta += std::f32::consts::TAU;
2019                        }
2020                        if delta.abs() <= arc * 0.5 {
2021                            return true;
2022                        }
2023                    }
2024                    _ => {}
2025                }
2026            }
2027        }
2028        false
2029    }
2030
2031    fn quest_audio_signature(&self) -> u64 {
2032        use std::collections::hash_map::DefaultHasher;
2033        use std::hash::{Hash, Hasher};
2034        let mut h = DefaultHasher::new();
2035        for q in &self.quest_log {
2036            q.quest_id.hash(&mut h);
2037            format!("{:?}", q.status).hash(&mut h);
2038            q.current_step_id.hash(&mut h);
2039            for o in &q.objectives {
2040                o.done.hash(&mut h);
2041                o.current.hash(&mut h);
2042            }
2043        }
2044        h.finish()
2045    }
2046
2047    /// Verb menu entries for the current `npc_verb_target`.
2048    pub fn npc_verb_options(&self) -> Vec<NpcVerbChoice> {
2049        let Some(ref id) = self.npc_verb_target else {
2050            return vec![];
2051        };
2052        let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
2053            return self.with_quest_verbs(id, vec![Self::talk_choice()]);
2054        };
2055        let role = npc.role.as_str();
2056        let rest = if Self::npc_role_is_bank(role) {
2057            vec![
2058                NpcVerbChoice {
2059                    label: "Bank".into(),
2060                    action: NpcVerbAction::Bank,
2061                },
2062                Self::talk_choice(),
2063            ]
2064        } else if Self::npc_role_is_storage(role) {
2065            vec![
2066                NpcVerbChoice {
2067                    label: "Storage".into(),
2068                    action: NpcVerbAction::Storage,
2069                },
2070                Self::talk_choice(),
2071            ]
2072        } else if Self::npc_role_is_market(role) {
2073            vec![
2074                NpcVerbChoice {
2075                    label: "Market".into(),
2076                    action: NpcVerbAction::Market,
2077                },
2078                Self::talk_choice(),
2079            ]
2080        } else if npc.can_trade || Self::npc_role_can_trade(role) {
2081            vec![
2082                Self::talk_choice(),
2083                NpcVerbChoice {
2084                    label: "Trade".into(),
2085                    action: NpcVerbAction::Trade,
2086                },
2087            ]
2088        } else {
2089            vec![Self::talk_choice()]
2090        };
2091        self.with_quest_verbs(id, rest)
2092    }
2093
2094    fn talk_choice() -> NpcVerbChoice {
2095        NpcVerbChoice {
2096            label: "Talk".into(),
2097            action: NpcVerbAction::Talk,
2098        }
2099    }
2100
2101    fn with_quest_verbs(&self, npc_id: &str, rest: Vec<NpcVerbChoice>) -> Vec<NpcVerbChoice> {
2102        let mut opts = self.quest_verb_choices(npc_id);
2103        opts.extend(rest);
2104        opts
2105    }
2106
2107    fn quest_verb_choices(&self, npc_id: &str) -> Vec<NpcVerbChoice> {
2108        if let Some(npc) = self.npcs.iter().find(|n| n.id == npc_id) {
2109            if !npc.quest_verbs.is_empty() {
2110                return npc
2111                    .quest_verbs
2112                    .iter()
2113                    .map(|v| {
2114                        let action = if v.kind == flatland_protocol::NpcQuestVerb::KIND_GIVE {
2115                            NpcVerbAction::QuestGive {
2116                                quest_id: v.quest_id.clone(),
2117                            }
2118                        } else {
2119                            NpcVerbAction::QuestTalk {
2120                                quest_id: v.quest_id.clone(),
2121                            }
2122                        };
2123                        NpcVerbChoice {
2124                            label: v.label.clone(),
2125                            action,
2126                        }
2127                    })
2128                    .collect();
2129            }
2130        }
2131        let catalog_ref = self.npc_quest_catalog_ref(npc_id);
2132        let mut opts = Vec::new();
2133        for q in &self.quest_log {
2134            if q.status != flatland_protocol::QuestStatusView::Active {
2135                continue;
2136            }
2137            let title = if q.title.trim().is_empty() {
2138                "Quest".to_string()
2139            } else {
2140                q.title.clone()
2141            };
2142            for o in &q.objectives {
2143                if o.done || o.npc_ref.as_deref() != Some(catalog_ref.as_str()) {
2144                    continue;
2145                }
2146                if o.kind == "give_item" {
2147                    opts.push(NpcVerbChoice {
2148                        label: format!("Turn in: {title}"),
2149                        action: NpcVerbAction::QuestGive {
2150                            quest_id: q.quest_id.clone(),
2151                        },
2152                    });
2153                } else if o.kind == "talk_npc" {
2154                    opts.push(NpcVerbChoice {
2155                        label: title.clone(),
2156                        action: NpcVerbAction::QuestTalk {
2157                            quest_id: q.quest_id.clone(),
2158                        },
2159                    });
2160                }
2161            }
2162        }
2163        opts
2164    }
2165
2166    fn npc_quest_catalog_ref(&self, npc_id: &str) -> String {
2167        self.npcs
2168            .iter()
2169            .find(|n| n.id == npc_id)
2170            .and_then(|n| n.paperdoll_ref.clone())
2171            .unwrap_or_else(|| npc_id.to_string())
2172    }
2173
2174    fn count_inventory_template(&self, template: &str) -> u32 {
2175        self.inventory_stacks
2176            .iter()
2177            .filter(|s| s.template_id == template)
2178            .map(|s| s.quantity)
2179            .sum()
2180    }
2181
2182    fn npc_role_can_trade(role: &str) -> bool {
2183        matches!(role, "broker" | "cook" | "farmer" | "merchant")
2184    }
2185
2186    fn npc_role_is_bank(role: &str) -> bool {
2187        role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
2188    }
2189
2190    fn npc_role_is_storage(role: &str) -> bool {
2191        role.eq_ignore_ascii_case("storage_manager")
2192    }
2193
2194    fn npc_role_is_market(role: &str) -> bool {
2195        role.eq_ignore_ascii_case("market_clerk")
2196    }
2197
2198    pub fn bank_menu_options(&self) -> Vec<&'static str> {
2199        vec![
2200            "Deposit…",
2201            "Withdraw…",
2202            "Deposit all",
2203            "Withdraw all",
2204            "Transfer…",
2205        ]
2206    }
2207
2208    pub fn storage_menu_options(&self) -> Vec<String> {
2209        let mut opts = vec!["Store…".into(), "Take…".into()];
2210        if let Some(panel) = &self.storage_panel {
2211            for dest in &panel.ship_destinations {
2212                opts.push(format!(
2213                    "Ship → {} ({} cp / {} ticks)",
2214                    dest.label, dest.fee_copper, dest.travel_ticks
2215                ));
2216            }
2217        }
2218        opts
2219    }
2220
2221    /// Loose on-person stacks eligible for town-storage store.
2222    /// Excludes hand-equipped weapons/tools (unequip first) — worn body gear is
2223    /// already outside root inventory.
2224    pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
2225        let equipped = self.hand_equipped_instance_ids();
2226        self.person_rows()
2227            .into_iter()
2228            .filter(|r| r.depth == 0)
2229            .filter_map(|r| {
2230                let id = r.stack.item_instance_id?;
2231                if equipped.contains(&id) {
2232                    return None;
2233                }
2234                Some(StoragePickOption {
2235                    item_instance_id: id,
2236                    template_id: r.stack.template_id.clone(),
2237                    label: storage_stack_label(&r.stack),
2238                    quantity: r.stack.quantity,
2239                    category: r.stack.category.clone().unwrap_or_default(),
2240                })
2241            })
2242            .collect()
2243    }
2244
2245    /// Instance ids currently occupying mainhand / offhand (for store / list filters).
2246    pub fn hand_equipped_instance_ids(&self) -> std::collections::HashSet<uuid::Uuid> {
2247        let mut ids = std::collections::HashSet::new();
2248        if let Some(id) = self.mainhand_instance_id {
2249            ids.insert(id);
2250        } else if let Some(tid) = &self.mainhand_template_id {
2251            if let Some(id) = self
2252                .inventory_stacks
2253                .iter()
2254                .find(|s| &s.template_id == tid)
2255                .and_then(|s| s.item_instance_id)
2256            {
2257                ids.insert(id);
2258            }
2259        }
2260        if let Some(id) = self.offhand_instance_id {
2261            ids.insert(id);
2262        } else if let Some(tid) = &self.offhand_template_id {
2263            if let Some(id) = self
2264                .inventory_stacks
2265                .iter()
2266                .find(|s| {
2267                    &s.template_id == tid
2268                        && s.item_instance_id.is_some_and(|iid| !ids.contains(&iid))
2269                })
2270                .and_then(|s| s.item_instance_id)
2271            {
2272                ids.insert(id);
2273            }
2274        }
2275        ids
2276    }
2277
2278    /// Vault stacks eligible for take / ship.
2279    pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
2280        let Some(panel) = &self.storage_panel else {
2281            return Vec::new();
2282        };
2283        panel
2284            .contents
2285            .iter()
2286            .filter_map(|s| {
2287                let id = s.item_instance_id?;
2288                Some(StoragePickOption {
2289                    item_instance_id: id,
2290                    template_id: s.template_id.clone(),
2291                    label: storage_stack_label(s),
2292                    quantity: s.quantity,
2293                    category: s.category.clone().unwrap_or_default(),
2294                })
2295            })
2296            .collect()
2297    }
2298
2299    /// Source rows for market list: on-person, then each eligible vault (only if they hold stacks).
2300    pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
2301        let mut opts = Vec::new();
2302        if !self
2303            .market_list_item_options(&MarketListSourceKind::Person)
2304            .is_empty()
2305        {
2306            opts.push((MarketListSourceKind::Person, "On person".into()));
2307        }
2308        if let Some(panel) = &self.market_panel {
2309            for vault in &panel.list_vaults {
2310                let source = MarketListSourceKind::TownStorage {
2311                    building_id: vault.building_id.clone(),
2312                };
2313                if self.market_list_item_options(&source).is_empty() {
2314                    continue;
2315                }
2316                let label = if vault.building_label.is_empty() {
2317                    format!("Town storage ({})", vault.building_id)
2318                } else {
2319                    format!("Town storage — {}", vault.building_label)
2320                };
2321                opts.push((source, label));
2322            }
2323        }
2324        opts
2325    }
2326
2327    /// Stacks eligible to list from the chosen market source (excludes non-listable templates).
2328    pub fn market_list_item_options(
2329        &self,
2330        source: &MarketListSourceKind,
2331    ) -> Vec<StoragePickOption> {
2332        let filter = self.market_filter.as_str();
2333        let cat_filter = self.market_category_filter;
2334        let mut opts: Vec<StoragePickOption> = match source {
2335            MarketListSourceKind::Person => {
2336                let equipped = self.hand_equipped_instance_ids();
2337                self.person_rows()
2338                    .into_iter()
2339                    .filter(|r| r.depth == 0)
2340                    .filter(|r| self.stack_is_market_listable(&r.stack))
2341                    .filter_map(|r| {
2342                        let id = r.stack.item_instance_id?;
2343                        if equipped.contains(&id) {
2344                            return None;
2345                        }
2346                        Some(StoragePickOption {
2347                            item_instance_id: id,
2348                            template_id: r.stack.template_id.clone(),
2349                            label: storage_stack_label(&r.stack),
2350                            quantity: r.stack.quantity,
2351                            category: r
2352                                .stack
2353                                .category
2354                                .clone()
2355                                .or_else(|| {
2356                                    self.inventory_item_category(&r.stack.template_id)
2357                                        .map(str::to_string)
2358                                })
2359                                .unwrap_or_default(),
2360                        })
2361                    })
2362                    .collect()
2363            }
2364            MarketListSourceKind::TownStorage { building_id } => {
2365                let Some(panel) = &self.market_panel else {
2366                    return Vec::new();
2367                };
2368                let Some(vault) = panel
2369                    .list_vaults
2370                    .iter()
2371                    .find(|v| &v.building_id == building_id)
2372                else {
2373                    return Vec::new();
2374                };
2375                vault
2376                    .contents
2377                    .iter()
2378                    .filter(|s| self.stack_is_market_listable(s))
2379                    .filter_map(|s| {
2380                        let id = s.item_instance_id?;
2381                        Some(StoragePickOption {
2382                            item_instance_id: id,
2383                            template_id: s.template_id.clone(),
2384                            label: storage_stack_label(s),
2385                            quantity: s.quantity,
2386                            category: s
2387                                .category
2388                                .clone()
2389                                .or_else(|| {
2390                                    self.inventory_item_category(&s.template_id)
2391                                        .map(str::to_string)
2392                                })
2393                                .unwrap_or_default(),
2394                        })
2395                    })
2396                    .collect()
2397            }
2398        };
2399        opts.retain(|o| {
2400            if !list_label_matches(&o.label, filter) {
2401                return false;
2402            }
2403            if let Some(group) = cat_filter {
2404                inventory_category_group(&o.category).0 == group
2405            } else {
2406                true
2407            }
2408        });
2409        opts
2410    }
2411
2412    /// Catalog `base_value_copper` when synced on stacks or inventory hints.
2413    pub fn item_base_value_copper_hint(&self, template_id: &str) -> Option<u32> {
2414        if let Some(hint) = self.inventory_hints.get(template_id) {
2415            if let Some(v) = hint.base_value_copper.filter(|v| *v > 0) {
2416                return Some(v);
2417            }
2418        }
2419        if let Some(v) = self
2420            .inventory_stacks
2421            .iter()
2422            .find(|s| s.template_id == template_id)
2423            .and_then(|s| s.base_value_copper.filter(|v| *v > 0))
2424        {
2425            return Some(v);
2426        }
2427        self.market_panel.as_ref().and_then(|panel| {
2428            panel.list_vaults.iter().find_map(|vault| {
2429                vault.contents.iter().find_map(|stack| {
2430                    (stack.template_id == template_id)
2431                        .then(|| stack.base_value_copper.filter(|v| *v > 0))
2432                        .flatten()
2433                })
2434            })
2435        })
2436    }
2437
2438    /// Estimated net copper per unit for hall NPC-price dump queue (default buyer rates).
2439    pub fn npc_market_dump_unit_estimate(&self, template_id: &str) -> Option<u32> {
2440        let base = self.item_base_value_copper_hint(template_id)?;
2441        npc_market_dump_unit_estimate_copper(base)
2442    }
2443
2444    fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
2445        if crate::currency::is_currency(&stack.template_id) {
2446            return false;
2447        }
2448        if let Some(flag) = stack.listable {
2449            return flag;
2450        }
2451        if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
2452            return hint.listable;
2453        }
2454        let cat = stack
2455            .category
2456            .as_deref()
2457            .or_else(|| self.inventory_item_category(&stack.template_id))
2458            .unwrap_or("");
2459        category_default_listable(cat)
2460    }
2461
2462    /// Category group labels present in the current browse book or list-pick source.
2463    pub fn market_available_category_groups(&self) -> Vec<&'static str> {
2464        let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
2465        match &self.market_ui_mode {
2466            MarketUiMode::ListPick { source, .. } => {
2467                let raw: Vec<_> = match source {
2468                    MarketListSourceKind::Person => self
2469                        .person_rows()
2470                        .into_iter()
2471                        .filter(|r| r.depth == 0)
2472                        .filter(|r| self.stack_is_market_listable(&r.stack))
2473                        .filter(|r| {
2474                            list_label_matches(&storage_stack_label(&r.stack), &self.market_filter)
2475                        })
2476                        .map(|r| {
2477                            r.stack
2478                                .category
2479                                .clone()
2480                                .or_else(|| {
2481                                    self.inventory_item_category(&r.stack.template_id)
2482                                        .map(str::to_string)
2483                                })
2484                                .unwrap_or_default()
2485                        })
2486                        .collect(),
2487                    MarketListSourceKind::TownStorage { building_id } => self
2488                        .market_panel
2489                        .as_ref()
2490                        .and_then(|p| p.list_vaults.iter().find(|v| &v.building_id == building_id))
2491                        .map(|vault| {
2492                            vault
2493                                .contents
2494                                .iter()
2495                                .filter(|s| self.stack_is_market_listable(s))
2496                                .filter(|s| {
2497                                    list_label_matches(&storage_stack_label(s), &self.market_filter)
2498                                })
2499                                .map(|s| {
2500                                    s.category
2501                                        .clone()
2502                                        .or_else(|| {
2503                                            self.inventory_item_category(&s.template_id)
2504                                                .map(str::to_string)
2505                                        })
2506                                        .unwrap_or_default()
2507                                })
2508                                .collect::<Vec<_>>()
2509                        })
2510                        .unwrap_or_default(),
2511                };
2512                for category in raw {
2513                    let (label, ord) = inventory_category_group(&category);
2514                    seen.insert(ord, label);
2515                }
2516            }
2517            _ => {
2518                if let Some(panel) = &self.market_panel {
2519                    for listing in &panel.listings {
2520                        if !list_label_matches(&listing.display_name, &self.market_filter)
2521                            && !list_label_matches(&listing.seller_label, &self.market_filter)
2522                        {
2523                            continue;
2524                        }
2525                        let (label, ord) = inventory_category_group(&listing.category);
2526                        seen.insert(ord, label);
2527                    }
2528                }
2529            }
2530        }
2531        seen.into_values().collect()
2532    }
2533
2534    /// Indices into `market_panel.listings` after category + text filter.
2535    pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
2536        let Some(panel) = &self.market_panel else {
2537            return Vec::new();
2538        };
2539        let filter = self.market_filter.as_str();
2540        let cat_filter = self.market_category_filter;
2541        panel
2542            .listings
2543            .iter()
2544            .enumerate()
2545            .filter(|(_, listing)| {
2546                if !list_label_matches(&listing.display_name, filter)
2547                    && !list_label_matches(&listing.seller_label, filter)
2548                    && !list_label_matches(&listing.template_id, filter)
2549                {
2550                    return false;
2551                }
2552                if let Some(group) = cat_filter {
2553                    inventory_category_group(&listing.category).0 == group
2554                } else {
2555                    true
2556                }
2557            })
2558            .map(|(i, _)| i)
2559            .collect()
2560    }
2561
2562    pub fn clear_harvest_state(&mut self) {
2563        self.harvest_in_progress = false;
2564        self.harvest_started_at = None;
2565    }
2566
2567    fn harvest_state_stale(&self) -> bool {
2568        match self.harvest_started_at {
2569            Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
2570            None => self.harvest_in_progress,
2571        }
2572    }
2573
2574    pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
2575        self.player.as_ref().and_then(|p| p.vitals)
2576    }
2577
2578    pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
2579        let materials_ok = blueprint.inputs.iter().all(|input| {
2580            self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
2581        });
2582        let tools_ok = blueprint
2583            .required_tools
2584            .iter()
2585            .all(|tool| self.player_has_craft_tool(&tool.item));
2586        let station_ok = match blueprint.station.as_deref() {
2587            None | Some("hand") => true,
2588            Some(tag) => self.player_at_station_tag(tag),
2589        };
2590        materials_ok && tools_ok && station_ok && self.craft_has_vessel_room_for_output(blueprint)
2591    }
2592
2593    /// Inventory/worn **or** a nearby placed furniture tool in the same space.
2594    pub fn player_has_craft_tool(&self, tool_template: &str) -> bool {
2595        if self.inventory.get(tool_template).copied().unwrap_or(0) >= 1 {
2596            return true;
2597        }
2598        let Some(player) = self.player.as_ref() else {
2599            return false;
2600        };
2601        let px = player.transform.position.x;
2602        let py = player.transform.position.y;
2603        // Match sim `CONTAINER_INTERACTION_RADIUS_M`.
2604        const RANGE: f32 = 3.0;
2605        self.placed_containers.iter().any(|c| {
2606            if c.template_id != tool_template {
2607                return false;
2608            }
2609            if !self.placed_container_in_current_space(c) {
2610                return false;
2611            }
2612            let dx = c.x - px;
2613            let dy = c.y - py;
2614            dx * dx + dy * dy <= RANGE * RANGE
2615        })
2616    }
2617
2618    /// Bulk/liquid craft outputs need an empty (or matching) vessel after inputs drain.
2619    fn craft_output_needs_vessel(&self, blueprint: &BlueprintView) -> bool {
2620        matches!(
2621            self.inventory_item_category(&blueprint.output),
2622            Some("bulk") | Some("liquid")
2623        ) || matches!(
2624            blueprint.output.as_str(),
2625            "dirt" | "mud" | "sand" | "water" | "milk"
2626        )
2627    }
2628
2629    fn craft_output_category(&self, blueprint: &BlueprintView) -> Option<&str> {
2630        self.inventory_item_category(&blueprint.output).or_else(|| {
2631            match blueprint.output.as_str() {
2632                "dirt" | "mud" | "sand" => Some("bulk"),
2633                "water" | "milk" => Some("liquid"),
2634                _ => None,
2635            }
2636        })
2637    }
2638
2639    fn craft_has_vessel_room_for_output(&self, blueprint: &BlueprintView) -> bool {
2640        if !self.craft_output_needs_vessel(blueprint) {
2641            return true;
2642        }
2643        let need = blueprint.output_qty.max(1);
2644        self.vessel_room_after_craft_inputs(blueprint) >= need
2645    }
2646
2647    /// Free capacity in vessels that can hold `blueprint.output` after consuming inputs.
2648    fn vessel_room_after_craft_inputs(&self, blueprint: &BlueprintView) -> u32 {
2649        let mut stacks = self.inventory_stacks.clone();
2650        for worn in self.worn.values() {
2651            stacks.push(worn.clone());
2652        }
2653        for input in &blueprint.inputs {
2654            let mut left = input.quantity;
2655            drain_payload_from_stacks(&mut stacks, &input.template_id, &mut left);
2656            if left > 0 {
2657                return 0;
2658            }
2659        }
2660        vessel_room_for_payload_in_stacks(
2661            &stacks,
2662            &blueprint.output,
2663            self.craft_output_category(blueprint),
2664        )
2665    }
2666
2667    /// Vessel inventory transparency for the craft detail panel.
2668    pub fn craft_vessel_status(&self, blueprint: &BlueprintView) -> CraftVesselStatus {
2669        let output_label = self.blueprint_output_label(blueprint);
2670        let needs_vessel = self.craft_output_needs_vessel(blueprint);
2671        let need_units = if needs_vessel {
2672            blueprint.output_qty.max(1)
2673        } else {
2674            0
2675        };
2676        let free_after_inputs = if needs_vessel {
2677            self.vessel_room_after_craft_inputs(blueprint)
2678        } else {
2679            0
2680        };
2681        let payload_cat = self.craft_output_category(blueprint);
2682        let mut vessels = Vec::new();
2683        Self::collect_craft_vessel_lines(
2684            &self.inventory_stacks,
2685            "pack",
2686            &blueprint.output,
2687            payload_cat,
2688            &mut vessels,
2689        );
2690        for worn in self.worn.values() {
2691            Self::collect_craft_vessel_lines(
2692                std::slice::from_ref(worn),
2693                "worn",
2694                &blueprint.output,
2695                payload_cat,
2696                &mut vessels,
2697            );
2698        }
2699        CraftVesselStatus {
2700            needs_vessel,
2701            output_label,
2702            need_units,
2703            free_after_inputs,
2704            ok: !needs_vessel || free_after_inputs >= need_units,
2705            vessels,
2706        }
2707    }
2708
2709    fn collect_craft_vessel_lines(
2710        stacks: &[flatland_protocol::ItemStack],
2711        location: &'static str,
2712        payload_id: &str,
2713        payload_category: Option<&str>,
2714        out: &mut Vec<CraftVesselLine>,
2715    ) {
2716        for stack in stacks {
2717            if is_serving_vessel_stack(stack) {
2718                let cap = serving_capacity_of(stack);
2719                let used = payload_units_in_vessel(stack);
2720                let free = vessel_free_room_for_payload(stack, payload_id, payload_category);
2721                let holds = stack
2722                    .props
2723                    .get("serving_holds")
2724                    .cloned()
2725                    .unwrap_or_else(|| {
2726                        if stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
2727                            && stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
2728                        {
2729                            "liquid,bulk".into()
2730                        } else if stack.props.get("bulk_vessel").is_some_and(|v| v == "1") {
2731                            "bulk".into()
2732                        } else if stack.props.get("liquid_vessel").is_some_and(|v| v == "1") {
2733                            "liquid".into()
2734                        } else {
2735                            "?".into()
2736                        }
2737                    });
2738                let label = stack
2739                    .display_name
2740                    .clone()
2741                    .unwrap_or_else(|| stack.template_id.clone());
2742                out.push(CraftVesselLine {
2743                    label,
2744                    holds,
2745                    capacity: cap,
2746                    used,
2747                    free,
2748                    quantity: stack.quantity.max(1),
2749                    accepts_output: free > 0,
2750                    location,
2751                });
2752            }
2753            Self::collect_craft_vessel_lines(
2754                &stack.contents,
2755                location,
2756                payload_id,
2757                payload_category,
2758                out,
2759            );
2760        }
2761    }
2762
2763    fn craft_prefs_key(&self) -> String {
2764        if let Some(cid) = self.character_id {
2765            cid.to_string()
2766        } else if self.entity_id != 0 {
2767            format!("entity:{}", self.entity_id)
2768        } else {
2769            String::new()
2770        }
2771    }
2772
2773    pub fn reload_craft_prefs(&mut self) {
2774        let key = self.craft_prefs_key();
2775        self.craft_prefs = crate::craft_prefs::load_for_character(&key);
2776    }
2777
2778    fn persist_craft_prefs(&self) {
2779        crate::craft_prefs::save_for_character(&self.craft_prefs_key(), &self.craft_prefs);
2780    }
2781
2782    /// Distinct craft tiers present in known blueprints (sorted ascending).
2783    pub fn craft_known_tiers(&self) -> Vec<u32> {
2784        let mut tiers: Vec<u32> = self
2785            .blueprints
2786            .iter()
2787            .map(|bp| bp.craft_tier.max(1))
2788            .collect::<std::collections::BTreeSet<_>>()
2789            .into_iter()
2790            .collect();
2791        tiers.sort_unstable();
2792        tiers
2793    }
2794
2795    /// Tab strip order: Ready, ★, Recent, then T1..Tn that have known recipes.
2796    pub fn craft_tab_strip(&self) -> Vec<CraftTab> {
2797        let mut tabs = vec![CraftTab::Ready, CraftTab::Favorites, CraftTab::Recent];
2798        for t in self.craft_known_tiers() {
2799            tabs.push(CraftTab::Tier(t));
2800        }
2801        tabs
2802    }
2803
2804    pub fn craft_set_tab(&mut self, tab: CraftTab) {
2805        self.craft_tab = tab;
2806        self.craft_menu_index = 0;
2807        self.clamp_craft_menu_index();
2808        self.clamp_craft_batch_quantity();
2809    }
2810
2811    pub fn craft_cycle_tab(&mut self, delta: i32) {
2812        let tabs = self.craft_tab_strip();
2813        if tabs.is_empty() {
2814            return;
2815        }
2816        let cur = tabs.iter().position(|t| *t == self.craft_tab).unwrap_or(0) as i32;
2817        let next = (cur + delta).rem_euclid(tabs.len() as i32) as usize;
2818        self.craft_set_tab(tabs[next]);
2819    }
2820
2821    pub fn craft_matches_search(&self, bp: &BlueprintView) -> bool {
2822        let f = self.craft_filter.trim();
2823        if f.is_empty() {
2824            return true;
2825        }
2826        if list_label_matches(&bp.label, f)
2827            || list_label_matches(&bp.output, f)
2828            || list_label_matches(&bp.output_display_name, f)
2829            || bp
2830                .category
2831                .as_deref()
2832                .is_some_and(|c| list_label_matches(c, f))
2833            || bp
2834                .station
2835                .as_deref()
2836                .is_some_and(|s| list_label_matches(s, f))
2837        {
2838            return true;
2839        }
2840        bp.inputs.iter().any(|i| {
2841            list_label_matches(&i.template_id, f) || list_label_matches(&i.display_name, f)
2842        }) || bp
2843            .required_tools
2844            .iter()
2845            .any(|t| list_label_matches(&t.item, f) || list_label_matches(&t.display_name, f))
2846    }
2847
2848    /// True while this blueprint's craft channel is running (Ready-tab pin).
2849    pub fn craft_blueprint_in_channel(&self, blueprint_id: &str) -> bool {
2850        self.craft_channel_blueprint_id.as_deref() == Some(blueprint_id)
2851            && self.active_craft_channel().is_some()
2852    }
2853
2854    /// Blueprint indices for the active tab + search, ready-first then A–Z.
2855    pub fn craft_filtered_indices(&self) -> Vec<usize> {
2856        let mut idxs: Vec<usize> = (0..self.blueprints.len())
2857            .filter(|&i| {
2858                let bp = &self.blueprints[i];
2859                if !self.craft_matches_search(bp) {
2860                    return false;
2861                }
2862                match self.craft_tab {
2863                    CraftTab::Ready => {
2864                        self.can_craft_blueprint(bp) || self.craft_blueprint_in_channel(&bp.id)
2865                    }
2866                    CraftTab::Favorites => self.craft_prefs.is_favorite(&bp.id),
2867                    CraftTab::Recent => self.craft_prefs.recent.iter().any(|id| id == &bp.id),
2868                    CraftTab::Tier(t) => bp.craft_tier.max(1) == t,
2869                }
2870            })
2871            .collect();
2872        match self.craft_tab {
2873            CraftTab::Recent => {
2874                idxs.sort_by_key(|&i| {
2875                    self.craft_prefs
2876                        .recent
2877                        .iter()
2878                        .position(|id| id == &self.blueprints[i].id)
2879                        .unwrap_or(usize::MAX)
2880                });
2881            }
2882            _ => {
2883                idxs.sort_by(|&a, &b| {
2884                    let ba = &self.blueprints[a];
2885                    let bb = &self.blueprints[b];
2886                    let ia = self.craft_blueprint_in_channel(&ba.id);
2887                    let ib = self.craft_blueprint_in_channel(&bb.id);
2888                    // In-progress first, then craftable, then A–Z.
2889                    ib.cmp(&ia)
2890                        .then_with(|| {
2891                            let ra = self.can_craft_blueprint(ba);
2892                            let rb = self.can_craft_blueprint(bb);
2893                            rb.cmp(&ra)
2894                        })
2895                        .then_with(|| {
2896                            ba.label
2897                                .to_ascii_lowercase()
2898                                .cmp(&bb.label.to_ascii_lowercase())
2899                        })
2900                });
2901            }
2902        }
2903        idxs
2904    }
2905
2906    pub fn craft_selected_blueprint(&self) -> Option<&BlueprintView> {
2907        let idxs = self.craft_filtered_indices();
2908        idxs.get(self.craft_menu_index)
2909            .and_then(|&i| self.blueprints.get(i))
2910    }
2911
2912    pub fn clamp_craft_menu_index(&mut self) {
2913        let n = self.craft_filtered_indices().len();
2914        if n == 0 {
2915            self.craft_menu_index = 0;
2916        } else {
2917            self.craft_menu_index = self.craft_menu_index.min(n - 1);
2918        }
2919    }
2920
2921    pub fn craft_is_favorite(&self, blueprint_id: &str) -> bool {
2922        self.craft_prefs.is_favorite(blueprint_id)
2923    }
2924
2925    pub fn craft_toggle_favorite_selected(&mut self) {
2926        let Some(id) = self.craft_selected_blueprint().map(|bp| bp.id.clone()) else {
2927            return;
2928        };
2929        self.craft_prefs.toggle_favorite(&id);
2930        self.persist_craft_prefs();
2931        if matches!(self.craft_tab, CraftTab::Favorites) {
2932            self.clamp_craft_menu_index();
2933        }
2934    }
2935
2936    pub fn craft_record_completed(&mut self, blueprint_id: &str) {
2937        self.craft_prefs.record_crafted(blueprint_id);
2938        self.persist_craft_prefs();
2939    }
2940
2941    pub fn focus_craft_filter(&mut self) {
2942        self.craft_filter_focused = true;
2943    }
2944
2945    pub fn append_craft_filter_char(&mut self, ch: char) {
2946        if !self.craft_filter_focused {
2947            return;
2948        }
2949        if is_list_filter_char(ch) {
2950            self.craft_filter.push(ch);
2951            self.craft_menu_index = 0;
2952            self.clamp_craft_menu_index();
2953        }
2954    }
2955
2956    pub fn craft_filter_backspace(&mut self) {
2957        if !self.craft_filter_focused {
2958            return;
2959        }
2960        self.craft_filter.pop();
2961        self.craft_menu_index = 0;
2962        self.clamp_craft_menu_index();
2963    }
2964
2965    /// Esc while filter focused: clear then blur. Returns true if handled.
2966    pub fn clear_or_blur_craft_filter(&mut self) -> bool {
2967        if self.craft_filter_focused {
2968            if !self.craft_filter.is_empty() {
2969                self.craft_filter.clear();
2970                self.craft_menu_index = 0;
2971                self.clamp_craft_menu_index();
2972            } else {
2973                self.craft_filter_focused = false;
2974            }
2975            return true;
2976        }
2977        if !self.craft_filter.is_empty() {
2978            self.craft_filter.clear();
2979            self.craft_menu_index = 0;
2980            self.clamp_craft_menu_index();
2981            return true;
2982        }
2983        false
2984    }
2985
2986    pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
2987        if !self.can_craft_blueprint(blueprint) {
2988            return 0;
2989        }
2990        let mut limit = u32::MAX;
2991        for input in &blueprint.inputs {
2992            if input.quantity == 0 {
2993                continue;
2994            }
2995            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2996            limit = limit.min(have / input.quantity);
2997        }
2998        for tool in &blueprint.required_tools {
2999            if tool.consumed {
3000                let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
3001                limit = limit.min(have);
3002            }
3003        }
3004        if self.craft_output_needs_vessel(blueprint) {
3005            let need = blueprint.output_qty.max(1);
3006            let room = self.vessel_room_after_craft_inputs(blueprint);
3007            if need > 0 {
3008                limit = limit.min(room / need);
3009            }
3010        }
3011        limit.min(CRAFT_BATCH_SELECT_CAP)
3012    }
3013
3014    /// How many sequential crafts current stamina can finish before a rest.
3015    pub fn craft_stamina_batch_cap(&self) -> u32 {
3016        let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
3017        if CRAFT_STAMINA_COST > 0.0 {
3018            (stamina / CRAFT_STAMINA_COST).floor() as u32
3019        } else {
3020            u32::MAX
3021        }
3022    }
3023
3024    pub fn clamp_craft_batch_quantity(&mut self) {
3025        let Some(bp) = self.craft_selected_blueprint().cloned() else {
3026            self.craft_batch_quantity = 1;
3027            return;
3028        };
3029        let max = self.max_craft_batches(&bp).max(1);
3030        self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
3031    }
3032
3033    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
3034        let Some(bp) = self.craft_selected_blueprint().cloned() else {
3035            return;
3036        };
3037        let max = self.max_craft_batches(&bp).max(1);
3038        let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
3039        self.craft_batch_quantity = next as u32;
3040    }
3041
3042    pub fn craft_batch_set_max(&mut self) {
3043        let Some(bp) = self.craft_selected_blueprint().cloned() else {
3044            return;
3045        };
3046        let max = self.max_craft_batches(&bp);
3047        self.craft_batch_quantity = if max == 0 { 1 } else { max };
3048    }
3049
3050    pub fn craft_batch_set_min(&mut self) {
3051        self.craft_batch_quantity = 1;
3052    }
3053
3054    pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
3055        let preserve_ui = self.show_shop_menu;
3056        let tab = self.shop_tab;
3057        let index = self.shop_menu_index;
3058        let qty = self.shop_quantity;
3059
3060        self.show_shop_menu = true;
3061        self.bank_panel = None;
3062        self.show_craft_menu = false;
3063        self.show_inventory_menu = false;
3064        self.show_stats = false;
3065        if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
3066            self.npc_verb_target = Some(catalog.npc_id.clone());
3067        }
3068        self.shop_catalog = Some(catalog);
3069
3070        if preserve_ui {
3071            self.shop_tab = tab;
3072            self.shop_menu_index = index;
3073            self.shop_quantity = qty;
3074        } else {
3075            self.shop_tab = ShopTab::Buy;
3076            self.shop_menu_index = 0;
3077            self.shop_quantity = 1;
3078            self.clear_shop_trade_log();
3079        }
3080        self.show_npc_verb_menu = false;
3081        self.clamp_shop_selection();
3082    }
3083
3084    pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
3085        let same_teller = self
3086            .bank_panel
3087            .as_ref()
3088            .is_some_and(|p| p.npc_id == panel.npc_id);
3089        self.bank_panel = Some(panel);
3090        self.storage_panel = None;
3091        self.market_panel = None;
3092        self.shop_catalog = None;
3093        self.show_shop_menu = false;
3094        self.show_craft_menu = false;
3095        self.show_inventory_menu = false;
3096        self.show_stats = false;
3097        self.show_npc_verb_menu = false;
3098        self.show_npc_chat = false;
3099        self.npc_chat = None;
3100        if !same_teller {
3101            self.bank_menu_index = 0;
3102            self.bank_ui_mode = BankUiMode::Menu;
3103        }
3104        if let Some(panel) = &self.bank_panel {
3105            if self.npc_verb_target.is_none() {
3106                self.npc_verb_target = Some(panel.npc_id.clone());
3107            }
3108        }
3109    }
3110
3111    pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
3112        let same_manager = self
3113            .storage_panel
3114            .as_ref()
3115            .is_some_and(|p| p.npc_id == panel.npc_id);
3116        self.storage_panel = Some(panel);
3117        self.bank_panel = None;
3118        self.market_panel = None;
3119        self.bank_ui_mode = BankUiMode::Menu;
3120        self.shop_catalog = None;
3121        self.show_shop_menu = false;
3122        self.show_craft_menu = false;
3123        self.show_inventory_menu = false;
3124        self.show_stats = false;
3125        self.show_npc_verb_menu = false;
3126        self.show_npc_chat = false;
3127        self.npc_chat = None;
3128        if !same_manager {
3129            self.storage_menu_index = 0;
3130            self.storage_ui_mode = StorageUiMode::Menu;
3131        } else {
3132            self.clamp_storage_pick_index();
3133        }
3134        if let Some(panel) = &self.storage_panel {
3135            if self.npc_verb_target.is_none() {
3136                self.npc_verb_target = Some(panel.npc_id.clone());
3137            }
3138        }
3139    }
3140
3141    pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
3142        for vault in &panel.list_vaults {
3143            self.merge_stack_catalog_hints(&vault.contents);
3144        }
3145        self.market_panel = Some(panel);
3146        self.bank_panel = None;
3147        self.storage_panel = None;
3148        self.shop_catalog = None;
3149        self.show_shop_menu = false;
3150        self.show_craft_menu = false;
3151        self.show_inventory_menu = false;
3152        self.show_stats = false;
3153        self.show_npc_verb_menu = false;
3154        self.show_npc_chat = false;
3155        self.npc_chat = None;
3156        self.market_menu_index = 0;
3157        self.market_buy_confirm = None;
3158        self.market_ui_mode = MarketUiMode::Browse;
3159        self.market_filter.clear();
3160        self.market_filter_focused = false;
3161        self.market_category_filter = None;
3162        if let Some(panel) = &self.market_panel {
3163            if self.npc_verb_target.is_none() {
3164                self.npc_verb_target = Some(panel.npc_id.clone());
3165            }
3166        }
3167    }
3168
3169    pub fn clear_market_panel(&mut self) {
3170        self.market_panel = None;
3171        self.market_menu_index = 0;
3172        self.market_buy_confirm = None;
3173        self.market_ui_mode = MarketUiMode::Browse;
3174        self.market_filter.clear();
3175        self.market_filter_focused = false;
3176        self.market_category_filter = None;
3177    }
3178
3179    pub fn clear_bank_panel(&mut self) {
3180        self.bank_panel = None;
3181        self.bank_menu_index = 0;
3182        self.bank_ui_mode = BankUiMode::Menu;
3183    }
3184
3185    pub fn clear_storage_panel(&mut self) {
3186        self.storage_panel = None;
3187        self.storage_menu_index = 0;
3188        self.storage_ui_mode = StorageUiMode::Menu;
3189    }
3190
3191    fn clamp_storage_pick_index(&mut self) {
3192        match &self.storage_ui_mode {
3193            StorageUiMode::StorePick { index } => {
3194                let n = self.storage_store_options().len();
3195                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3196                self.storage_ui_mode = StorageUiMode::StorePick { index: next };
3197            }
3198            StorageUiMode::TakePick { index } => {
3199                let n = self.storage_vault_options().len();
3200                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3201                self.storage_ui_mode = StorageUiMode::TakePick { index: next };
3202            }
3203            StorageUiMode::ShipPick {
3204                dest_building_id,
3205                dest_label,
3206                index,
3207            } => {
3208                let n = self.storage_vault_options().len();
3209                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3210                self.storage_ui_mode = StorageUiMode::ShipPick {
3211                    dest_building_id: dest_building_id.clone(),
3212                    dest_label: dest_label.clone(),
3213                    index: next,
3214                };
3215            }
3216            StorageUiMode::Menu
3217            | StorageUiMode::StoreAmount { .. }
3218            | StorageUiMode::TakeAmount { .. }
3219            | StorageUiMode::ShipAmount { .. } => {}
3220        }
3221    }
3222
3223    pub fn shop_list_len(&self) -> usize {
3224        let Some(catalog) = &self.shop_catalog else {
3225            return 0;
3226        };
3227        match self.shop_tab {
3228            ShopTab::Buy => catalog.sells.len(),
3229            ShopTab::Sell => catalog.buys.len(),
3230        }
3231    }
3232
3233    pub fn shop_menu_move(&mut self, delta: i32) {
3234        let n = self.shop_list_len();
3235        if n == 0 {
3236            return;
3237        }
3238        let idx = self.shop_menu_index as i32;
3239        let next = (idx + delta).rem_euclid(n as i32);
3240        self.shop_menu_index = next as usize;
3241        self.clamp_shop_quantity();
3242    }
3243
3244    pub fn shop_quantity_adjust(&mut self, delta: i32) {
3245        let max = self.shop_quantity_max();
3246        if max == 0 {
3247            self.shop_quantity = 0;
3248            return;
3249        }
3250        let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
3251        self.shop_quantity = next as u32;
3252    }
3253
3254    pub(crate) fn clamp_shop_selection(&mut self) {
3255        let n = self.shop_list_len();
3256        if n == 0 {
3257            self.shop_menu_index = 0;
3258        } else {
3259            self.shop_menu_index = self.shop_menu_index.min(n - 1);
3260        }
3261        self.clamp_shop_quantity();
3262    }
3263
3264    fn shop_quantity_max(&self) -> u32 {
3265        let Some(catalog) = &self.shop_catalog else {
3266            return 1;
3267        };
3268        match self.shop_tab {
3269            ShopTab::Buy => {
3270                if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
3271                    if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
3272                        return 1;
3273                    }
3274                }
3275                99
3276            }
3277            ShopTab::Sell => catalog
3278                .buys
3279                .get(self.shop_menu_index)
3280                .map(|l| l.quantity)
3281                .unwrap_or(0),
3282        }
3283    }
3284
3285    pub fn shop_quantity_set_max(&mut self) {
3286        self.shop_quantity = self.shop_quantity_max();
3287    }
3288
3289    pub fn shop_quantity_set_min(&mut self) {
3290        let max = self.shop_quantity_max();
3291        self.shop_quantity = if max == 0 { 0 } else { 1 };
3292    }
3293
3294    fn clamp_shop_quantity(&mut self) {
3295        let max = self.shop_quantity_max();
3296        if max == 0 {
3297            self.shop_quantity = 0;
3298        } else {
3299            self.shop_quantity = self.shop_quantity.max(1).min(max);
3300        }
3301    }
3302
3303    pub fn player_at_station_tag(&self, tag: &str) -> bool {
3304        let Some(id) = self.effective_inside_building() else {
3305            return false;
3306        };
3307        self.buildings
3308            .iter()
3309            .find(|b| b.id == id)
3310            .is_some_and(|b| b.tags.iter().any(|t| t == tag))
3311    }
3312
3313    /// Short hint for UI when a recipe cannot be started.
3314    pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
3315        if self.can_craft_blueprint(blueprint) {
3316            return None;
3317        }
3318        let mut missing = Vec::new();
3319        for input in &blueprint.inputs {
3320            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
3321            if have < input.quantity {
3322                let name = self.blueprint_ingredient_label(input);
3323                let vessel_note = if self.inventory_item_category(&input.template_id)
3324                    == Some("liquid")
3325                    || matches!(input.template_id.as_str(), "water" | "milk")
3326                {
3327                    "; fill a bottle/waterskin"
3328                } else if self.inventory_item_category(&input.template_id) == Some("bulk")
3329                    || matches!(input.template_id.as_str(), "dirt" | "mud" | "sand")
3330                {
3331                    "; scoop into a sack/bucket"
3332                } else {
3333                    ""
3334                };
3335                missing.push(format!(
3336                    "{}×{} (have {have}{vessel_note})",
3337                    input.quantity, name
3338                ));
3339            }
3340        }
3341        for tool in &blueprint.required_tools {
3342            if !self.player_has_craft_tool(&tool.item) {
3343                missing.push(format!("tool: {}", self.blueprint_tool_label(tool)));
3344            }
3345        }
3346        if let Some(station) = blueprint.station.as_deref() {
3347            if station != "hand" && !self.player_at_station_tag(station) {
3348                missing.push(format!("station: {station} (enter building)"));
3349            }
3350        }
3351        if self.craft_output_needs_vessel(blueprint)
3352            && !self.craft_has_vessel_room_for_output(blueprint)
3353        {
3354            let name = self
3355                .inventory_hints
3356                .get(&blueprint.output)
3357                .map(|h| h.display_name.as_str())
3358                .unwrap_or(blueprint.output.as_str());
3359            let need = blueprint.output_qty.max(1);
3360            let free = self.vessel_room_after_craft_inputs(blueprint);
3361            let accepting = self
3362                .craft_vessel_status(blueprint)
3363                .vessels
3364                .iter()
3365                .filter(|v| v.accepts_output)
3366                .count();
3367            missing.push(format!(
3368                "vessel room for {name}: need {need} free after inputs, have {free} ({accepting} accepting vessel(s))"
3369            ));
3370        }
3371        if missing.is_empty() {
3372            None
3373        } else {
3374            Some(missing.join(", "))
3375        }
3376    }
3377
3378    /// In-progress inventory craft (n-menu), if any.
3379    pub fn active_craft_channel(&self) -> Option<&flatland_protocol::TimedChannelHud> {
3380        self.timed_channel
3381            .as_ref()
3382            .filter(|c| c.channel == flatland_protocol::TimedChannelKind::Craft)
3383    }
3384
3385    pub fn player_entity(&self) -> Option<&EntityState> {
3386        self.player
3387            .as_ref()
3388            .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
3389    }
3390
3391    /// Apply persisted HUD / workers UI prefs from `client.json`.
3392    pub fn apply_client_ui_prefs(&mut self) {
3393        let cfg = crate::client_config::ClientConfig::load();
3394        if let Some(hidden) = cfg.hud_log_hidden {
3395            self.hud_log_hidden = hidden;
3396        }
3397        if let Some(compact) = cfg.workers_menu_compact {
3398            self.workers_menu_compact = compact;
3399        }
3400    }
3401
3402    pub fn player_position(&self) -> (f32, f32) {
3403        let (x, y, _) = self.player_position_with_z();
3404        (x, y)
3405    }
3406
3407    pub fn player_position_with_z(&self) -> (f32, f32, f32) {
3408        if let Some(p) = self.player_entity() {
3409            (
3410                p.transform.position.x,
3411                p.transform.position.y,
3412                p.transform.position.z,
3413            )
3414        } else {
3415            (0.0, 0.0, 0.0)
3416        }
3417    }
3418
3419    pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
3420        let mut rows: Vec<(String, u32, String)> = self
3421            .inventory
3422            .iter()
3423            .filter(|(_, q)| **q > 0)
3424            .map(|(id, qty)| {
3425                let label = self
3426                    .inventory_hints
3427                    .get(id)
3428                    .map(|h| h.display_name.clone())
3429                    .unwrap_or_else(|| id.clone());
3430                (id.clone(), *qty, label)
3431            })
3432            .collect();
3433        rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
3434        rows
3435    }
3436
3437    pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
3438        self.inventory_hints
3439            .get(template_id)
3440            .map(|h| h.category.as_str())
3441            .filter(|c| !c.is_empty())
3442    }
3443
3444    pub fn stack_is_serving(stack: &flatland_protocol::ItemStack) -> bool {
3445        stack.props.get("serving").is_some_and(|v| v == "1") || Self::stack_is_liquid_vessel(stack)
3446    }
3447
3448    pub fn stack_is_liquid_vessel(stack: &flatland_protocol::ItemStack) -> bool {
3449        stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
3450            || stack
3451                .props
3452                .get("serving_holds")
3453                .is_some_and(|v| v.split(',').any(|p| p.trim() == "liquid"))
3454    }
3455
3456    pub fn stack_is_food_serving(stack: &flatland_protocol::ItemStack) -> bool {
3457        stack
3458            .props
3459            .get("serving_holds")
3460            .is_some_and(|v| v.split(',').any(|p| p.trim() == "food"))
3461    }
3462
3463    pub fn stack_is_bulk_vessel(stack: &flatland_protocol::ItemStack) -> bool {
3464        stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
3465            || stack
3466                .props
3467                .get("serving_holds")
3468                .is_some_and(|v| v.split(',').any(|p| p.trim() == "bulk"))
3469    }
3470
3471    pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
3472        stack
3473            .props
3474            .get("grants_item_status_effect")
3475            .map(|s| !s.is_empty())
3476            .unwrap_or(false)
3477    }
3478
3479    pub fn stack_is_blueprint_scroll(stack: &flatland_protocol::ItemStack) -> bool {
3480        stack
3481            .props
3482            .get("teaches_blueprint")
3483            .map(|s| !s.trim().is_empty())
3484            .unwrap_or(false)
3485    }
3486
3487    pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
3488        stack
3489            .props
3490            .get("grants_item_status_effect")
3491            .map(String::as_str)
3492            .filter(|s| !s.is_empty())
3493    }
3494
3495    pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
3496        stack
3497            .props
3498            .get("grants_item_status_mode")
3499            .map(String::as_str)
3500            .unwrap_or("on_hit")
3501    }
3502
3503    /// Candidate gear for a grant consumable (inventory + worn).
3504    pub fn grant_target_options(
3505        &self,
3506        grant: &flatland_protocol::ItemStack,
3507    ) -> Vec<GrantTargetOption> {
3508        let mode = Self::grant_mode(grant);
3509        let grant_tags: Vec<&str> = grant
3510            .props
3511            .get("grants_item_status_tags")
3512            .map(|s| {
3513                s.split(',')
3514                    .map(str::trim)
3515                    .filter(|t| !t.is_empty())
3516                    .collect()
3517            })
3518            .unwrap_or_default();
3519        let grant_id = grant.item_instance_id;
3520        let mut out = Vec::new();
3521        let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
3522            let Some(iid) = stack.item_instance_id else {
3523                return;
3524            };
3525            if Some(iid) == grant_id {
3526                return;
3527            }
3528            if stack.props.get("enchantable").map(String::as_str) == Some("0") {
3529                return;
3530            }
3531            if !grant_target_matches_mode(stack, mode) {
3532                return;
3533            }
3534            if !grant_tags_match(stack, &grant_tags) {
3535                return;
3536            }
3537            let name = stack
3538                .display_name
3539                .clone()
3540                .unwrap_or_else(|| stack.template_id.clone());
3541            let bindings = if stack.status_bindings.is_empty() {
3542                String::new()
3543            } else {
3544                format!(
3545                    " · {}",
3546                    stack
3547                        .status_bindings
3548                        .iter()
3549                        .map(|b| b.effect_id.as_str())
3550                        .collect::<Vec<_>>()
3551                        .join(", ")
3552                )
3553            };
3554            out.push(GrantTargetOption {
3555                label: format!("{where_label}: {name}{bindings}"),
3556                target_instance_id: iid,
3557            });
3558        };
3559        fn walk(
3560            stacks: &[flatland_protocol::ItemStack],
3561            where_label: &str,
3562            push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
3563        ) {
3564            for s in stacks {
3565                push(s, where_label);
3566                if !s.contents.is_empty() {
3567                    let nested = format!(
3568                        "{where_label}/{}",
3569                        s.display_name.as_deref().unwrap_or(s.template_id.as_str())
3570                    );
3571                    walk(&s.contents, &nested, push);
3572                }
3573            }
3574        }
3575        walk(&self.inventory_stacks, "Bag", &mut push);
3576        for (slot, stack) in &self.worn {
3577            push(stack, body_slot_label(*slot));
3578            let nest = format!(
3579                "{}/{}",
3580                body_slot_label(*slot),
3581                stack
3582                    .display_name
3583                    .as_deref()
3584                    .unwrap_or(stack.template_id.as_str())
3585            );
3586            walk(&stack.contents, &nest, &mut push);
3587        }
3588        out
3589    }
3590
3591    pub fn item_base_mass(&self, template_id: &str) -> f32 {
3592        self.inventory_hints
3593            .get(template_id)
3594            .and_then(|h| h.base_mass)
3595            .unwrap_or(0.5)
3596    }
3597
3598    pub fn item_base_volume(&self, template_id: &str) -> f32 {
3599        self.inventory_hints
3600            .get(template_id)
3601            .and_then(|h| h.base_volume)
3602            .unwrap_or(1.0)
3603    }
3604
3605    pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
3606        let unit = stack
3607            .base_mass
3608            .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
3609        unit * stack.quantity as f32
3610    }
3611
3612    fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
3613        let unit = stack.base_volume.unwrap_or(1.0);
3614        unit * stack.quantity as f32
3615            + stack
3616                .contents
3617                .iter()
3618                .map(Self::stack_tree_volume)
3619                .sum::<f32>()
3620    }
3621
3622    fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
3623        contents.iter().map(Self::stack_tree_volume).sum()
3624    }
3625
3626    fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
3627        self.inventory_hints
3628            .get(template_id)
3629            .and_then(|h| h.capacity_volume)
3630            .filter(|c| *c > 0.0)
3631    }
3632
3633    fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
3634        stack
3635            .capacity_volume
3636            .filter(|c| *c > 0.0)
3637            .or_else(|| self.template_capacity_volume(&stack.template_id))
3638    }
3639
3640    /// Volume used / capacity / free space label for storage containers in the inventory UI.
3641    pub fn container_volume_label(&self, row: &InventoryRow) -> String {
3642        let Some((used, cap)) = self.container_volume_stats(row) else {
3643            return String::new();
3644        };
3645        format!("  {}", format_container_volume_usage(used, cap))
3646    }
3647
3648    fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
3649        if row.is_chest_shell {
3650            let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
3651                return None;
3652            };
3653            let chest = self
3654                .placed_containers
3655                .iter()
3656                .find(|c| c.id == *container_id)?;
3657            let cap = self
3658                .stack_capacity_volume(&row.stack)
3659                .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
3660            let used = if chest.accessible {
3661                Self::contents_used_volume(&chest.contents)
3662            } else {
3663                0.0
3664            };
3665            return Some((used, cap));
3666        }
3667
3668        let cap = self.stack_capacity_volume(&row.stack)?;
3669        let used = Self::contents_used_volume(&row.stack.contents);
3670        Some((used, cap))
3671    }
3672
3673    fn destination_volume_stats(
3674        &self,
3675        location: &flatland_protocol::InventoryLocation,
3676        parent_instance_id: Option<uuid::Uuid>,
3677    ) -> Option<(f32, f32)> {
3678        let parent = self.container_stack_for(location, parent_instance_id)?;
3679        let mut cap = self.stack_capacity_volume(&parent);
3680        if cap.is_none() {
3681            if let flatland_protocol::InventoryLocation::Placed { container_id } = location {
3682                if let Some(chest) = self
3683                    .placed_containers
3684                    .iter()
3685                    .find(|c| c.id == *container_id)
3686                {
3687                    let looking_at_shell =
3688                        parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id));
3689                    if looking_at_shell {
3690                        cap = chest.capacity_volume.filter(|v| *v > 0.0);
3691                    }
3692                }
3693            }
3694        }
3695        let cap = cap?;
3696        let used = Self::contents_used_volume(&parent.contents).max(0.0);
3697        Some((used, cap))
3698    }
3699
3700    pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
3701        if row.is_chest_shell {
3702            return true;
3703        }
3704        if row.is_equip_shell {
3705            return self.inventory_item_category(&row.stack.template_id) == Some("container");
3706        }
3707        self.inventory_item_category(&row.stack.template_id) == Some("container")
3708            || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
3709    }
3710
3711    fn container_stack_for(
3712        &self,
3713        location: &flatland_protocol::InventoryLocation,
3714        parent_instance_id: Option<uuid::Uuid>,
3715    ) -> Option<flatland_protocol::ItemStack> {
3716        match location {
3717            flatland_protocol::InventoryLocation::Root => {
3718                let pid = parent_instance_id?;
3719                self.find_stack_by_instance(&self.inventory_stacks, pid)
3720            }
3721            flatland_protocol::InventoryLocation::Worn { slot } => {
3722                let worn = self.worn.get(slot)?;
3723                if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
3724                    Some(worn.clone())
3725                } else {
3726                    self.find_stack_by_instance(&worn.contents, parent_instance_id?)
3727                }
3728            }
3729            flatland_protocol::InventoryLocation::Placed { container_id } => {
3730                let chest = self
3731                    .placed_containers
3732                    .iter()
3733                    .find(|c| c.id == *container_id)?;
3734                if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
3735                    Some(flatland_protocol::ItemStack {
3736                        template_id: chest.template_id.clone(),
3737                        quantity: 1,
3738                        item_instance_id: chest.item_instance_id,
3739                        props: Default::default(),
3740                        status_bindings: Vec::new(),
3741                        contents: chest.contents.clone(),
3742                        display_name: Some(chest.display_name.clone()),
3743                        category: Some("container".into()),
3744                        capacity_volume: self
3745                            .inventory_hints
3746                            .get(&chest.template_id)
3747                            .and_then(|h| h.capacity_volume),
3748                        worker_lodging_capacity: chest.worker_lodging_capacity,
3749                        ..Default::default()
3750                    })
3751                } else {
3752                    self.find_stack_by_instance(&chest.contents, parent_instance_id?)
3753                }
3754            }
3755            flatland_protocol::InventoryLocation::Keychain => None,
3756            flatland_protocol::InventoryLocation::WhisperPouch => None,
3757        }
3758    }
3759
3760    fn find_stack_by_instance(
3761        &self,
3762        stacks: &[flatland_protocol::ItemStack],
3763        instance_id: uuid::Uuid,
3764    ) -> Option<flatland_protocol::ItemStack> {
3765        for stack in stacks {
3766            if stack.item_instance_id == Some(instance_id) {
3767                return Some(stack.clone());
3768            }
3769            if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
3770                return Some(found);
3771            }
3772        }
3773        None
3774    }
3775
3776    /// Client-side estimate of how many units can move to `to` (server clamps authoritatively).
3777    pub fn max_movable_to(
3778        &self,
3779        template_id: &str,
3780        stack_qty: u32,
3781        from: &flatland_protocol::InventoryLocation,
3782        to: &flatland_protocol::InventoryLocation,
3783        parent_instance_id: Option<uuid::Uuid>,
3784    ) -> u32 {
3785        let unit_vol = self.item_base_volume(template_id);
3786        let mut limit = stack_qty;
3787
3788        if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
3789            let cap = parent
3790                .capacity_volume
3791                .or_else(|| {
3792                    self.inventory_hints
3793                        .get(&parent.template_id)
3794                        .and_then(|h| h.capacity_volume)
3795                })
3796                .unwrap_or(0.0);
3797            if cap > 0.0 && unit_vol > 0.0 {
3798                let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
3799                limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
3800            }
3801        }
3802
3803        let _ = from;
3804        limit.max(0).min(stack_qty)
3805    }
3806
3807    pub fn move_picker_max_at_selection(&self) -> u32 {
3808        let Some(picker) = &self.move_picker else {
3809            return 1;
3810        };
3811        let Some(opt) = picker.options.get(self.move_picker_index) else {
3812            return picker.stack_quantity;
3813        };
3814        match &opt.kind {
3815            MoveOptionKind::Cancel
3816            | MoveOptionKind::Drop
3817            | MoveOptionKind::Use
3818            | MoveOptionKind::GrantApply
3819            | MoveOptionKind::SellPlotToCrown { .. }
3820            | MoveOptionKind::PickupPlaced { .. }
3821            | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
3822            MoveOptionKind::Move {
3823                location,
3824                parent_instance_id,
3825            } => self.max_movable_to(
3826                &picker.template_id,
3827                picker.stack_quantity,
3828                &picker.from,
3829                location,
3830                *parent_instance_id,
3831            ),
3832        }
3833    }
3834
3835    pub fn clamp_move_picker_quantity(&mut self) {
3836        let max = self.move_picker_max_at_selection();
3837        if let Some(picker) = &mut self.move_picker {
3838            if max == 0 {
3839                picker.quantity = 1;
3840            } else {
3841                picker.quantity = picker.quantity.clamp(1, max);
3842            }
3843        }
3844    }
3845
3846    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
3847        let max = self.move_picker_max_at_selection().max(1);
3848        if let Some(picker) = &mut self.move_picker {
3849            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3850            picker.quantity = next as u32;
3851        }
3852    }
3853
3854    pub fn move_picker_set_quantity_max(&mut self) {
3855        let max = self.move_picker_max_at_selection();
3856        if let Some(picker) = &mut self.move_picker {
3857            picker.quantity = if max == 0 {
3858                1
3859            } else {
3860                max.min(picker.stack_quantity)
3861            };
3862        }
3863    }
3864
3865    pub fn move_picker_set_quantity_min(&mut self) {
3866        if let Some(picker) = &mut self.move_picker {
3867            picker.quantity = 1;
3868        }
3869    }
3870
3871    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
3872        if let Some(picker) = &mut self.destroy_picker {
3873            let max = picker.stack_quantity.max(1);
3874            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3875            picker.quantity = next as u32;
3876        }
3877    }
3878
3879    pub fn destroy_picker_set_quantity_max(&mut self) {
3880        if let Some(picker) = &mut self.destroy_picker {
3881            picker.quantity = picker.stack_quantity.max(1);
3882        }
3883    }
3884
3885    pub fn destroy_picker_set_quantity_min(&mut self) {
3886        if let Some(picker) = &mut self.destroy_picker {
3887            picker.quantity = 1;
3888        }
3889    }
3890
3891    pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3892        let have = self.inventory.get(template_id).copied().unwrap_or(0);
3893        (have, have >= need)
3894    }
3895
3896    /// Have/need using plot-build storage pool (town vault / nearby chest + inventory).
3897    pub fn plot_build_stock_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3898        let have = self
3899            .plot_build_offer
3900            .as_ref()
3901            .and_then(|o| {
3902                o.available
3903                    .iter()
3904                    .find(|s| s.template_id == template_id)
3905                    .map(|s| s.quantity)
3906            })
3907            .unwrap_or_else(|| self.inventory.get(template_id).copied().unwrap_or(0));
3908        (have, have >= need)
3909    }
3910
3911    pub fn plot_build_wall_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3912        self.building_materials
3913            .iter()
3914            .filter(|m| m.can_wall)
3915            .collect()
3916    }
3917
3918    pub fn plot_build_roof_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3919        self.building_materials
3920            .iter()
3921            .filter(|m| m.can_roof)
3922            .collect()
3923    }
3924
3925    pub fn plot_build_selected_wall(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3926        self.plot_build_wall_options()
3927            .get(self.plot_build_wall_index)
3928            .copied()
3929    }
3930
3931    pub fn plot_build_selected_roof(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3932        self.plot_build_roof_options()
3933            .get(self.plot_build_roof_index)
3934            .copied()
3935    }
3936
3937    /// BOM lines for the current wall/roof selection and tilled pad area.
3938    pub fn plot_build_bom_lines(&self) -> Vec<(String, String, u32)> {
3939        let Some(wall) = self.plot_build_selected_wall() else {
3940            return Vec::new();
3941        };
3942        let Some(roof) = self.plot_build_selected_roof() else {
3943            return Vec::new();
3944        };
3945        let area = self
3946            .plot_build_offer
3947            .as_ref()
3948            .filter(|o| o.pad_ok)
3949            .map(|o| o.pad_width_m * o.pad_depth_m)
3950            .unwrap_or(0.0);
3951        if area <= 0.0 {
3952            return Vec::new();
3953        }
3954        let mut map: std::collections::HashMap<String, (String, u32)> =
3955            std::collections::HashMap::new();
3956        for line in &wall.wall_bom {
3957            let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3958            if qty == 0 {
3959                continue;
3960            }
3961            let name = if line.display_name.is_empty() {
3962                line.template_id.clone()
3963            } else {
3964                line.display_name.clone()
3965            };
3966            let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3967            entry.1 = entry.1.saturating_add(qty);
3968        }
3969        for line in &roof.roof_bom {
3970            let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3971            if qty == 0 {
3972                continue;
3973            }
3974            let name = if line.display_name.is_empty() {
3975                line.template_id.clone()
3976            } else {
3977                line.display_name.clone()
3978            };
3979            let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3980            entry.1 = entry.1.saturating_add(qty);
3981        }
3982        let mut out: Vec<_> = map
3983            .into_iter()
3984            .map(|(id, (name, qty))| (id, name, qty))
3985            .collect();
3986        out.sort_by(|a, b| a.0.cmp(&b.0));
3987        out
3988    }
3989
3990    pub fn plot_build_duration_secs(&self) -> Option<f32> {
3991        let wall = self.plot_build_selected_wall()?;
3992        let roof = self.plot_build_selected_roof()?;
3993        let offer = self.plot_build_offer.as_ref()?;
3994        if !offer.pad_ok {
3995            return None;
3996        }
3997        let area = offer.pad_width_m * offer.pad_depth_m;
3998        let mult = wall.tick_mult.max(roof.tick_mult).max(0.1);
3999        let ticks = (offer.base_ticks as f32 + area * offer.tick_per_m2 as f32 * mult).ceil();
4000        Some(ticks.max(2.0) / 30.0)
4001    }
4002
4003    pub fn plot_build_can_afford(&self) -> bool {
4004        if self.plot_build_offer.as_ref().is_none_or(|o| !o.pad_ok) {
4005            return false;
4006        }
4007        self.plot_build_bom_lines()
4008            .iter()
4009            .all(|(id, _, need)| self.plot_build_stock_status(id, *need).1)
4010    }
4011
4012    pub fn currency_display(&self) -> String {
4013        crate::currency::currency_line(&self.inventory)
4014    }
4015
4016    /// True when standing in a shallow-water terrain zone from the segment snapshot.
4017    pub fn in_shallow_water(&self) -> bool {
4018        let (px, py) = self.player_position();
4019        self.terrain_at(px, py)
4020            .is_some_and(|k| k == TerrainKindView::ShallowWater)
4021    }
4022
4023    /// On or orthogonally next to fillable water, or next to a well-tagged building.
4024    pub fn near_liquid_fill_source(&self) -> bool {
4025        let (px, py) = self.player_position();
4026        const CELL: f32 = 1.0;
4027        let offsets = [
4028            (0.0, 0.0),
4029            (CELL, 0.0),
4030            (-CELL, 0.0),
4031            (0.0, CELL),
4032            (0.0, -CELL),
4033        ];
4034        for (dx, dy) in offsets {
4035            if matches!(
4036                self.terrain_at(px + dx, py + dy),
4037                Some(TerrainKindView::ShallowWater | TerrainKindView::DeepWater)
4038            ) {
4039                return true;
4040            }
4041        }
4042        self.buildings.iter().any(|b| {
4043            if !b.tags.iter().any(|t| t == "well") {
4044                return false;
4045            }
4046            let hw = b.width_m * 0.5;
4047            let hd = b.depth_m * 0.5;
4048            let nx = px.clamp(b.x - hw, b.x + hw);
4049            let ny = py.clamp(b.y - hd, b.y + hd);
4050            let dx = px - nx;
4051            let dy = py - ny;
4052            dx * dx + dy * dy <= INTERACTION_RADIUS_M * INTERACTION_RADIUS_M
4053        })
4054    }
4055
4056    pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
4057        self.terrain_zone_at(x, y).map(|z| z.kind)
4058    }
4059
4060    /// First terrain zone containing `(x, y)` — highest `z_order` wins.
4061    pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
4062        use std::cell::RefCell;
4063
4064        const CHUNK: i32 = 8;
4065        thread_local! {
4066            static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
4067                RefCell::new(None);
4068        }
4069
4070        let zones = &self.terrain_zones;
4071        if zones.is_empty() {
4072            return None;
4073        }
4074        if zones.len() <= 48 {
4075            return zones
4076                .iter()
4077                .enumerate()
4078                .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
4079                .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
4080                .map(|(_, z)| z);
4081        }
4082
4083        let ptr = zones.as_ptr();
4084        let len = zones.len();
4085        INDEX.with(|cell| {
4086            let mut slot = cell.borrow_mut();
4087            let stale = match slot.as_ref() {
4088                Some((p, l, _)) => *p != ptr || *l != len,
4089                None => true,
4090            };
4091            if stale {
4092                let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
4093                    std::collections::HashMap::new();
4094                for (zi, z) in zones.iter().enumerate() {
4095                    let x0 = z.x0.min(z.x1).floor() as i32;
4096                    let y0 = z.y0.min(z.y1).floor() as i32;
4097                    let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
4098                    let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
4099                    let cx0 = x0.div_euclid(CHUNK);
4100                    let cy0 = y0.div_euclid(CHUNK);
4101                    let cx1 = x1.div_euclid(CHUNK);
4102                    let cy1 = y1.div_euclid(CHUNK);
4103                    for cy in cy0..=cy1 {
4104                        for cx in cx0..=cx1 {
4105                            chunks.entry((cx, cy)).or_default().push(zi);
4106                        }
4107                    }
4108                }
4109                *slot = Some((ptr, len, chunks));
4110            }
4111            let chunks = &slot.as_ref().expect("index").2;
4112            let cx = (x.floor() as i32).div_euclid(CHUNK);
4113            let cy = (y.floor() as i32).div_euclid(CHUNK);
4114            let mut best: Option<(usize, &TerrainZoneView)> = None;
4115            if let Some(list) = chunks.get(&(cx, cy)) {
4116                for &zi in list {
4117                    let Some(z) = zones.get(zi) else { continue };
4118                    if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
4119                        continue;
4120                    }
4121                    best = match best {
4122                        None => Some((zi, z)),
4123                        Some((bi, bz)) => {
4124                            if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
4125                                Some((zi, z))
4126                            } else {
4127                                Some((bi, bz))
4128                            }
4129                        }
4130                    };
4131                }
4132            }
4133            best.map(|(_, z)| z)
4134        })
4135    }
4136
4137    /// Ground elevation from terrain zones (m).
4138    pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
4139        self.terrain_zone_at(x, y)
4140            .map(|z| z.elevation)
4141            .unwrap_or(0.0)
4142    }
4143
4144    /// Walkable z levels at a map column (terrain + platforms).
4145    pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
4146        const TOL: f32 = 0.35;
4147        let mut levels = vec![self.elevation_at(x, y)];
4148        for p in &self.z_platforms {
4149            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
4150                levels.push(p.z);
4151            }
4152        }
4153        levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
4154        levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
4155        levels
4156    }
4157
4158    pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
4159        const TOL: f32 = 0.35;
4160        self.walkable_levels_at(x, y)
4161            .iter()
4162            .any(|&l| (l - z).abs() <= TOL)
4163    }
4164
4165    pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
4166        let mut top = self.elevation_at(x, y);
4167        for p in &self.z_platforms {
4168            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
4169                top = top.max(p.z);
4170            }
4171        }
4172        top
4173    }
4174
4175    /// Authoritative interior context from the server (`inside_building` flag).
4176    pub fn effective_inside_building(&self) -> Option<String> {
4177        self.player_entity().and_then(|p| p.inside_building.clone())
4178    }
4179
4180    /// True when a placed chest shares the player's outdoor/interior space.
4181    /// Owned indoor chests may still arrive in AOI while outdoors (worker route
4182    /// labels); they must not be drawn or treated as nearby world props.
4183    pub fn placed_container_in_current_space(
4184        &self,
4185        c: &flatland_protocol::PlacedContainerView,
4186    ) -> bool {
4187        match (
4188            self.effective_inside_building().as_deref(),
4189            c.building_id.as_deref(),
4190        ) {
4191            (None, None) => true,
4192            (Some(a), Some(b)) => a == b,
4193            _ => false,
4194        }
4195    }
4196
4197    fn merge_stack_catalog_hints(&mut self, stacks: &[flatland_protocol::ItemStack]) {
4198        fn walk(
4199            stacks: &[flatland_protocol::ItemStack],
4200            hints: &mut std::collections::HashMap<String, InventoryHint>,
4201        ) {
4202            for stack in stacks {
4203                if stack.display_name.is_some()
4204                    || stack.category.is_some()
4205                    || stack.base_mass.is_some()
4206                    || stack.base_volume.is_some()
4207                    || stack.base_value_copper.is_some()
4208                {
4209                    hints.insert(
4210                        stack.template_id.clone(),
4211                        InventoryHint {
4212                            display_name: stack
4213                                .display_name
4214                                .clone()
4215                                .unwrap_or_else(|| stack.template_id.clone()),
4216                            category: stack.category.clone().unwrap_or_default(),
4217                            base_mass: stack.base_mass,
4218                            base_volume: stack.base_volume,
4219                            capacity_volume: stack.capacity_volume,
4220                            stackable: stack.stackable.unwrap_or(true),
4221                            listable: stack.listable.unwrap_or_else(|| {
4222                                category_default_listable(stack.category.as_deref().unwrap_or(""))
4223                            }),
4224                            base_value_copper: stack.base_value_copper,
4225                        },
4226                    );
4227                }
4228                walk(&stack.contents, hints);
4229            }
4230        }
4231        walk(stacks, &mut self.inventory_hints);
4232    }
4233
4234    pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
4235        self.inventory_stacks = stacks.to_vec();
4236        self.inventory.clear();
4237        self.inventory_hints.clear();
4238        fn walk(
4239            stacks: &[flatland_protocol::ItemStack],
4240            inventory: &mut std::collections::HashMap<String, u32>,
4241            hints: &mut std::collections::HashMap<String, InventoryHint>,
4242        ) {
4243            for stack in stacks {
4244                *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
4245                if stack.display_name.is_some()
4246                    || stack.category.is_some()
4247                    || stack.base_mass.is_some()
4248                    || stack.base_volume.is_some()
4249                    || stack.base_value_copper.is_some()
4250                {
4251                    hints.insert(
4252                        stack.template_id.clone(),
4253                        InventoryHint {
4254                            display_name: stack
4255                                .display_name
4256                                .clone()
4257                                .unwrap_or_else(|| stack.template_id.clone()),
4258                            category: stack.category.clone().unwrap_or_default(),
4259                            base_mass: stack.base_mass,
4260                            base_volume: stack.base_volume,
4261                            capacity_volume: stack.capacity_volume,
4262                            stackable: stack.stackable.unwrap_or(true),
4263                            listable: stack.listable.unwrap_or_else(|| {
4264                                category_default_listable(stack.category.as_deref().unwrap_or(""))
4265                            }),
4266                            base_value_copper: stack.base_value_copper,
4267                        },
4268                    );
4269                }
4270                walk(&stack.contents, inventory, hints);
4271            }
4272        }
4273        walk(stacks, &mut self.inventory, &mut self.inventory_hints);
4274        // Include worn items (and nested contents, e.g. belt-clipped pouches) in craft counts.
4275        for item in self.worn.values() {
4276            walk(
4277                std::slice::from_ref(item),
4278                &mut self.inventory,
4279                &mut self.inventory_hints,
4280            );
4281        }
4282    }
4283
4284    fn sync_item_catalog(&mut self, entries: &[ItemCatalogEntryView]) {
4285        if entries.is_empty() {
4286            return;
4287        }
4288        self.item_catalog.clear();
4289        self.item_catalog.reserve(entries.len());
4290        for entry in entries {
4291            if entry.template_id.is_empty() {
4292                continue;
4293            }
4294            self.item_catalog
4295                .insert(entry.template_id.clone(), entry.clone());
4296        }
4297    }
4298
4299    /// Remove a carried stack by instance id (root or nested in a worn bag).
4300    /// Used for optimistic give-to-worker so the bag UI does not keep the item
4301    /// until the next tick — and so a later "Gave …" interaction cannot add it back.
4302    fn remove_carried_instance(&mut self, instance_id: uuid::Uuid, quantity: Option<u32>) {
4303        fn take_from(
4304            stacks: &mut Vec<flatland_protocol::ItemStack>,
4305            instance_id: uuid::Uuid,
4306            qty: Option<u32>,
4307        ) -> bool {
4308            if let Some(i) = stacks
4309                .iter()
4310                .position(|s| s.item_instance_id == Some(instance_id))
4311            {
4312                let have = stacks[i].quantity;
4313                let take = qty.unwrap_or(have).min(have);
4314                if take >= have {
4315                    stacks.remove(i);
4316                } else {
4317                    stacks[i].quantity = have - take;
4318                }
4319                return true;
4320            }
4321            stacks
4322                .iter_mut()
4323                .any(|stack| take_from(&mut stack.contents, instance_id, qty))
4324        }
4325
4326        if take_from(&mut self.inventory_stacks, instance_id, quantity) {
4327            let stacks = self.inventory_stacks.clone();
4328            self.sync_inventory_from_stacks(&stacks);
4329            self.refresh_inventory_ui();
4330            return;
4331        }
4332        let slots: Vec<_> = self.worn.keys().copied().collect();
4333        for slot in slots {
4334            let Some(item) = self.worn.get_mut(&slot) else {
4335                continue;
4336            };
4337            if take_from(&mut item.contents, instance_id, quantity) {
4338                let stacks = self.inventory_stacks.clone();
4339                self.sync_inventory_from_stacks(&stacks);
4340                self.refresh_inventory_ui();
4341                return;
4342            }
4343        }
4344    }
4345
4346    /// Apply server interaction deltas immediately (quest rewards, shop, etc.) so the
4347    /// inventory UI updates before the next tick snapshot arrives.
4348    pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
4349        // Worker handoff notices reuse `inventory_delta` as a toast payload, not a
4350        // gain. The same tick already dropped the stack; applying it as an add put
4351        // the item back in the player bag.
4352        if notice.message.starts_with("Gave ") {
4353            if notice.coins_delta != 0 {
4354                crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
4355                let stacks = self.inventory_stacks.clone();
4356                self.sync_inventory_from_stacks(&stacks);
4357            }
4358            self.record_shop_trade_notice(notice);
4359            return;
4360        }
4361        let subtract_items =
4362            notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
4363        for stack in &notice.inventory_delta {
4364            if stack.quantity == 0 {
4365                continue;
4366            }
4367            if subtract_items {
4368                crate::currency::drain_template_stacks(
4369                    &mut self.inventory_stacks,
4370                    &stack.template_id,
4371                    stack.quantity,
4372                );
4373                continue;
4374            }
4375            let stackable = self
4376                .inventory_hints
4377                .get(&stack.template_id)
4378                .map(|h| h.stackable)
4379                .or(stack.stackable)
4380                .unwrap_or(true);
4381            if stackable {
4382                if let Some(existing) = self
4383                    .inventory_stacks
4384                    .iter_mut()
4385                    .find(|s| s.template_id == stack.template_id)
4386                {
4387                    existing.quantity = existing.quantity.saturating_add(stack.quantity);
4388                    if stack.display_name.is_some() {
4389                        existing.display_name = stack.display_name.clone();
4390                    }
4391                    if stack.category.is_some() {
4392                        existing.category = stack.category.clone();
4393                    }
4394                    continue;
4395                }
4396            }
4397            self.inventory_stacks.push(stack.clone());
4398        }
4399        if notice.coins_delta != 0 {
4400            crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
4401        }
4402        if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
4403            let stacks = self.inventory_stacks.clone();
4404            self.sync_inventory_from_stacks(&stacks);
4405        }
4406        self.record_shop_trade_notice(notice);
4407    }
4408
4409    /// Worn body-slot items — each shown as a shell row (unequip via Enter) followed by
4410    /// its nested contents (e.g. pouches clipped onto a worn belt). `BodySlot` derives
4411    /// `Ord` in display order (Head/Body/Arms/Legs/Feet/Back/Waist), so `BTreeMap`
4412    /// iteration alone gives a stable row order.
4413    pub fn worn_rows(&self) -> Vec<InventoryRow> {
4414        let mut rows = Vec::new();
4415        for (slot, item) in &self.worn {
4416            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4417            rows.push(InventoryRow {
4418                depth: 0,
4419                stack: item.clone(),
4420                from: from.clone(),
4421                from_parent_instance_id: None,
4422                is_equip_shell: true,
4423                is_chest_shell: false,
4424                section: InventorySection::Worn,
4425            });
4426            for child in &item.contents {
4427                push_inventory_rows(
4428                    &mut rows,
4429                    1,
4430                    child,
4431                    &from,
4432                    item.item_instance_id,
4433                    InventorySection::Worn,
4434                );
4435            }
4436        }
4437        rows
4438    }
4439
4440    /// Root stacks eligible to present in a player trade (excludes hand-equipped gear).
4441    pub fn trade_presentable_stacks(&self) -> Vec<&flatland_protocol::ItemStack> {
4442        let equipped = self.hand_equipped_instance_ids();
4443        self.inventory_stacks
4444            .iter()
4445            .filter(|s| s.item_instance_id.is_some_and(|id| !equipped.contains(&id)))
4446            .collect()
4447    }
4448
4449    /// Root on-person stacks that can be handed to a hired worker.
4450    pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
4451        let equipped = self.hand_equipped_instance_ids();
4452        self.inventory_stacks
4453            .iter()
4454            .filter_map(|stack| {
4455                let item_instance_id = stack.item_instance_id?;
4456                if equipped.contains(&item_instance_id) {
4457                    return None;
4458                }
4459                let label = stack
4460                    .display_name
4461                    .clone()
4462                    .unwrap_or_else(|| stack.template_id.clone());
4463                let label = if stack.quantity > 1 {
4464                    format!("{label} ×{}", stack.quantity)
4465                } else {
4466                    label
4467                };
4468                Some(WorkerGiveOption {
4469                    item_instance_id,
4470                    label,
4471                    quantity: stack.quantity,
4472                    template_id: stack.template_id.clone(),
4473                })
4474            })
4475            .collect()
4476    }
4477
4478    /// Employer-known blueprints the selected worker does not yet know.
4479    pub fn teachable_blueprint_options(
4480        &self,
4481        worker: &flatland_protocol::HiredWorkerView,
4482    ) -> Vec<WorkerTeachOption> {
4483        let copper = crate::currency::copper_from_counts(&self.inventory);
4484        let mut options: Vec<WorkerTeachOption> = self
4485            .blueprints
4486            .iter()
4487            .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
4488            .map(|bp| {
4489                let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
4490                let cost = bp.worker_train_copper;
4491                WorkerTeachOption {
4492                    blueprint_id: bp.id.clone(),
4493                    label: if bp.label.is_empty() {
4494                        bp.id.clone()
4495                    } else {
4496                        bp.label.clone()
4497                    },
4498                    cost_copper: cost,
4499                    min_level,
4500                    worker_level: worker.level,
4501                    can_afford: copper >= cost,
4502                    level_ok: worker.level >= min_level,
4503                }
4504            })
4505            .collect();
4506        options.sort_by(|a, b| a.label.cmp(&b.label));
4507        options
4508    }
4509
4510    /// Loose on-person inventory (not worn, not inside a placed chest).
4511    /// Root stacks are ordered by category group; nested contents stay under their parent.
4512    pub fn person_rows(&self) -> Vec<InventoryRow> {
4513        self.person_rows_filtered("")
4514    }
4515
4516    pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4517        let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
4518        roots.sort_by(|a, b| {
4519            let ca = a
4520                .category
4521                .as_deref()
4522                .or_else(|| self.inventory_item_category(&a.template_id))
4523                .unwrap_or("");
4524            let cb = b
4525                .category
4526                .as_deref()
4527                .or_else(|| self.inventory_item_category(&b.template_id))
4528                .unwrap_or("");
4529            let ga = inventory_category_group(ca).1;
4530            let gb = inventory_category_group(cb).1;
4531            ga.cmp(&gb).then_with(|| {
4532                let na = a.display_name.as_deref().unwrap_or(a.template_id.as_str());
4533                let nb = b.display_name.as_deref().unwrap_or(b.template_id.as_str());
4534                na.cmp(nb)
4535            })
4536        });
4537        let mut rows = Vec::new();
4538        for stack in roots {
4539            push_inventory_rows_filtered(
4540                &mut rows,
4541                0,
4542                stack,
4543                &flatland_protocol::InventoryLocation::Root,
4544                None,
4545                InventorySection::Person,
4546                filter,
4547            );
4548        }
4549        rows
4550    }
4551
4552    pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4553        if filter.is_empty() {
4554            return self.worn_rows();
4555        }
4556        let mut rows = Vec::new();
4557        for (slot, item) in &self.worn {
4558            if !stack_matches_filter(item, filter) {
4559                continue;
4560            }
4561            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4562            let self_hit = {
4563                let f = filter.to_ascii_lowercase();
4564                let name = item
4565                    .display_name
4566                    .as_deref()
4567                    .unwrap_or("")
4568                    .to_ascii_lowercase();
4569                let tid = item.template_id.to_ascii_lowercase();
4570                name.contains(&f) || tid.contains(&f)
4571            };
4572            rows.push(InventoryRow {
4573                depth: 0,
4574                stack: item.clone(),
4575                from: from.clone(),
4576                from_parent_instance_id: None,
4577                is_equip_shell: true,
4578                is_chest_shell: false,
4579                section: InventorySection::Worn,
4580            });
4581            for child in &item.contents {
4582                if self_hit || stack_matches_filter(child, filter) {
4583                    push_inventory_rows_filtered(
4584                        &mut rows,
4585                        1,
4586                        child,
4587                        &from,
4588                        item.item_instance_id,
4589                        InventorySection::Worn,
4590                        if self_hit { "" } else { filter },
4591                    );
4592                }
4593            }
4594        }
4595        rows
4596    }
4597
4598    /// Items carried inside equipped containers, without showing the equipped
4599    /// container shells themselves. Equipped gear belongs in the paperdoll;
4600    /// these rows are still movable because the contents are carried by the player.
4601    pub fn carried_worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4602        let mut rows = Vec::new();
4603        for (slot, item) in &self.worn {
4604            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4605            for child in &item.contents {
4606                push_inventory_rows_filtered(
4607                    &mut rows,
4608                    0,
4609                    child,
4610                    &from,
4611                    item.item_instance_id,
4612                    InventorySection::Person,
4613                    filter,
4614                );
4615            }
4616        }
4617        rows
4618    }
4619
4620    /// Legacy alias used by the HUD sidebar summary (worn + on-person, unchanged).
4621    pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
4622        let mut rows = self.worn_rows();
4623        rows.extend(self.person_rows());
4624        rows.into_iter().map(|r| (r.depth, r.stack)).collect()
4625    }
4626
4627    /// Placed chests within `CONTAINER_RANGE_M`, nearest first. Contents are only
4628    /// populated when `accessible` — this is what makes a chest's contents
4629    /// disappear the moment you walk away or it's locked without your key.
4630    pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
4631        let (px, py) = self.player_position();
4632        let mut list: Vec<NearbyContainer> = self
4633            .placed_containers
4634            .iter()
4635            .filter(|c| self.placed_container_in_current_space(c))
4636            .filter_map(|c| {
4637                let distance_m = (c.x - px).hypot(c.y - py);
4638                if distance_m > CONTAINER_RANGE_M {
4639                    return None;
4640                }
4641                let mut rows = Vec::new();
4642                let from = flatland_protocol::InventoryLocation::Placed {
4643                    container_id: c.id.clone(),
4644                };
4645                rows.push(InventoryRow {
4646                    depth: 0,
4647                    stack: flatland_protocol::ItemStack {
4648                        template_id: c.template_id.clone(),
4649                        quantity: 1,
4650                        item_instance_id: c.item_instance_id,
4651                        props: Default::default(),
4652                        status_bindings: Vec::new(),
4653                        contents: Vec::new(),
4654                        display_name: Some(c.display_name.clone()),
4655                        category: Some("container".into()),
4656                        capacity_volume: c.capacity_volume,
4657                        worker_lodging_capacity: c.worker_lodging_capacity,
4658                        ..Default::default()
4659                    },
4660                    from: from.clone(),
4661                    from_parent_instance_id: None,
4662                    is_equip_shell: false,
4663                    is_chest_shell: true,
4664                    section: InventorySection::Nearby,
4665                });
4666                if c.accessible {
4667                    for child in &c.contents {
4668                        push_inventory_rows(
4669                            &mut rows,
4670                            1,
4671                            child,
4672                            &from,
4673                            c.item_instance_id,
4674                            InventorySection::Nearby,
4675                        );
4676                    }
4677                }
4678                Some(NearbyContainer {
4679                    view: c.clone(),
4680                    distance_m,
4681                    rows,
4682                })
4683            })
4684            .collect();
4685        list.sort_by(|a, b| {
4686            a.distance_m
4687                .partial_cmp(&b.distance_m)
4688                .unwrap_or(std::cmp::Ordering::Equal)
4689        });
4690        list
4691    }
4692
4693    /// Nearest placed chest within `max_dist`, regardless of accessibility.
4694    pub fn nearest_placed_container(
4695        &self,
4696        max_dist: f32,
4697    ) -> Option<flatland_protocol::PlacedContainerView> {
4698        let (px, py) = self.player_position();
4699        self.placed_containers
4700            .iter()
4701            .filter(|c| self.placed_container_in_current_space(c))
4702            .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
4703            .min_by(|a, b| {
4704                let da = (a.x - px).hypot(a.y - py);
4705                let db = (b.x - px).hypot(b.y - py);
4706                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
4707            })
4708            .cloned()
4709    }
4710
4711    /// Selectable rows for the **active inventory tab** (and current filter).
4712    /// Index into this with `inventory_menu_index`; browser lines must use the same order.
4713    pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
4714        let filter = self.inventory_filter.as_str();
4715        match self.inventory_tab {
4716            InventoryTab::OnPerson => {
4717                let mut rows = self.carried_worn_rows_filtered(filter);
4718                rows.extend(self.person_rows_filtered(filter));
4719                rows
4720            }
4721            InventoryTab::Nearby => {
4722                let mut rows = Vec::new();
4723                for nc in self.nearby_containers() {
4724                    if filter.is_empty() {
4725                        rows.extend(nc.rows);
4726                        continue;
4727                    }
4728                    let shell = nc.rows.first().cloned();
4729                    let contents: Vec<_> = nc
4730                        .rows
4731                        .iter()
4732                        .skip(1)
4733                        .filter(|r| stack_matches_filter(&r.stack, filter))
4734                        .cloned()
4735                        .collect();
4736                    let shell_hit = shell
4737                        .as_ref()
4738                        .map(|s| stack_matches_filter(&s.stack, filter))
4739                        .unwrap_or(false);
4740                    if shell_hit || !contents.is_empty() {
4741                        if let Some(s) = shell {
4742                            rows.push(s);
4743                        }
4744                        if shell_hit {
4745                            rows.extend(nc.rows.into_iter().skip(1));
4746                        } else {
4747                            rows.extend(contents);
4748                        }
4749                    }
4750                }
4751                rows
4752            }
4753        }
4754    }
4755
4756    pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
4757        self.inventory_selectable_rows()
4758            .into_iter()
4759            .nth(self.inventory_menu_index)
4760    }
4761
4762    fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
4763        let cat = self
4764            .inventory_item_category(&row.stack.template_id)
4765            .unwrap_or("");
4766        if cat == "key" {
4767            self.key_inventory_label(&row.stack)
4768        } else {
4769            row.stack
4770                .display_name
4771                .clone()
4772                .unwrap_or_else(|| row.stack.template_id.clone())
4773        }
4774    }
4775
4776    /// Signature of everything already shown on the gfx row title (bindings, grants, qty, worn slot).
4777    fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
4778        let bindings =
4779            format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
4780        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
4781            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
4782            let mode = Self::grant_mode(&row.stack);
4783            format!(" [grant {effect} · {mode} — e apply]")
4784        } else {
4785            String::new()
4786        };
4787        let qty = if row.stack.quantity > 1 {
4788            format!(" ×{}", row.stack.quantity)
4789        } else {
4790            String::new()
4791        };
4792        let worn_slot = if row.is_equip_shell {
4793            match row.from {
4794                flatland_protocol::InventoryLocation::Worn { slot } => {
4795                    format!(" ({})", body_slot_label(slot))
4796                }
4797                _ => String::new(),
4798            }
4799        } else {
4800            String::new()
4801        };
4802        format!("{grant_hint}{bindings}{qty}{worn_slot}")
4803    }
4804
4805    fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
4806        (
4807            row.stack.template_id.clone(),
4808            self.inventory_row_base_label(row),
4809            self.inventory_row_visible_mod_signature(row),
4810        )
4811    }
4812
4813    /// Keys for which two or more instanced rows look the same in the browser (need hover disambiguation).
4814    fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
4815        let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
4816        for row in self.inventory_selectable_rows() {
4817            if row.stack.item_instance_id.is_none() {
4818                continue;
4819            }
4820            let key = self.inventory_row_instance_identity_key(&row);
4821            *counts.entry(key).or_default() += 1;
4822        }
4823        counts
4824            .into_iter()
4825            .filter(|(_, n)| *n > 1)
4826            .map(|(k, _)| k)
4827            .collect()
4828    }
4829
4830    fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
4831        let hex: String = id
4832            .as_simple()
4833            .to_string()
4834            .chars()
4835            .filter(|c| c.is_ascii_hexdigit())
4836            .collect();
4837        let short = if hex.len() >= 4 {
4838            &hex[hex.len() - 4..]
4839        } else {
4840            hex.as_str()
4841        };
4842        format!("Instance {id} (#{short})")
4843    }
4844
4845    /// Format one selectable inventory row for TUI/gfx (label + hints + mass/volume).
4846    pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
4847        let cat = self
4848            .inventory_item_category(&row.stack.template_id)
4849            .unwrap_or("");
4850        let label = self.inventory_row_base_label(row);
4851        let hint: String = if row.is_equip_shell {
4852            " [worn — Enter to unequip]".into()
4853        } else if row.is_chest_shell {
4854            let (locked, lodging_note) = match &row.from {
4855                flatland_protocol::InventoryLocation::Placed { container_id } => {
4856                    let locked = self
4857                        .placed_containers
4858                        .iter()
4859                        .find(|c| c.id == *container_id)
4860                        .map(|c| c.locked)
4861                        .unwrap_or(false);
4862                    let lodging_note = self
4863                        .lodging_occupancy_label(container_id)
4864                        .map(|who| format!(" [lodging: {who}]"))
4865                        .unwrap_or_default();
4866                    (locked, lodging_note)
4867                }
4868                _ => (false, String::new()),
4869            };
4870            if locked {
4871                format!(" [locked — Enter pick up · l unlock]{lodging_note}")
4872            } else {
4873                format!(" [Enter pick up · l lock]{lodging_note}")
4874            }
4875        } else if cat == "key" {
4876            self.key_inventory_hint(&row.stack)
4877        } else {
4878            match cat {
4879                "weapon" => " [weapon]".into(),
4880                "container" => " [bag/chest/belt]".into(),
4881                "lodging" => " [worker lodging]".into(),
4882                "armor" => " [armor]".into(),
4883                _ => String::new(),
4884            }
4885        };
4886        let qty = if row.stack.quantity > 1 {
4887            format!(" ×{}", row.stack.quantity)
4888        } else {
4889            String::new()
4890        };
4891        let bindings =
4892            format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
4893        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
4894            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
4895            let mode = Self::grant_mode(&row.stack);
4896            format!(" [grant {effect} · {mode} — e apply]")
4897        } else {
4898            String::new()
4899        };
4900        let mass = self.stack_mass(&row.stack);
4901        let mass_kg = (mass >= 0.05).then_some(mass);
4902        let mass_str = mass_kg.map(|m| format!("  {m:.1} kg")).unwrap_or_default();
4903        let volume = self.container_volume_stats(row);
4904        let vol_str = self.container_volume_label(row);
4905
4906        let mut title = label.clone();
4907        title.push_str(&qty);
4908        if row.is_equip_shell {
4909            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
4910                title.push_str(&format!(" ({})", body_slot_label(slot)));
4911            }
4912        }
4913
4914        InventoryRowView {
4915            depth: row.depth,
4916            text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
4917            title: format!("{title}{grant_hint}{bindings}"),
4918            mass_kg,
4919            volume,
4920            instance_tooltip: None,
4921        }
4922    }
4923
4924    fn push_browser_item(
4925        &self,
4926        lines: &mut Vec<InventoryBrowserLine>,
4927        row: &InventoryRow,
4928        global_idx: &mut usize,
4929        target: usize,
4930        highlight: bool,
4931        ambiguous_instance_keys: &HashSet<(String, String, String)>,
4932    ) {
4933        let mut view = self.format_inventory_row(row);
4934        if let Some(id) = row.stack.item_instance_id {
4935            let key = self.inventory_row_instance_identity_key(row);
4936            if ambiguous_instance_keys.contains(&key) {
4937                view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
4938            }
4939        }
4940        lines.push(InventoryBrowserLine::Item {
4941            selectable_index: *global_idx,
4942            selected: highlight && *global_idx == target,
4943            depth: view.depth,
4944            text: view.text,
4945            title: view.title,
4946            mass_kg: view.mass_kg,
4947            volume: view.volume,
4948            instance_tooltip: view.instance_tooltip,
4949        });
4950        *global_idx += 1;
4951    }
4952
4953    /// Sectioned inventory browser lines for the active tab. Selectable rows carry
4954    /// `selectable_index` matching `inventory_menu_index`.
4955    pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
4956        let mut lines = Vec::new();
4957        let target = self.inventory_menu_index;
4958        let highlight = !self.show_move_picker && !self.show_grant_picker;
4959        let filter = self.inventory_filter.as_str();
4960        let mut global_idx = 0usize;
4961        let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
4962
4963        match self.inventory_tab {
4964            InventoryTab::OnPerson => {
4965                lines.push(InventoryBrowserLine::Section("— In carried bags —".into()));
4966                let carried = self.carried_worn_rows_filtered(filter);
4967                if carried.is_empty() {
4968                    lines.push(InventoryBrowserLine::Hint(
4969                        "  (no items in carried bags)".into(),
4970                    ));
4971                } else {
4972                    for row in &carried {
4973                        self.push_browser_item(
4974                            &mut lines,
4975                            row,
4976                            &mut global_idx,
4977                            target,
4978                            highlight,
4979                            &ambiguous_instance_keys,
4980                        );
4981                    }
4982                }
4983
4984                lines.push(InventoryBrowserLine::Blank);
4985                lines.push(InventoryBrowserLine::Section(
4986                    "— On you (loose, not worn) —".into(),
4987                ));
4988                let person = self.person_rows_filtered(filter);
4989                if person.is_empty() {
4990                    lines.push(InventoryBrowserLine::Hint("  (empty)".into()));
4991                } else {
4992                    let mut last_group: Option<&'static str> = None;
4993                    for row in &person {
4994                        if row.depth == 0 {
4995                            let cat = row
4996                                .stack
4997                                .category
4998                                .as_deref()
4999                                .or_else(|| self.inventory_item_category(&row.stack.template_id))
5000                                .unwrap_or("");
5001                            let (group, _) = inventory_category_group(cat);
5002                            if last_group != Some(group) {
5003                                lines.push(InventoryBrowserLine::SlotLabel(format!("  {group}")));
5004                                last_group = Some(group);
5005                            }
5006                        }
5007                        self.push_browser_item(
5008                            &mut lines,
5009                            row,
5010                            &mut global_idx,
5011                            target,
5012                            highlight,
5013                            &ambiguous_instance_keys,
5014                        );
5015                    }
5016                }
5017            }
5018            InventoryTab::Nearby => {
5019                let nearby = self.nearby_containers();
5020                if nearby.is_empty() {
5021                    lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
5022                    lines.push(InventoryBrowserLine::Hint(
5023                        "  (none within reach — walk up to a chest)".into(),
5024                    ));
5025                    lines.push(InventoryBrowserLine::Hint(
5026                        "  Select an on-person item, then m / Enter → move into chest.".into(),
5027                    ));
5028                } else {
5029                    let mut any_visible = false;
5030                    for nc in &nearby {
5031                        let shell = nc.rows.first();
5032                        let contents: Vec<&InventoryRow> = if filter.is_empty() {
5033                            nc.rows.iter().skip(1).collect()
5034                        } else {
5035                            let shell_hit = shell
5036                                .map(|s| {
5037                                    let f = filter.to_ascii_lowercase();
5038                                    let name = s
5039                                        .stack
5040                                        .display_name
5041                                        .as_deref()
5042                                        .unwrap_or("")
5043                                        .to_ascii_lowercase();
5044                                    let tid = s.stack.template_id.to_ascii_lowercase();
5045                                    name.contains(&f) || tid.contains(&f)
5046                                })
5047                                .unwrap_or(false);
5048                            if shell_hit {
5049                                nc.rows.iter().skip(1).collect()
5050                            } else {
5051                                nc.rows
5052                                    .iter()
5053                                    .skip(1)
5054                                    .filter(|r| stack_matches_filter(&r.stack, filter))
5055                                    .collect()
5056                            }
5057                        };
5058                        let shell_visible = filter.is_empty()
5059                            || shell
5060                                .map(|s| stack_matches_filter(&s.stack, filter))
5061                                .unwrap_or(false)
5062                            || !contents.is_empty();
5063                        if !shell_visible && shell.is_some() {
5064                            continue;
5065                        }
5066                        any_visible = true;
5067                        lines.push(InventoryBrowserLine::Blank);
5068                        let lock_note = if nc.view.locked && nc.view.accessible {
5069                            "  unlocked with your key"
5070                        } else if nc.view.locked {
5071                            "  locked"
5072                        } else {
5073                            ""
5074                        };
5075                        lines.push(InventoryBrowserLine::Section(format!(
5076                            "— {} ({:.0}m away){lock_note} —",
5077                            nc.view.display_name, nc.distance_m
5078                        )));
5079                        if !nc.view.accessible {
5080                            lines.push(InventoryBrowserLine::Hint(
5081                                "  locked — need the matching key (l to try)".into(),
5082                            ));
5083                        } else if nc.rows.is_empty() {
5084                            lines.push(InventoryBrowserLine::Hint(
5085                                "  (empty — switch to On person, select an item, m to move in)"
5086                                    .into(),
5087                            ));
5088                        } else if let Some(shell_row) = shell {
5089                            self.push_browser_item(
5090                                &mut lines,
5091                                shell_row,
5092                                &mut global_idx,
5093                                target,
5094                                highlight,
5095                                &ambiguous_instance_keys,
5096                            );
5097                            for row in contents {
5098                                self.push_browser_item(
5099                                    &mut lines,
5100                                    row,
5101                                    &mut global_idx,
5102                                    target,
5103                                    highlight,
5104                                    &ambiguous_instance_keys,
5105                                );
5106                            }
5107                        }
5108                    }
5109                    if !any_visible {
5110                        lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
5111                        lines.push(InventoryBrowserLine::Hint(
5112                            "  (no matching items — clear filter with Esc)".into(),
5113                        ));
5114                    }
5115                }
5116            }
5117        }
5118        lines
5119    }
5120
5121    /// Destinations for picking up a placed chest/crate into inventory.
5122    pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
5123        let mut opts = Vec::new();
5124        opts.push(MoveOption::action(
5125            "Relocate…",
5126            MoveOptionKind::RelocatePlaced {
5127                container_id: container_id.to_string(),
5128            },
5129        ));
5130        opts.push(MoveOption::action(
5131            "On your person (loose)",
5132            MoveOptionKind::PickupPlaced {
5133                container_id: container_id.to_string(),
5134                nest_location: flatland_protocol::InventoryLocation::Root,
5135                nest_parent_instance_id: None,
5136            },
5137        ));
5138        for (slot, item) in &self.worn {
5139            if item.category.as_deref() != Some("container") {
5140                continue;
5141            }
5142            if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
5143                continue;
5144            }
5145            let Some(parent_id) = item.item_instance_id else {
5146                continue;
5147            };
5148            let shell_name = item
5149                .display_name
5150                .clone()
5151                .unwrap_or_else(|| item.template_id.clone());
5152            let nest_location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
5153            opts.push(MoveOption {
5154                label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
5155                kind: MoveOptionKind::PickupPlaced {
5156                    container_id: container_id.to_string(),
5157                    nest_location: nest_location.clone(),
5158                    nest_parent_instance_id: Some(parent_id),
5159                },
5160                volume: self.destination_volume_stats(&nest_location, Some(parent_id)),
5161            });
5162            // Nested pouches inside the worn bag.
5163            self.append_chest_pickup_nested(
5164                &mut opts,
5165                container_id,
5166                nest_location,
5167                item,
5168                &format!("in {shell_name}"),
5169            );
5170        }
5171        opts.push(MoveOption::action("Cancel", MoveOptionKind::Cancel));
5172        opts
5173    }
5174
5175    fn append_chest_pickup_nested(
5176        &self,
5177        opts: &mut Vec<MoveOption>,
5178        container_id: &str,
5179        location: flatland_protocol::InventoryLocation,
5180        parent: &flatland_protocol::ItemStack,
5181        context: &str,
5182    ) {
5183        for child in &parent.contents {
5184            if child.category.as_deref() != Some("container") {
5185                continue;
5186            }
5187            if !Self::is_volume_container_stack(child) {
5188                continue;
5189            }
5190            // Skip placeable nested chests — relocating into another chest is not useful.
5191            if child.world_placeable == Some(true) {
5192                continue;
5193            }
5194            let Some(child_id) = child.item_instance_id else {
5195                continue;
5196            };
5197            let name = child
5198                .display_name
5199                .clone()
5200                .unwrap_or_else(|| child.template_id.clone());
5201            opts.push(MoveOption {
5202                label: format!("{name} ({context})"),
5203                kind: MoveOptionKind::PickupPlaced {
5204                    container_id: container_id.to_string(),
5205                    nest_location: location.clone(),
5206                    nest_parent_instance_id: Some(child_id),
5207                },
5208                volume: self.destination_volume_stats(&location, Some(child_id)),
5209            });
5210            self.append_chest_pickup_nested(
5211                opts,
5212                container_id,
5213                location.clone(),
5214                child,
5215                &format!("in {name}"),
5216            );
5217        }
5218    }
5219
5220    /// Build the "move to…" destination list for an item currently at `from`.
5221    pub fn move_destinations_for(
5222        &self,
5223        from: &flatland_protocol::InventoryLocation,
5224        from_parent_instance_id: Option<uuid::Uuid>,
5225        moving_instance_id: Option<uuid::Uuid>,
5226        moving_template_id: &str,
5227    ) -> Vec<MoveOption> {
5228        let mut opts = Vec::new();
5229        if *from != flatland_protocol::InventoryLocation::Root {
5230            opts.push(MoveOption::action(
5231                "On your person (loose)",
5232                MoveOptionKind::Move {
5233                    location: flatland_protocol::InventoryLocation::Root,
5234                    parent_instance_id: None,
5235                },
5236            ));
5237        }
5238        for (slot, item) in &self.worn {
5239            if item.category.as_deref() != Some("container") {
5240                continue;
5241            }
5242            let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
5243            let shell_name = item
5244                .display_name
5245                .clone()
5246                .unwrap_or_else(|| item.template_id.clone());
5247
5248            // Backpack and other worn volume containers — store directly inside the shell.
5249            if *slot != BodySlot::Waist
5250                && item.item_instance_id != moving_instance_id
5251                && Self::is_volume_container_stack(item)
5252            {
5253                self.push_move_destination(
5254                    &mut opts,
5255                    format!("{shell_name} (worn {})", body_slot_label(*slot)),
5256                    location.clone(),
5257                    item.item_instance_id,
5258                    from,
5259                    from_parent_instance_id,
5260                );
5261            }
5262
5263            // Belt loops only accept pouch attachments — not loose materials.
5264            if *slot == BodySlot::Waist
5265                && Self::attaches_to_belt_loop(moving_template_id)
5266                && item.item_instance_id != moving_instance_id
5267            {
5268                self.push_move_destination(
5269                    &mut opts,
5270                    format!("{shell_name} (belt loop)"),
5271                    location.clone(),
5272                    item.item_instance_id,
5273                    from,
5274                    from_parent_instance_id,
5275                );
5276            }
5277
5278            let context = if *slot == BodySlot::Waist {
5279                format!("on {shell_name}")
5280            } else {
5281                format!("in {shell_name}")
5282            };
5283            self.append_nested_container_destinations(
5284                &mut opts,
5285                location,
5286                item,
5287                &context,
5288                from,
5289                from_parent_instance_id,
5290                moving_instance_id,
5291            );
5292        }
5293        for nc in self.nearby_containers() {
5294            if !nc.view.accessible {
5295                continue;
5296            }
5297            let location = flatland_protocol::InventoryLocation::Placed {
5298                container_id: nc.view.id.clone(),
5299            };
5300            self.push_move_destination(
5301                &mut opts,
5302                format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
5303                location,
5304                nc.view.item_instance_id,
5305                from,
5306                from_parent_instance_id,
5307            );
5308        }
5309        let allow_drop = moving_instance_id
5310            .map(|id| !self.hand_equipped_instance_ids().contains(&id))
5311            .unwrap_or(true)
5312            && moving_instance_id
5313                .and_then(|id| self.stack_for_instance(id))
5314                .map(|stack| {
5315                    !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
5316                })
5317                .unwrap_or(
5318                    moving_template_id != KEY_TEMPLATE
5319                        && moving_template_id != PROPERTY_DEED_TEMPLATE,
5320                );
5321        if allow_drop {
5322            opts.push(MoveOption::action(
5323                "Drop on the ground",
5324                MoveOptionKind::Drop,
5325            ));
5326        }
5327        opts.push(MoveOption::action("Cancel", MoveOptionKind::Cancel));
5328        opts
5329    }
5330
5331    fn is_same_container_dest(
5332        dest_location: &flatland_protocol::InventoryLocation,
5333        dest_parent: Option<uuid::Uuid>,
5334        from: &flatland_protocol::InventoryLocation,
5335        from_parent: Option<uuid::Uuid>,
5336    ) -> bool {
5337        dest_location == from && dest_parent == from_parent
5338    }
5339
5340    fn push_move_destination(
5341        &self,
5342        opts: &mut Vec<MoveOption>,
5343        label: String,
5344        location: flatland_protocol::InventoryLocation,
5345        parent_instance_id: Option<uuid::Uuid>,
5346        from: &flatland_protocol::InventoryLocation,
5347        from_parent_instance_id: Option<uuid::Uuid>,
5348    ) {
5349        if Self::is_same_container_dest(
5350            &location,
5351            parent_instance_id,
5352            from,
5353            from_parent_instance_id,
5354        ) {
5355            return;
5356        }
5357        let volume = self.destination_volume_stats(&location, parent_instance_id);
5358        opts.push(MoveOption {
5359            label,
5360            kind: MoveOptionKind::Move {
5361                location,
5362                parent_instance_id,
5363            },
5364            volume,
5365        });
5366    }
5367
5368    fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
5369        stack.capacity_volume.is_some_and(|c| c > 0.0)
5370    }
5371
5372    fn attaches_to_belt_loop(template_id: &str) -> bool {
5373        matches!(template_id, "leather_pouch" | "dimensional_pouch")
5374    }
5375
5376    fn append_nested_container_destinations(
5377        &self,
5378        opts: &mut Vec<MoveOption>,
5379        location: flatland_protocol::InventoryLocation,
5380        container: &flatland_protocol::ItemStack,
5381        context: &str,
5382        from: &flatland_protocol::InventoryLocation,
5383        from_parent_instance_id: Option<uuid::Uuid>,
5384        moving_instance_id: Option<uuid::Uuid>,
5385    ) {
5386        for child in &container.contents {
5387            if Self::is_volume_container_stack(child)
5388                && child.item_instance_id != moving_instance_id
5389            {
5390                let name = child
5391                    .display_name
5392                    .clone()
5393                    .unwrap_or_else(|| child.template_id.clone());
5394                self.push_move_destination(
5395                    opts,
5396                    format!("{name} ({context})"),
5397                    location.clone(),
5398                    child.item_instance_id,
5399                    from,
5400                    from_parent_instance_id,
5401                );
5402            }
5403            let nested_context = format!(
5404                "in {}",
5405                child.display_name.as_deref().unwrap_or(&child.template_id)
5406            );
5407            self.append_nested_container_destinations(
5408                opts,
5409                location.clone(),
5410                child,
5411                &nested_context,
5412                from,
5413                from_parent_instance_id,
5414                moving_instance_id,
5415            );
5416        }
5417    }
5418
5419    fn clamp_inventory_indices(&mut self) {
5420        let n = self.inventory_selectable_rows().len();
5421        self.inventory_menu_index = if n == 0 {
5422            0
5423        } else {
5424            self.inventory_menu_index.min(n - 1)
5425        };
5426        if let Some(picker) = &self.move_picker {
5427            let pn = picker.options.len();
5428            self.move_picker_index = if pn == 0 {
5429                0
5430            } else {
5431                self.move_picker_index.min(pn - 1)
5432            };
5433        }
5434    }
5435
5436    /// Drop interior map layers whenever the player is outdoors.
5437    /// Restores outdoor z-bands saved before [`Self::sync_interior_z_bands`] overwrote them.
5438    /// Tick deltas never refresh `z_platforms`; without restore, stale interior platforms
5439    /// force `build_grid` into a full-map z pass on every click until client restart.
5440    fn sync_interior_map_context(&mut self) {
5441        if self.effective_inside_building().is_none() {
5442            self.interior_map = None;
5443            if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
5444                self.z_platforms = platforms;
5445                self.z_transitions = transitions;
5446            }
5447            return;
5448        }
5449        self.sync_interior_z_bands();
5450    }
5451
5452    /// While indoors, z-bands come from the active interior blueprint (multi-floor stairs).
5453    fn sync_interior_z_bands(&mut self) {
5454        if self.effective_inside_building().is_some() {
5455            if let Some(map) = &self.interior_map {
5456                if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
5457                    if self.z_bands_outdoor_backup.is_none() {
5458                        self.z_bands_outdoor_backup = Some((
5459                            std::mem::take(&mut self.z_platforms),
5460                            std::mem::take(&mut self.z_transitions),
5461                        ));
5462                    }
5463                    self.z_platforms = map.z_platforms.clone();
5464                    self.z_transitions = map.z_transitions.clone();
5465                }
5466            }
5467        }
5468    }
5469
5470    fn apply_snapshot_fields(
5471        &mut self,
5472        snapshot: &flatland_protocol::Snapshot,
5473        entity_id: EntityId,
5474    ) {
5475        self.tick = snapshot.tick;
5476        self.chunk_rev = snapshot.chunk_rev;
5477        self.content_rev = snapshot.content_rev;
5478        self.publish_rev = snapshot.publish_rev;
5479        self.resource_nodes = snapshot.resource_nodes.clone();
5480        self.replace_harvest_route_nodes(&snapshot.resource_nodes);
5481        self.ground_drops = snapshot.ground_drops.clone();
5482        self.placed_containers = snapshot.placed_containers.clone();
5483        self.world_x0 = snapshot.world_x0;
5484        self.world_y0 = snapshot.world_y0;
5485        self.world_width_m = snapshot.world_width_m;
5486        self.world_height_m = snapshot.world_height_m;
5487        self.world_clock = snapshot.world_clock;
5488        self.terrain_zones = snapshot.terrain_zones.clone();
5489        self.z_platforms = snapshot.z_platforms.clone();
5490        self.z_transitions = snapshot.z_transitions.clone();
5491        // Snapshot is authoritative for the current context; drop any prior enter/leave stash.
5492        self.z_bands_outdoor_backup = None;
5493        self.buildings = snapshot.buildings.clone();
5494        self.doors = snapshot.doors.clone();
5495        self.interior_map = snapshot.interior_map.clone();
5496        self.npcs = snapshot.npcs.clone();
5497        self.blueprints = snapshot.blueprints.clone();
5498        self.building_materials = snapshot.building_materials.clone();
5499        self.sync_inventory_from_stacks(&snapshot.inventory);
5500        self.player = snapshot
5501            .entities
5502            .iter()
5503            .find(|e| e.id == entity_id)
5504            .cloned();
5505        self.entities = snapshot.entities.clone();
5506        self.quest_log = snapshot.quest_log.clone();
5507        self.apply_hired_workers(snapshot.hired_workers.clone());
5508        self.interactables = snapshot.interactables.clone();
5509        self.ledger = snapshot.ledger.clone();
5510        self.career = snapshot.career.clone();
5511        self.combat_fx = snapshot.combat_fx.clone();
5512        self.ground_hazards = snapshot.ground_hazards.clone();
5513        self.property_zones = snapshot.property_zones.clone();
5514        self.tax_zones = snapshot.tax_zones.clone();
5515        self.growth_zones = snapshot.growth_zones.clone();
5516        self.biome_zones = snapshot.biome_zones.clone();
5517        self.terrain_kind_nav = snapshot.terrain_kind_nav.clone();
5518        self.property_plots = snapshot.property_plots.clone();
5519        self.property_plot_settings = snapshot.property_plot_settings.clone();
5520        self.sync_item_catalog(&snapshot.item_catalog);
5521        // If welcome/reconnect lands indoors, seed an empty outdoor restore target so
5522        // sync_interior_z_bands does not stash interior snapshot bands as "outdoor".
5523        if self.effective_inside_building().is_some() {
5524            self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
5525        }
5526        self.sync_interior_map_context();
5527        self.refresh_whisper_range();
5528        self.sync_gameplay_audio();
5529    }
5530
5531    fn replace_harvest_route_nodes(&mut self, incoming: &[flatland_protocol::ResourceNodeView]) {
5532        self.harvest_route_nodes = incoming
5533            .iter()
5534            .filter(|n| crate::is_harvest_route_node(n))
5535            .cloned()
5536            .collect();
5537    }
5538
5539    fn upsert_harvest_route_nodes(&mut self, incoming: &[flatland_protocol::ResourceNodeView]) {
5540        for node in incoming.iter().filter(|n| crate::is_harvest_route_node(n)) {
5541            if let Some(existing) = self
5542                .harvest_route_nodes
5543                .iter_mut()
5544                .find(|n| n.id == node.id)
5545            {
5546                *existing = node.clone();
5547            } else {
5548                self.harvest_route_nodes.push(node.clone());
5549            }
5550        }
5551    }
5552
5553    /// Re-derive inventory selection state after a snapshot/tick — chests that
5554    /// went out of range or got locked simply vanish from the row list, and the
5555    /// move picker (if any) closes once its item is no longer reachable.
5556    fn refresh_inventory_ui(&mut self) {
5557        if let Some(picker) = &self.move_picker {
5558            let instance_id = picker.item_instance_id;
5559            let still_exists = self
5560                .inventory_selectable_rows()
5561                .iter()
5562                .any(|r| r.stack.item_instance_id == Some(instance_id));
5563            if !still_exists {
5564                self.move_picker = None;
5565                self.show_move_picker = false;
5566            }
5567        }
5568        if let Some(picker) = &self.destroy_picker {
5569            let instance_id = picker.item_instance_id;
5570            let still_exists = self
5571                .inventory_selectable_rows()
5572                .iter()
5573                .any(|r| r.stack.item_instance_id == Some(instance_id));
5574            if !still_exists {
5575                self.destroy_picker = None;
5576                self.show_destroy_picker = false;
5577                self.destroy_confirm_pending = false;
5578            }
5579        }
5580        self.clamp_inventory_indices();
5581    }
5582
5583    /// Replace the hired-worker list from a snapshot/delta.
5584    ///
5585    /// Keeps a stable sort and remaps `workers_menu_index` by instance id so the
5586    /// selected worker (and its step line) does not jump when the server rebuilds
5587    /// the list.
5588    fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
5589        let selected_id = self
5590            .hired_workers
5591            .get(self.workers_menu_index)
5592            .map(|w| w.instance_id.clone());
5593        let previous_worker_ids: HashSet<String> = self
5594            .hired_workers
5595            .iter()
5596            .map(|worker| worker.instance_id.clone())
5597            .collect();
5598        workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
5599        let now = Instant::now();
5600        let saw_new_worker = workers
5601            .iter()
5602            .any(|worker| !previous_worker_ids.contains(&worker.instance_id));
5603        for worker in &workers {
5604            let was_hit = self
5605                .hired_workers
5606                .iter()
5607                .find(|previous| previous.instance_id == worker.instance_id)
5608                .is_some_and(|previous| {
5609                    matches!(worker.mode, flatland_protocol::WorkerModeView::Defender)
5610                        && worker.vitals.health_pct + 0.01 < previous.vitals.health_pct
5611                });
5612            if was_hit {
5613                self.worker_health_ring_until
5614                    .insert(worker.entity_id, now + WORKER_HEALTH_RING_HOLD);
5615            }
5616        }
5617        let worker_entity_ids: HashSet<EntityId> =
5618            workers.iter().map(|worker| worker.entity_id).collect();
5619        self.worker_health_ring_until
5620            .retain(|entity_id, _| worker_entity_ids.contains(entity_id));
5621        for w in &workers {
5622            let prev_err = self
5623                .hired_workers
5624                .iter()
5625                .find(|p| p.instance_id == w.instance_id)
5626                .and_then(|p| p.last_error.as_deref());
5627            let new_err = w.last_error.as_deref();
5628            if new_err != prev_err {
5629                if let Some(err) = new_err {
5630                    if !worker_error_is_transient(err) {
5631                        self.push_log(format!("Worker {}: {err}", w.label));
5632                    }
5633                }
5634            }
5635        }
5636        let mut next_display = BTreeMap::new();
5637        let mut next_errors = BTreeMap::new();
5638        for w in &workers {
5639            let mut sticky = self
5640                .worker_step_display
5641                .remove(&w.instance_id)
5642                .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
5643            sticky.observe(&w.step_label, now);
5644            next_display.insert(w.instance_id.clone(), sticky);
5645
5646            let mut err_sticky = self
5647                .worker_error_display
5648                .remove(&w.instance_id)
5649                .unwrap_or_default();
5650            err_sticky.observe(w.last_error.as_deref(), now);
5651            if err_sticky.shown(now).is_some() {
5652                next_errors.insert(w.instance_id.clone(), err_sticky);
5653            }
5654        }
5655        self.worker_step_display = next_display;
5656        self.worker_error_display = next_errors;
5657        self.hired_workers = workers;
5658        if saw_new_worker {
5659            self.pending_worker_hire_since = None;
5660        }
5661        self.sync_worker_take_picker_from_hired();
5662        if let Some(id) = selected_id {
5663            if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
5664                self.workers_menu_index = idx;
5665                return;
5666            }
5667        }
5668        if self.workers_menu_index >= self.hired_workers.len() {
5669            self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
5670        }
5671    }
5672
5673    /// Keep the take-from-worker sheet in sync after hired-worker inventory updates.
5674    fn sync_worker_take_picker_from_hired(&mut self) {
5675        if !self.show_worker_take_picker {
5676            return;
5677        }
5678        let Some(picker) = self.worker_take_picker.clone() else {
5679            return;
5680        };
5681        let Some(worker) = self
5682            .hired_workers
5683            .iter()
5684            .find(|w| w.instance_id == picker.worker_instance_id)
5685            .cloned()
5686        else {
5687            self.show_worker_take_picker = false;
5688            self.worker_take_picker = None;
5689            self.worker_take_picker_index = 0;
5690            return;
5691        };
5692        let options: Vec<WorkerGiveOption> = worker
5693            .inventory
5694            .iter()
5695            .filter_map(|stack| {
5696                let item_instance_id = stack.item_instance_id?;
5697                let label = stack
5698                    .display_name
5699                    .clone()
5700                    .unwrap_or_else(|| stack.template_id.clone());
5701                let label = if stack.quantity > 1 {
5702                    format!("{label} ×{}", stack.quantity)
5703                } else {
5704                    label
5705                };
5706                Some(WorkerGiveOption {
5707                    item_instance_id,
5708                    label,
5709                    quantity: stack.quantity,
5710                    template_id: stack.template_id.clone(),
5711                })
5712            })
5713            .collect();
5714        if options.is_empty() {
5715            self.show_worker_take_picker = false;
5716            self.worker_take_picker = None;
5717            self.worker_take_picker_index = 0;
5718            return;
5719        }
5720        let prev_id = picker
5721            .options
5722            .get(self.worker_take_picker_index)
5723            .map(|o| o.item_instance_id);
5724        let idx = prev_id
5725            .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
5726            .unwrap_or(0)
5727            .min(options.len().saturating_sub(1));
5728        let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
5729        let quantity = picker.quantity.clamp(1, max_qty);
5730        self.worker_take_picker_index = idx;
5731        self.worker_take_picker = Some(WorkerTakePicker {
5732            worker_instance_id: picker.worker_instance_id,
5733            worker_label: picker.worker_label,
5734            options,
5735            quantity,
5736        });
5737    }
5738
5739    /// Held coarse step label for the workers menu (`step:` line).
5740    pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
5741        self.worker_step_display
5742            .get(worker_instance_id)
5743            .map(|s| s.shown.as_str())
5744            .or_else(|| {
5745                self.hired_workers
5746                    .iter()
5747                    .find(|w| w.instance_id == worker_instance_id)
5748                    .map(|w| w.step_label.as_str())
5749            })
5750            .unwrap_or("")
5751    }
5752
5753    /// Held error line for workers UI (detail + compact).
5754    pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
5755        let now = Instant::now();
5756        self.worker_error_display
5757            .get(worker_instance_id)
5758            .and_then(|s| s.shown(now))
5759            .or_else(|| {
5760                self.hired_workers
5761                    .iter()
5762                    .find(|w| w.instance_id == worker_instance_id)
5763                    .and_then(|w| w.last_error.as_deref())
5764                    .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
5765            })
5766            .filter(|e| !worker_error_is_hud_noise(e))
5767    }
5768
5769    fn apply_combat_hud(&mut self, combat: &CombatHud) {
5770        self.in_combat = combat.in_combat;
5771        self.auto_attack = combat.auto_attack;
5772        self.combat_has_los = combat.has_los;
5773        self.attack_cd_ticks = combat.attack_cd_ticks;
5774        self.gcd_ticks = combat.gcd_ticks;
5775        self.weapon_ability_id = combat.ability_id.clone();
5776        self.mainhand_template_id = combat.mainhand_template_id.clone();
5777        self.mainhand_label = combat.mainhand_label.clone();
5778        self.mainhand_instance_id = combat.mainhand_instance_id;
5779        self.offhand_template_id = combat.offhand_template_id.clone();
5780        self.offhand_label = combat.offhand_label.clone();
5781        self.offhand_instance_id = combat.offhand_instance_id;
5782        self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
5783            1
5784        } else {
5785            combat.mainhand_hand_slots
5786        };
5787        self.defense = combat.defense.clone();
5788        self.worn = combat.worn.iter().cloned().collect();
5789        self.carry_mass = combat.carry_mass;
5790        self.carry_mass_max = combat.carry_mass_max;
5791        self.encumbrance = combat.encumbrance;
5792        self.move_speed_mps = combat.move_speed_mps;
5793        self.move_speed_mult = combat.move_speed_mult;
5794        self.cast_progress = combat.cast.clone();
5795        self.timed_channel = combat.timed_channel.clone();
5796        if self.active_craft_channel().is_none() {
5797            self.craft_channel_blueprint_id = None;
5798        }
5799        self.plot_build_offer = combat.plot_build.clone();
5800        self.ability_cooldowns = combat.ability_cooldowns.clone();
5801        self.blocking_active = combat.blocking_active;
5802        self.max_target_slots = combat.max_target_slots.max(1);
5803        self.combat_slots = combat.slots.clone();
5804        self.rotation_presets = combat.rotation_presets.clone();
5805        self.known_abilities = combat.known_abilities.clone();
5806        self.ability_meta = combat
5807            .ability_meta
5808            .iter()
5809            .cloned()
5810            .map(|meta| (meta.id.clone(), meta))
5811            .collect();
5812        self.ability_mastery = combat
5813            .ability_mastery
5814            .iter()
5815            .cloned()
5816            .map(|row| (row.ability_id.clone(), row))
5817            .collect();
5818        self.hotbar = combat.hotbar.clone();
5819        self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
5820        self.keychain_stacks = combat.keychain.clone();
5821        self.whisper_pouch_stacks = combat.whisper_pouch.clone();
5822        self.combat_target_detail = combat.target.clone();
5823        self.statuses = combat.statuses.clone();
5824        self.combat_target = combat.target_entity_id;
5825        if combat.progression_xp_base > 0.0 {
5826            self.progression_curve = Some(flatland_protocol::ProgressionCurve {
5827                baseline_display: combat.progression_baseline,
5828                xp_base: combat.progression_xp_base,
5829                xp_growth: combat.progression_xp_growth,
5830            });
5831        }
5832        if let Some(xp) = &combat.progression_xp {
5833            if let Some(player) = &mut self.player {
5834                player.progression_xp = Some(xp.clone());
5835                if let Some(attrs) = combat.attributes {
5836                    player.attributes = Some(attrs);
5837                }
5838                if let Some(skills) = &combat.skills {
5839                    player.skills = Some(skills.clone());
5840                }
5841            }
5842        }
5843        if let Some(label) = &combat.target_label {
5844            self.combat_target_label = Some(label.clone());
5845        } else if let Some(id) = combat.target_entity_id {
5846            self.combat_target_label = self
5847                .entities
5848                .iter()
5849                .find(|e| e.id == id)
5850                .map(|e| e.label.clone())
5851                .or_else(|| self.combat_target_label.clone());
5852        }
5853        self.refresh_inventory_ui();
5854    }
5855
5856    /// Entity id assigned to a combat slot (from server HUD).
5857    pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
5858        self.combat_slots
5859            .iter()
5860            .find(|s| s.slot_index == slot)
5861            .and_then(|s| s.target_entity_id)
5862            .or_else(|| if slot == 1 { self.combat_target } else { None })
5863    }
5864
5865    /// True if `ability_id` may be cast at a free ground point (`aim_mode` `ground` or `either`).
5866    pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
5867        self.ability_meta
5868            .get(ability_id)
5869            .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
5870            // Until HUD meta syncs, still allow ground cast when an aim point is armed —
5871            // the server validates aim_mode.
5872            .unwrap_or(self.ground_target.is_some())
5873    }
5874
5875    /// True if `ability_id` requires a free ground point (`aim_mode` `ground`).
5876    pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
5877        self.ability_meta
5878            .get(ability_id)
5879            .map(|meta| meta.aim_mode == "ground")
5880            .unwrap_or(false)
5881    }
5882
5883    /// True when the ability may be placed in an auto-rotation preset.
5884    /// Missing meta (pre-sync) defaults to eligible; server still validates.
5885    pub fn ability_auto_rotation_eligible(&self, ability_id: &str) -> bool {
5886        self.ability_meta
5887            .get(ability_id)
5888            .map(|meta| meta.auto_rotation_eligible)
5889            .unwrap_or(true)
5890    }
5891
5892    /// Set the local free-aim ground target (world meters, Shift+click on open ground).
5893    pub fn set_ground_target(&mut self, x: f32, y: f32) {
5894        self.ground_target = Some((x, y, 0.0));
5895    }
5896
5897    /// Clear the local free-aim ground target.
5898    pub fn clear_ground_target(&mut self) {
5899        self.ground_target = None;
5900    }
5901
5902    /// Ability bound to hotbar key `1`–`9` from server-persisted state.
5903    /// May be an ability id or `item:<template_id>` consumable binding.
5904    pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
5905        if !(1..=9).contains(&slot_1_to_9) {
5906            return None;
5907        }
5908        self.hotbar
5909            .get((slot_1_to_9 - 1) as usize)
5910            .and_then(|a| a.as_deref())
5911            .filter(|id| !id.is_empty())
5912    }
5913
5914    /// Short label for a hotbar slot (ability id, or consumable name × qty).
5915    pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
5916        let binding = self.hotbar_ability(slot_1_to_9)?;
5917        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
5918            let name = self
5919                .inventory_hints
5920                .get(template_id)
5921                .map(|h| h.display_name.as_str())
5922                .unwrap_or(template_id);
5923            let qty = self.inventory.get(template_id).copied().unwrap_or(0);
5924            Some(format!("{name}×{qty}"))
5925        } else {
5926            Some(binding.to_string())
5927        }
5928    }
5929
5930    /// Known abilities plus current weapon ability (for loadout / rotation pickers).
5931    pub fn loadout_ability_choices(&self) -> Vec<String> {
5932        let mut out = self.known_abilities.clone();
5933        let weapon = self.weapon_ability_id.trim();
5934        if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
5935            out.push(weapon.to_string());
5936        }
5937        out
5938    }
5939
5940    /// Hotbar bind candidates: learned abilities, then on-person consumables.
5941    pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
5942        let mut out = Vec::new();
5943        for ability in self.loadout_ability_choices() {
5944            let meta = if ability == self.weapon_ability_id {
5945                Some("weapon".into())
5946            } else {
5947                None
5948            };
5949            out.push(LoadoutHotbarChoice {
5950                binding: ability.clone(),
5951                label: ability,
5952                meta,
5953            });
5954        }
5955        let mut consumables: Vec<(String, String, u32)> = Vec::new();
5956        for stack in &self.inventory_stacks {
5957            if Self::stack_is_item_grant(stack) {
5958                continue;
5959            }
5960            if Self::stack_is_blueprint_scroll(stack) {
5961                continue;
5962            }
5963            if self.inventory_item_category(&stack.template_id) != Some("consumable")
5964                && !Self::stack_is_serving(stack)
5965            {
5966                continue;
5967            }
5968            let qty = stack.quantity.max(1);
5969            if let Some((_, _, existing)) = consumables
5970                .iter_mut()
5971                .find(|(id, _, _)| id == &stack.template_id)
5972            {
5973                *existing = existing.saturating_add(qty);
5974            } else {
5975                let label = stack
5976                    .display_name
5977                    .clone()
5978                    .or_else(|| {
5979                        self.inventory_hints
5980                            .get(&stack.template_id)
5981                            .map(|h| h.display_name.clone())
5982                    })
5983                    .unwrap_or_else(|| stack.template_id.clone());
5984                consumables.push((stack.template_id.clone(), label, qty));
5985            }
5986        }
5987        consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
5988        for (template_id, label, qty) in consumables {
5989            out.push(LoadoutHotbarChoice {
5990                binding: flatland_protocol::hotbar_consumable_binding(&template_id),
5991                label: format!("{label} ×{qty}"),
5992                meta: Some("use".into()),
5993            });
5994        }
5995        out
5996    }
5997
5998    /// Hostile wildlife / monsters for T1 (`Tab`).
5999    pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
6000        self.combat_candidates()
6001    }
6002
6003    /// Allies first, then monsters, for T2 (`Shift+Tab`). Includes self for heals.
6004    pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
6005        let (px, py) = self.player_position();
6006        let dist = |id: EntityId| {
6007            self.entities
6008                .iter()
6009                .find(|e| e.id == id)
6010                .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
6011                .unwrap_or(f32::MAX)
6012        };
6013
6014        let mut allies = Vec::new();
6015        // Self first — heal_touch on T2.
6016        if let Some(me) = self.player.as_ref() {
6017            let alive = me
6018                .vitals
6019                .as_ref()
6020                .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
6021                .unwrap_or(true);
6022            if alive {
6023                allies.push((self.entity_id, "Yourself".into()));
6024            }
6025        }
6026        for entity in &self.entities {
6027            if entity.id == self.entity_id {
6028                continue;
6029            }
6030            if entity.vitals.is_some() {
6031                let alive = entity
6032                    .vitals
6033                    .as_ref()
6034                    .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
6035                    .unwrap_or(true);
6036                if alive {
6037                    allies.push((entity.id, entity.label.clone()));
6038                }
6039            }
6040        }
6041        allies.sort_by(|(a, _), (b, _)| {
6042            if *a == self.entity_id {
6043                return std::cmp::Ordering::Less;
6044            }
6045            if *b == self.entity_id {
6046                return std::cmp::Ordering::Greater;
6047            }
6048            dist(*a)
6049                .partial_cmp(&dist(*b))
6050                .unwrap_or(std::cmp::Ordering::Equal)
6051        });
6052
6053        let mut monsters = self.combat_candidates();
6054        monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
6055        allies.into_iter().chain(monsters).collect()
6056    }
6057
6058    fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
6059        match slot_index {
6060            2 => self.t2_candidates(),
6061            _ => self.t1_candidates(),
6062        }
6063    }
6064
6065    /// Nearest combat candidate within `radius_m` of world click (gfx click-to-target).
6066    pub fn pick_combat_target_at(
6067        &self,
6068        wx: f32,
6069        wy: f32,
6070        slot_index: u8,
6071        radius_m: f32,
6072    ) -> Option<(EntityId, String)> {
6073        let mut best: Option<(f32, EntityId, String)> = None;
6074        for (id, label) in self.candidates_for_slot(slot_index) {
6075            let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
6076                // NPCs may only appear in NpcView — fall back to npc list coords.
6077                if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
6078                    let d = distance(wx, wy, npc.x, npc.y);
6079                    if d <= radius_m {
6080                        best = match best {
6081                            Some((bd, _, _)) if bd <= d => best,
6082                            _ => Some((d, id, label)),
6083                        };
6084                    }
6085                }
6086                continue;
6087            };
6088            let d = distance(
6089                wx,
6090                wy,
6091                entity.transform.position.x,
6092                entity.transform.position.y,
6093            );
6094            if d <= radius_m {
6095                best = match best {
6096                    Some((bd, _, _)) if bd <= d => best,
6097                    _ => Some((d, id, label)),
6098                };
6099            }
6100        }
6101        best.map(|(_, id, label)| (id, label))
6102    }
6103
6104    /// Full client reset after Welcome (initial connect or reconnect).
6105    pub(crate) fn restore_from_welcome(
6106        &mut self,
6107        session_id: SessionId,
6108        entity_id: EntityId,
6109        snapshot: &flatland_protocol::Snapshot,
6110    ) {
6111        self.clear_harvest_state();
6112        self.disconnect_reason = None;
6113        self.show_stats = false;
6114        self.show_craft_menu = false;
6115        self.show_shop_menu = false;
6116        self.shop_catalog = None;
6117        self.show_inventory_menu = false;
6118        self.session_id = session_id;
6119        self.entity_id = entity_id;
6120        self.connected = true;
6121        self.apply_snapshot_fields(snapshot, entity_id);
6122        if let Some(combat) = &snapshot.combat {
6123            self.apply_combat_hud(combat);
6124            let stacks = self.inventory_stacks.clone();
6125            self.sync_inventory_from_stacks(&stacks);
6126        }
6127    }
6128
6129    fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
6130        self.tick = delta.tick;
6131        self.world_clock = delta.world_clock;
6132
6133        // Degenerate AOI tick (observer missing server-side): keep welcome snapshot layers.
6134        if delta.entities.is_empty() {
6135            self.ground_drops = delta.ground_drops.clone();
6136            self.combat_fx = delta.combat_fx.clone();
6137            self.ground_hazards = delta.ground_hazards.clone();
6138            self.property_plots = delta.property_plots.clone();
6139            self.apply_terrain_overlays(&delta.terrain_overlays);
6140            if let Some(combat) = &delta.combat {
6141                self.apply_combat_hud(combat);
6142                let stacks = self.inventory_stacks.clone();
6143                self.sync_inventory_from_stacks(&stacks);
6144            }
6145            // Peer vanished from AOI — drop directed whisper.
6146            self.refresh_whisper_range();
6147            self.sync_gameplay_audio();
6148            return;
6149        }
6150        if !delta.buildings.is_empty() {
6151            self.buildings = delta.buildings.clone();
6152        }
6153        if !delta.blueprints.is_empty() {
6154            self.blueprints = delta.blueprints.clone();
6155        }
6156        if !delta.building_materials.is_empty() {
6157            self.building_materials = delta.building_materials.clone();
6158        }
6159        self.sync_inventory_from_stacks(&delta.inventory);
6160
6161        if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
6162            self.player = Some(updated.clone());
6163        }
6164        self.entities = delta.entities.clone();
6165        if self.player.is_none() {
6166            self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
6167        }
6168
6169        self.sync_interior_map_context();
6170
6171        // Static world layers: ticks often omit these (indoors, or unchanged).
6172        // Never wipe the welcome snapshot with an empty vec — except when indoors,
6173        // where empty means "no nodes in this building" (clear outdoor leftovers).
6174        if !delta.resource_nodes.is_empty() {
6175            self.resource_nodes = delta.resource_nodes.clone();
6176            self.upsert_harvest_route_nodes(&delta.resource_nodes);
6177        } else if delta.interior_map.is_some() || self.effective_inside_building().is_some() {
6178            self.resource_nodes = delta.resource_nodes.clone();
6179        }
6180        self.ground_drops = delta.ground_drops.clone();
6181        // Always replace — empty indoors means no chests in this building (do not keep outdoor ghosts).
6182        self.placed_containers = delta.placed_containers.clone();
6183        if !delta.doors.is_empty() {
6184            self.doors = delta.doors.clone();
6185        }
6186        if self.effective_inside_building().is_some() {
6187            if let Some(map) = &delta.interior_map {
6188                self.interior_map = Some(map.clone());
6189            }
6190        } else {
6191            self.interior_map = None;
6192        }
6193        self.sync_interior_z_bands();
6194        // Always replace NPC AOI — empty means none in range (do not keep ghosts).
6195        self.npcs = delta.npcs.clone();
6196        if !delta.quest_log.is_empty() {
6197            self.quest_log = delta.quest_log.clone();
6198        }
6199        self.apply_hired_workers(delta.hired_workers.clone());
6200        if !delta.interactables.is_empty() {
6201            self.interactables = delta.interactables.clone();
6202        }
6203        if delta.ledger.is_some() {
6204            self.ledger = delta.ledger.clone();
6205        }
6206        if delta.career.is_some() {
6207            self.career = delta.career.clone();
6208        }
6209        self.combat_fx = delta.combat_fx.clone();
6210        self.ground_hazards = delta.ground_hazards.clone();
6211        // Empty = unchanged (server may stagger plot rebuilds); non-empty replaces.
6212        if !delta.property_plots.is_empty() {
6213            self.property_plots = delta.property_plots.clone();
6214        }
6215        self.apply_terrain_overlays(&delta.terrain_overlays);
6216        if let Some(combat) = &delta.combat {
6217            self.apply_combat_hud(combat);
6218            let stacks = self.inventory_stacks.clone();
6219            self.sync_inventory_from_stacks(&stacks);
6220        } else {
6221            self.refresh_inventory_ui();
6222        }
6223        self.refresh_whisper_range();
6224        self.sync_gameplay_audio();
6225    }
6226
6227    /// Merge runtime cultivate overlays into `terrain_zones` (ids prefixed `rt:`).
6228    /// Always replaces prior `rt:` entries with the server's near-set (empty = none nearby).
6229    fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
6230        self.terrain_zones.retain(|z| !z.id.starts_with("rt:"));
6231        self.terrain_zones.extend(overlays.iter().cloned());
6232    }
6233
6234    /// End directed whisper when the peer is farther than interact range (or missing).
6235    /// Each client runs this locally so both players drop whisper mode when either walks away.
6236    fn refresh_whisper_range(&mut self) {
6237        let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
6238            return;
6239        };
6240        let (px, py) = self.player_position();
6241        let in_range = self.entities.iter().any(|e| {
6242            e.id == peer
6243                && distance(px, py, e.transform.position.x, e.transform.position.y)
6244                    <= INTERACTION_RADIUS_M
6245        });
6246        if !in_range {
6247            self.social_chat.cancel_whisper_out_of_range();
6248        }
6249    }
6250
6251    /// Wildlife and other combat targets visible in AOI (`NpcView.entity_id`).
6252    pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
6253        let (px, py) = self.player_position();
6254        let mut out = Vec::new();
6255        for npc in &self.npcs {
6256            let Some(eid) = npc.entity_id else {
6257                continue;
6258            };
6259            let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
6260            let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
6261            if alive && has_hp {
6262                out.push((eid, npc.label.clone()));
6263            }
6264        }
6265        out.sort_by(|(a_id, a_label), (b_id, b_label)| {
6266            let dist = |id: EntityId| {
6267                self.entities
6268                    .iter()
6269                    .find(|e| e.id == id)
6270                    .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
6271                    .unwrap_or(f32::MAX)
6272            };
6273            dist(*a_id)
6274                .partial_cmp(&dist(*b_id))
6275                .unwrap_or(std::cmp::Ordering::Equal)
6276                .then_with(|| a_label.cmp(b_label))
6277                .then_with(|| a_id.cmp(b_id))
6278        });
6279        out
6280    }
6281
6282    pub fn refresh_combat_target_label(&mut self) {
6283        let Some(id) = self.combat_target else {
6284            return;
6285        };
6286        if let Some((_, label)) = self
6287            .combat_candidates()
6288            .into_iter()
6289            .find(|(eid, _)| *eid == id)
6290        {
6291            self.combat_target_label = Some(label);
6292        } else if let Some(label) = self
6293            .entities
6294            .iter()
6295            .find(|e| e.id == id)
6296            .map(|e| e.label.clone())
6297        {
6298            self.combat_target_label = Some(label);
6299        }
6300    }
6301
6302    pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
6303        self.quest_log
6304            .iter()
6305            .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
6306            .collect()
6307    }
6308
6309    /// True when the observer has at least one free lodging slot (max workers = beds).
6310    pub fn has_worker_lodging(&self) -> bool {
6311        self.free_worker_lodging_slots() > 0
6312    }
6313
6314    /// Owned lodging capacity minus currently hired workers.
6315    pub fn free_worker_lodging_slots(&self) -> i64 {
6316        let slots: u32 = self
6317            .placed_containers
6318            .iter()
6319            .filter(|c| match (self.character_id, c.owner_character_id) {
6320                (Some(me), Some(owner)) => me == owner,
6321                (Some(_), None) => false,
6322                (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
6323            })
6324            .map(|c| c.worker_lodging_capacity.unwrap_or(0))
6325            .sum();
6326        let used = self.hired_workers.len() as u32;
6327        slots as i64 - used as i64
6328    }
6329
6330    /// Display names of hired workers assigned to this lodging container.
6331    pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
6332        let mut names: Vec<String> = self
6333            .hired_workers
6334            .iter()
6335            .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
6336            .map(|w| w.label.clone())
6337            .collect();
6338        names.sort();
6339        names
6340    }
6341
6342    /// Compact lodging occupancy for labels: `"Elda, Ana"`, `"vacant"`, or `""` if not lodging.
6343    pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
6344        let is_lodging = self
6345            .placed_containers
6346            .iter()
6347            .find(|c| c.id == container_id)
6348            .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
6349        if !is_lodging {
6350            return None;
6351        }
6352        let names = self.lodging_occupant_labels(container_id);
6353        Some(if names.is_empty() {
6354            "vacant".into()
6355        } else {
6356            names.join(", ")
6357        })
6358    }
6359
6360    /// True when a lodging bed has at least one hired worker assigned.
6361    pub fn lodging_is_occupied(&self, container_id: &str) -> bool {
6362        matches!(
6363            self.lodging_occupancy_label(container_id),
6364            Some(label) if label != "vacant"
6365        )
6366    }
6367
6368    pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
6369        self.quest_log
6370            .iter()
6371            .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
6372            .or_else(|| {
6373                self.quest_log
6374                    .iter()
6375                    .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
6376            })
6377    }
6378
6379    /// True when a lockable exterior door is within interact range (prefer over loadout `l`).
6380    pub fn nearby_lockable_door(&self) -> bool {
6381        let (px, py) = self.player_position();
6382        self.doors
6383            .iter()
6384            .any(|d| d.lock_id.is_some() && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M)
6385    }
6386
6387    /// True when an open player-house door is in range for Enter-to-go-inside.
6388    pub fn nearby_open_player_door(&self) -> bool {
6389        if self.effective_inside_building().is_some() {
6390            return false;
6391        }
6392        let (px, py) = self.player_position();
6393        self.doors.iter().any(|d| {
6394            if !d.open || d.locked {
6395                return false;
6396            }
6397            let player_house = self
6398                .buildings
6399                .iter()
6400                .find(|b| b.id == d.building_id)
6401                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6402            player_house && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M
6403        })
6404    }
6405
6406    /// True when inside a player house near the exterior portal (Enter exits freely).
6407    pub fn nearby_player_exit_door(&self) -> bool {
6408        let Some(bid) = self.effective_inside_building() else {
6409            return false;
6410        };
6411        let (px, py) = self.player_position();
6412        self.doors.iter().any(|d| {
6413            if d.building_id != bid || d.portal.is_none() {
6414                return false;
6415            }
6416            let player_house = self
6417                .buildings
6418                .iter()
6419                .find(|b| b.id == d.building_id)
6420                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6421            player_house && (d.x - px).hypot(d.y - py) <= 1.5
6422        })
6423    }
6424
6425    /// Nearest interactable target for `f` (doors, NPCs, quest boards).
6426    pub fn nearest_interact_target(&self) -> Option<String> {
6427        let (px, py) = self.player_position();
6428        let inside = self.effective_inside_building();
6429
6430        #[derive(Clone, Copy, PartialEq, Eq)]
6431        enum Kind {
6432            Player,
6433            Npc,
6434            HiredWorker,
6435            QuestBoard,
6436            ExitDoor,
6437            EnterDoor,
6438        }
6439
6440        fn kind_class(kind: Kind) -> u8 {
6441            match kind {
6442                Kind::EnterDoor => 0,
6443                Kind::QuestBoard => 1,
6444                Kind::Player | Kind::Npc => 2,
6445                Kind::ExitDoor => 3,
6446                Kind::HiredWorker => 4,
6447            }
6448        }
6449
6450        fn kind_priority(kind: Kind) -> u8 {
6451            match kind {
6452                Kind::EnterDoor => 0,
6453                Kind::QuestBoard => 1,
6454                Kind::Player | Kind::Npc => 2,
6455                Kind::ExitDoor => 3,
6456                Kind::HiredWorker => 4,
6457            }
6458        }
6459
6460        let mut best: Option<(f32, Kind, String)> = None;
6461
6462        let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
6463            if dist > max {
6464                return;
6465            }
6466            let replace = match best {
6467                None => true,
6468                Some((_, bk, _)) if kind_class(kind) < kind_class(bk) => true,
6469                Some((bd, bk, _)) if kind_class(kind) == kind_class(bk) && dist < bd - 0.05 => true,
6470                Some((bd, bk, _))
6471                    if kind_class(kind) == kind_class(bk) && (dist - bd).abs() <= 0.05 =>
6472                {
6473                    kind_priority(kind) < kind_priority(bk)
6474                }
6475                _ => false,
6476            };
6477            if replace {
6478                best = Some((dist, kind, id));
6479            }
6480        };
6481
6482        for npc in &self.npcs {
6483            consider(
6484                distance(px, py, npc.x, npc.y),
6485                INTERACTION_RADIUS_M,
6486                Kind::Npc,
6487                npc.id.clone(),
6488            );
6489        }
6490
6491        for worker in &self.hired_workers {
6492            consider(
6493                distance(px, py, worker.x, worker.y),
6494                INTERACTION_RADIUS_M,
6495                Kind::HiredWorker,
6496                worker.instance_id.clone(),
6497            );
6498        }
6499
6500        for entity in &self.entities {
6501            if entity.id == self.entity_id
6502                || entity.vitals.is_none()
6503                || entity.label.trim().is_empty()
6504            {
6505                continue;
6506            }
6507            // Hired workers are NPCs with their own UI — not Whisper/Trade peers.
6508            if self.hired_workers.iter().any(|w| w.entity_id == entity.id) {
6509                continue;
6510            }
6511            consider(
6512                distance(
6513                    px,
6514                    py,
6515                    entity.transform.position.x,
6516                    entity.transform.position.y,
6517                ),
6518                INTERACTION_RADIUS_M,
6519                Kind::Player,
6520                entity.id.to_string(),
6521            );
6522        }
6523
6524        for door in &self.doors {
6525            if let Some(ref bid) = inside {
6526                if door.building_id != *bid {
6527                    continue;
6528                }
6529                let is_exit = door.portal.is_some();
6530                let max = if is_exit {
6531                    INTERACTION_RADIUS_M
6532                } else {
6533                    DOOR_INTERACTION_RADIUS_M
6534                };
6535                let kind = if is_exit {
6536                    Kind::ExitDoor
6537                } else {
6538                    Kind::EnterDoor
6539                };
6540                consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
6541                continue;
6542            }
6543            consider(
6544                distance(px, py, door.x, door.y),
6545                DOOR_INTERACTION_RADIUS_M,
6546                Kind::EnterDoor,
6547                door.id.clone(),
6548            );
6549        }
6550
6551        if inside.is_none() {
6552            for inter in &self.interactables {
6553                if inter.kind == "quest_board" {
6554                    consider(
6555                        distance(px, py, inter.x, inter.y),
6556                        QUEST_BOARD_INTERACTION_RADIUS_M,
6557                        Kind::QuestBoard,
6558                        inter.id.clone(),
6559                    );
6560                }
6561            }
6562        }
6563
6564        best.map(|(_, _, id)| id)
6565    }
6566
6567    /// Nearest quest board and distance (any distance), for out-of-range feedback.
6568    pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
6569        if self.effective_inside_building().is_some() {
6570            return None;
6571        }
6572        let (px, py) = self.player_position();
6573        self.interactables
6574            .iter()
6575            .filter(|i| i.kind == "quest_board")
6576            .map(|i| {
6577                let label = if i.label.is_empty() {
6578                    "Quest board".to_string()
6579                } else {
6580                    i.label.clone()
6581                };
6582                (label, distance(px, py, i.x, i.y))
6583            })
6584            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
6585    }
6586
6587    /// Human-readable template name from inventory hints, then the synced item catalog.
6588    pub fn template_display_name(&self, template_id: &str) -> String {
6589        if let Some(name) = self
6590            .inventory_hints
6591            .get(template_id)
6592            .map(|h| h.display_name.clone())
6593            .filter(|n| !n.is_empty())
6594        {
6595            return name;
6596        }
6597        if let Some(entry) = self.item_catalog.get(template_id) {
6598            if !entry.display_name.trim().is_empty() {
6599                return entry.display_name.clone();
6600            }
6601        }
6602        humanize_template_id(template_id)
6603    }
6604
6605    /// Player-facing worker-route stop line. Item names from the catalog, never raw template ids.
6606    pub fn worker_route_stop_summary(
6607        &self,
6608        stop: &crate::worker_route_editor::WorkerRouteStop,
6609    ) -> String {
6610        stop.summary_resolved(
6611            |id| {
6612                self.placed_containers
6613                    .iter()
6614                    .find(|c| c.id == id)
6615                    .map(|c| c.display_name.clone())
6616                    .filter(|n| !n.trim().is_empty())
6617                    .unwrap_or_else(|| "storage".to_string())
6618            },
6619            |id| {
6620                self.npcs
6621                    .iter()
6622                    .find(|n| n.id == id)
6623                    .map(|n| n.label.clone())
6624                    .filter(|s| !s.trim().is_empty())
6625                    .unwrap_or_else(|| "merchant".to_string())
6626            },
6627            |id| {
6628                self.harvest_route_nodes
6629                    .iter()
6630                    .chain(self.resource_nodes.iter())
6631                    .find(|n| n.id == id)
6632                    .map(resource_node_route_label)
6633                    .unwrap_or_else(|| resource_node_route_label_parts(id, "", ""))
6634            },
6635            |id| plot_stop_label(&self.property_plots, *id),
6636            |t| self.template_display_name(t),
6637        )
6638    }
6639
6640    pub fn catalog_entry(&self, template_id: &str) -> Option<&ItemCatalogEntryView> {
6641        self.item_catalog.get(template_id)
6642    }
6643
6644    /// Prefer blueprint-view display name, then inventory hints / humanized id.
6645    pub fn blueprint_item_label(&self, template_id: &str, display_name: &str) -> String {
6646        if !display_name.is_empty() {
6647            display_name.to_string()
6648        } else {
6649            self.template_display_name(template_id)
6650        }
6651    }
6652
6653    pub fn blueprint_output_label(&self, blueprint: &BlueprintView) -> String {
6654        self.blueprint_item_label(&blueprint.output, &blueprint.output_display_name)
6655    }
6656
6657    pub fn blueprint_ingredient_label(
6658        &self,
6659        input: &flatland_protocol::BlueprintIngredientView,
6660    ) -> String {
6661        self.blueprint_item_label(&input.template_id, &input.display_name)
6662    }
6663
6664    pub fn blueprint_tool_label(&self, tool: &flatland_protocol::ToolRequirementView) -> String {
6665        self.blueprint_item_label(&tool.item, &tool.display_name)
6666    }
6667
6668    /// Harvest picker rows for the worker route editor — distance/sort from rest bed, not player.
6669    pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
6670        use crate::worker_route_editor::{
6671            node_candidates, node_candidates_stable, route_editor_lodging_anchor,
6672        };
6673        let nodes = if self.harvest_route_nodes.is_empty() {
6674            &self.resource_nodes
6675        } else {
6676            &self.harvest_route_nodes
6677        };
6678        let lodging = self
6679            .worker_route_editor
6680            .as_ref()
6681            .and_then(|ed| ed.lodging_container_id.as_deref());
6682        match route_editor_lodging_anchor(lodging, &self.placed_containers) {
6683            Some((ax, ay)) => node_candidates(nodes, ax, ay),
6684            None => node_candidates_stable(nodes),
6685        }
6686    }
6687
6688    pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
6689        if dist_m.is_nan() {
6690            return "—".into();
6691        }
6692        let from_bed = self
6693            .worker_route_editor
6694            .as_ref()
6695            .and_then(|ed| ed.lodging_container_id.as_deref())
6696            .and_then(|id| {
6697                self.placed_containers
6698                    .iter()
6699                    .find(|c| c.id == id)
6700                    .map(|c| c.display_name.clone())
6701            });
6702        match from_bed {
6703            Some(bed) => format!("{dist_m:.0}m from {bed}"),
6704            None => format!("{dist_m:.0}m"),
6705        }
6706    }
6707
6708    /// Chest label for the location panel — generic type unless this player owns it.
6709    pub fn placed_container_public_label(
6710        &self,
6711        c: &flatland_protocol::PlacedContainerView,
6712    ) -> String {
6713        let is_owner = match (self.character_id, c.owner_character_id) {
6714            (Some(me), Some(owner)) => me == owner,
6715            _ => false,
6716        };
6717        if is_owner {
6718            c.display_name.clone()
6719        } else {
6720            self.template_display_name(&c.template_id)
6721        }
6722    }
6723
6724    /// Keys on person and stowed on the keychain for the keychain overlay.
6725    pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
6726        let mut out = Vec::new();
6727        for stack in &self.inventory_stacks {
6728            if stack.template_id == KEY_TEMPLATE {
6729                out.push(KeychainEntry {
6730                    stack: stack.clone(),
6731                    stowed: false,
6732                });
6733            }
6734        }
6735        for stack in &self.keychain_stacks {
6736            if stack.template_id == KEY_TEMPLATE {
6737                out.push(KeychainEntry {
6738                    stack: stack.clone(),
6739                    stowed: true,
6740                });
6741            }
6742        }
6743        out
6744    }
6745
6746    /// Display name of the chest a `container_key` opens, when known on the client.
6747    pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
6748        if stack.template_id != KEY_TEMPLATE {
6749            return None;
6750        }
6751        if let Some(name) = stack
6752            .props
6753            .get(PROP_OPENS_CONTAINER_NAME)
6754            .filter(|n| !n.is_empty())
6755        {
6756            return Some(name.clone());
6757        }
6758        let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
6759        self.container_name_for_lock_id(opens)
6760    }
6761
6762    /// Keys always show the catalog name — never a chest rename or stray `custom_name`.
6763    pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
6764        if stack.template_id == KEY_TEMPLATE {
6765            self.template_display_name(KEY_TEMPLATE)
6766        } else {
6767            stack
6768                .display_name
6769                .clone()
6770                .unwrap_or_else(|| stack.template_id.clone())
6771        }
6772    }
6773
6774    /// Hint suffix for a key row (`[key for …]` or `[key — unpaired]`).
6775    pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
6776        if stack.template_id != KEY_TEMPLATE {
6777            return String::new();
6778        }
6779        match self.key_pair_chest_label(stack) {
6780            Some(chest) if self.key_drop_blocked(stack) => {
6781                format!(" [key for {chest} — can't drop while locked]")
6782            }
6783            Some(chest) => format!(" [key for {chest}]"),
6784            None => " [key — unpaired]".into(),
6785        }
6786    }
6787
6788    /// Resolve a lock id to a container label (placed chest or one still on your person).
6789    pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
6790        for c in &self.placed_containers {
6791            if c.lock_id.as_deref() == Some(lock) {
6792                return Some(c.display_name.clone());
6793            }
6794        }
6795        Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
6796            self.worn
6797                .values()
6798                .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
6799        })
6800    }
6801
6802    /// Keys cannot be dropped while their paired chest is locked.
6803    pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
6804        if stack.template_id != KEY_TEMPLATE {
6805            return false;
6806        }
6807        let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
6808            return false;
6809        };
6810        for c in &self.placed_containers {
6811            if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
6812                return true;
6813            }
6814        }
6815        if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
6816            return true;
6817        }
6818        self.worn
6819            .values()
6820            .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
6821    }
6822
6823    /// Property deeds cannot be dropped or destroyed (store or trade only).
6824    pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
6825        stack.template_id == PROPERTY_DEED_TEMPLATE
6826    }
6827
6828    pub fn is_property_deed_template(template_id: &str) -> bool {
6829        template_id == PROPERTY_DEED_TEMPLATE
6830    }
6831
6832    pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
6833        stack
6834            .props
6835            .get("plot_id")
6836            .and_then(|s| uuid::Uuid::parse_str(s).ok())
6837    }
6838
6839    /// World position (cell center) of the plot cell the player is standing on, if tillable.
6840    pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
6841        let (px, py) = self.player_position();
6842        let (cx, cy) = self.farm_plot_cell_under_player()?;
6843        let tx = cx as f32 + 0.5;
6844        let ty = cy as f32 + 0.5;
6845        if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
6846            return None;
6847        }
6848        let kind = self.terrain_at(tx, ty).or_else(|| self.terrain_at(px, py));
6849        if kind == Some(TerrainKindView::Tilled) {
6850            return None;
6851        }
6852        if matches!(
6853            kind,
6854            Some(TerrainKindView::ShallowWater)
6855                | Some(TerrainKindView::DeepWater)
6856                | Some(TerrainKindView::Rock)
6857        ) {
6858            return None;
6859        }
6860        Some((tx, ty))
6861    }
6862
6863    fn container_name_in_stacks(
6864        stacks: &[flatland_protocol::ItemStack],
6865        lock: &str,
6866    ) -> Option<String> {
6867        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
6868            for s in stacks {
6869                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
6870                    return Some(GameState::stack_container_label(s));
6871                }
6872                if let Some(name) = walk(&s.contents, lock) {
6873                    return Some(name);
6874                }
6875            }
6876            None
6877        }
6878        walk(stacks, lock)
6879    }
6880
6881    fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
6882        stack
6883            .props
6884            .get(PROP_CUSTOM_NAME)
6885            .cloned()
6886            .or_else(|| stack.display_name.clone())
6887            .unwrap_or_else(|| stack.template_id.clone())
6888    }
6889
6890    fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6891        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6892            for s in stacks {
6893                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
6894                    return true;
6895                }
6896                if walk(&s.contents, lock) {
6897                    return true;
6898                }
6899            }
6900            false
6901        }
6902        walk(stacks, lock)
6903    }
6904
6905    fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
6906        if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
6907            return Some(stack.clone());
6908        }
6909        for worn in self.worn.values() {
6910            if worn.item_instance_id == Some(instance_id) {
6911                return Some(worn.clone());
6912            }
6913            if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
6914                return Some(stack.clone());
6915            }
6916        }
6917        None
6918    }
6919
6920    /// Highest-`z_order` property zone covering `(x, y)`.
6921    pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
6922        self.property_zones
6923            .iter()
6924            .enumerate()
6925            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
6926            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
6927            .map(|(_, z)| z)
6928    }
6929
6930    /// Highest-`z_order` tax zone covering `(x, y)`.
6931    pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
6932        self.tax_zones
6933            .iter()
6934            .enumerate()
6935            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
6936            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
6937            .map(|(_, z)| z)
6938    }
6939
6940    /// Max tax `rate_bps` across 1 m cell centers of the claim rect (matches server).
6941    pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
6942        let mut max_bps = 0u32;
6943        let mut y = y0 + 0.5;
6944        while y < y1 {
6945            let mut x = x0 + 0.5;
6946            while x < x1 {
6947                if let Some(tz) = self.tax_zone_at(x, y) {
6948                    max_bps = max_bps.max(tz.rate_bps);
6949                }
6950                x += 1.0;
6951            }
6952            y += 1.0;
6953        }
6954        max_bps
6955    }
6956
6957    /// Claim footprint as half-open world AABB `(x0, y0, x1, y1)`.
6958    pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
6959        let mode = self.claim_mode.as_ref()?;
6960        let w = mode.width_m.max(1) as f32;
6961        let h = mode.height_m.max(1) as f32;
6962        Some((
6963            mode.anchor_x,
6964            mode.anchor_y,
6965            mode.anchor_x + w,
6966            mode.anchor_y + h,
6967        ))
6968    }
6969
6970    /// Relocate ghost as half-open 1×1 world AABB `(x0, y0, x1, y1)`.
6971    pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
6972        let mode = self.relocate_mode.as_ref()?;
6973        let x0 = mode.cursor_x.floor();
6974        let y0 = mode.cursor_y.floor();
6975        Some((x0, y0, x0 + 1.0, y0 + 1.0))
6976    }
6977
6978    /// Client quote matching server `quote_plot`:
6979    /// `(purchase, upkeep, area, premium, can_afford, valid, reason)`.
6980    pub fn claim_quote(&self) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
6981        let mode = self.claim_mode.as_ref()?;
6982        let zone = self.property_zones.iter().find(|z| z.id == mode.zone_id)?;
6983        let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
6984        let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
6985        let zone_area = zone_view_area_m2(zone).max(1.0);
6986        let area_frac = (area / zone_area).clamp(0.0, 1.0);
6987        let weight = self
6988            .property_plot_settings
6989            .as_ref()
6990            .map(|s| s.tax_premium_weight)
6991            .unwrap_or(0.5)
6992            .max(0.0);
6993        let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
6994        let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
6995        let purchase = ((zone.crown_price_copper as f64) * (area_frac as f64) * (premium as f64))
6996            .ceil()
6997            .max(0.0) as u64;
6998        let upkeep = if zone.upkeep_copper_per_day == 0 {
6999            0
7000        } else {
7001            ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
7002                .ceil()
7003                .max(1.0) as u64
7004        };
7005        let copper = crate::currency::copper_from_counts(&self.inventory);
7006        let can_afford = copper >= purchase;
7007        let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
7008        Some((purchase, upkeep, area, premium, can_afford, valid, reason))
7009    }
7010
7011    fn validate_claim_footprint(
7012        &self,
7013        zone: &flatland_protocol::PropertyZoneView,
7014        x0: f32,
7015        y0: f32,
7016        x1: f32,
7017        y1: f32,
7018        area: f32,
7019    ) -> (bool, String) {
7020        let min_area = self
7021            .property_plot_settings
7022            .as_ref()
7023            .map(|s| s.min_plot_area_m2)
7024            .unwrap_or(4.0);
7025        if area + f32::EPSILON < min_area {
7026            return (false, "plot too small".into());
7027        }
7028        if zone.max_area_m2.is_some_and(|m| area > m) {
7029            return (false, "plot exceeds max area".into());
7030        }
7031        if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
7032            return (false, "plot must lie inside the property zone".into());
7033        }
7034        if self
7035            .property_plots
7036            .iter()
7037            .any(|p| rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1))
7038        {
7039            return (false, "plot overlaps an existing claim".into());
7040        }
7041        (true, String::new())
7042    }
7043
7044    /// Standing in a property zone on a free cell (not already claimed).
7045    pub fn free_property_zone_under_player(&self) -> Option<&flatland_protocol::PropertyZoneView> {
7046        let (px, py) = self.player_position();
7047        let zone = self.property_zone_at(px, py)?;
7048        if self.property_plots.iter().any(|p| point_in_plot(px, py, p)) {
7049            return None;
7050        }
7051        Some(zone)
7052    }
7053
7054    /// Own plot covering the player (`is_mine`).
7055    pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
7056        let (px, py) = self.player_position();
7057        self.property_plots
7058            .iter()
7059            .find(|p| p.is_mine && point_in_plot(px, py, p))
7060    }
7061
7062    /// Plot under the player that they may farm (deed, grant, or public).
7063    pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
7064        let (px, py) = self.player_position();
7065        self.property_plots
7066            .iter()
7067            .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
7068    }
7069
7070    /// Grid cell under the player on a farmable plot (`[cx,cx+1)` × `[cy,cy+1)` contains feet).
7071    pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
7072        if self.farmable_plot_under_player().is_none() {
7073            return None;
7074        }
7075        let (px, py) = self.player_position();
7076        Some((px.floor() as i32, py.floor() as i32))
7077    }
7078
7079    fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
7080        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
7081        self.resource_nodes.iter().any(|n| {
7082            let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
7083            ncx == cx && ncy == cy || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
7084        })
7085    }
7086
7087    fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
7088        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
7089        let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
7090            || self
7091                .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
7092                .is_some_and(|z| z.kind == TerrainKindView::Tilled);
7093        if !tilled {
7094            return false;
7095        }
7096        !self.resource_node_occupies_farm_cell(cx, cy)
7097    }
7098
7099    /// Empty tilled soil on the cell the player is standing on (no crop node).
7100    pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
7101        let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
7102            return false;
7103        };
7104        self.free_tilled_plant_slot_at(cx, cy)
7105    }
7106
7107    /// True when a free tilled cell (no resource node) is within interact range.
7108    pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
7109        let (px, py) = self.player_position();
7110        for dy in -2..=2 {
7111            for dx in -2..=2 {
7112                let cx = px.floor() as i32 + dx;
7113                let cy = py.floor() as i32 + dy;
7114                let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
7115                if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
7116                    continue;
7117                }
7118                if self.free_tilled_plant_slot_at(cx, cy) {
7119                    return true;
7120                }
7121            }
7122        }
7123        false
7124    }
7125
7126    fn stack_is_farm_seed(&self, stack: &flatland_protocol::ItemStack) -> bool {
7127        if stack.quantity == 0 {
7128            return false;
7129        }
7130        if stack.props.contains_key("seed_for") {
7131            return true;
7132        }
7133        if let Some(entry) = self.item_catalog.get(&stack.template_id) {
7134            if entry.is_farm_seed() {
7135                return true;
7136            }
7137        }
7138        stack.template_id.ends_with("_seed")
7139    }
7140
7141    /// Farm seed stacks in inventory (template_id, qty, display label), sorted by label.
7142    pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
7143        let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
7144        fn walk(
7145            stacks: &[flatland_protocol::ItemStack],
7146            state: &GameState,
7147            counts: &mut std::collections::HashMap<String, u32>,
7148        ) {
7149            for s in stacks {
7150                if state.stack_is_farm_seed(s) {
7151                    *counts.entry(s.template_id.clone()).or_default() += s.quantity;
7152                }
7153                walk(&s.contents, state, counts);
7154            }
7155        }
7156        walk(&self.inventory_stacks, self, &mut counts);
7157        for worn in self.worn.values() {
7158            walk(std::slice::from_ref(worn), self, &mut counts);
7159        }
7160        let mut out: Vec<_> = counts
7161            .into_iter()
7162            .map(|(template_id, quantity)| {
7163                let label = self.template_display_name(&template_id);
7164                (template_id, quantity, label)
7165            })
7166            .collect();
7167        out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
7168        out
7169    }
7170
7171    /// First farm seed template in inventory (root + nested bags).
7172    pub fn first_farm_seed_template(&self) -> Option<String> {
7173        self.farm_seed_entries()
7174            .into_iter()
7175            .next()
7176            .map(|(id, _, _)| id)
7177    }
7178
7179    pub fn clamp_plant_menu(&mut self) {
7180        let n = self.farm_seed_entries().len();
7181        if n == 0 {
7182            self.plant_menu_index = 0;
7183            self.plant_quantity = 1;
7184            return;
7185        }
7186        self.plant_menu_index = self.plant_menu_index.min(n - 1);
7187        let max_qty = self
7188            .farm_seed_entries()
7189            .get(self.plant_menu_index)
7190            .map(|(_, q, _)| *q)
7191            .unwrap_or(1)
7192            .max(1);
7193        self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
7194    }
7195
7196    pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
7197        let entries = self.farm_seed_entries();
7198        let (id, max, label) = entries.get(self.plant_menu_index)?;
7199        let qty = self.plant_quantity.min(*max).max(1);
7200        Some((id.clone(), qty, label.clone()))
7201    }
7202
7203    /// Terrain, interactables, and map objects near the player for the HUD location panel.
7204    pub fn location_context_lines(&self) -> Vec<ContextLine> {
7205        let (px, py) = self.player_position();
7206        let inside = self.effective_inside_building();
7207        let mut lines = Vec::new();
7208
7209        if let Some(kind) = self.terrain_at(px, py) {
7210            lines.push(ContextLine {
7211                on_top: true,
7212                text: format!("Terrain: {}", terrain_kind_label(kind)),
7213            });
7214        }
7215
7216        if let Some(id) = inside.as_ref() {
7217            if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
7218                lines.push(ContextLine {
7219                    on_top: true,
7220                    text: format!("Inside: {}", b.label),
7221                });
7222            }
7223        }
7224
7225        let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
7226
7227        for node in &self.resource_nodes {
7228            if node.id.starts_with("preview:") {
7229                continue;
7230            }
7231            let dist = distance(px, py, node.x, node.y);
7232            if dist > NEARBY_SCAN_M {
7233                continue;
7234            }
7235            let on_top = dist <= ON_TOP_RADIUS_M;
7236            let prefix = if on_top { "On" } else { "Near" };
7237            let name = resource_node_near_display_label(&node.label);
7238            let action = resource_node_near_action_suffix(node);
7239            nearby.push((
7240                dist,
7241                ContextLine {
7242                    on_top,
7243                    text: format!("{prefix}: {name} ({dist:.1}m){action}"),
7244                },
7245            ));
7246        }
7247
7248        for drop in &self.ground_drops {
7249            let dist = distance(px, py, drop.x, drop.y);
7250            if dist > INTERACTION_RADIUS_M {
7251                continue;
7252            }
7253            let on_top = dist <= ON_TOP_RADIUS_M;
7254            let name = self.template_display_name(&drop.template_id);
7255            let prefix = if on_top { "On" } else { "Near" };
7256            let qty = if drop.quantity > 1 {
7257                format!(" ×{}", drop.quantity)
7258            } else {
7259                String::new()
7260            };
7261            nearby.push((
7262                dist,
7263                ContextLine {
7264                    on_top,
7265                    text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
7266                },
7267            ));
7268        }
7269
7270        for c in &self.placed_containers {
7271            if !self.placed_container_in_current_space(c) {
7272                continue;
7273            }
7274            let dist = distance(px, py, c.x, c.y);
7275            if dist > CONTAINER_RANGE_M {
7276                continue;
7277            }
7278            let on_top = dist <= ON_TOP_RADIUS_M;
7279            let name = self.placed_container_public_label(c);
7280            let lock = if c.locked { " [locked]" } else { "" };
7281            let prefix = if on_top { "On" } else { "Near" };
7282            nearby.push((
7283                dist,
7284                ContextLine {
7285                    on_top,
7286                    text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
7287                },
7288            ));
7289        }
7290
7291        for npc in &self.npcs {
7292            let dist = distance(px, py, npc.x, npc.y);
7293            if dist > NEARBY_SCAN_M {
7294                continue;
7295            }
7296            let on_top = dist <= ON_TOP_RADIUS_M;
7297            let prefix = if on_top { "On" } else { "Near" };
7298            nearby.push((
7299                dist,
7300                ContextLine {
7301                    on_top,
7302                    text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
7303                },
7304            ));
7305        }
7306
7307        for door in &self.doors {
7308            let dist = distance(px, py, door.x, door.y);
7309            if dist > DOOR_INTERACTION_RADIUS_M {
7310                continue;
7311            }
7312            let building = self
7313                .buildings
7314                .iter()
7315                .find(|b| b.id == door.building_id)
7316                .map(|b| b.label.as_str())
7317                .unwrap_or(door.building_id.as_str());
7318            let player_house = self
7319                .buildings
7320                .iter()
7321                .find(|b| b.id == door.building_id)
7322                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
7323            let action = if inside.is_some() && door.portal.is_some() {
7324                if player_house {
7325                    if door.locked {
7326                        "locked — l unlock · Enter exit".to_string()
7327                    } else if door.open {
7328                        "close · Enter exit · l lock".to_string()
7329                    } else {
7330                        "open · Enter exit · l lock".to_string()
7331                    }
7332                } else {
7333                    "exit".to_string()
7334                }
7335            } else if player_house {
7336                if door.locked {
7337                    "locked — l unlock".to_string()
7338                } else if door.open {
7339                    "close · Enter go inside · l lock".to_string()
7340                } else {
7341                    "open · l lock".to_string()
7342                }
7343            } else {
7344                "enter".to_string()
7345            };
7346            nearby.push((
7347                dist,
7348                ContextLine {
7349                    on_top: dist <= ON_TOP_RADIUS_M,
7350                    text: format!("{building} door ({dist:.1}m) — f {action}"),
7351                },
7352            ));
7353        }
7354
7355        if inside.is_none() {
7356            for inter in &self.interactables {
7357                if inter.kind != "quest_board" {
7358                    continue;
7359                }
7360                let dist = distance(px, py, inter.x, inter.y);
7361                if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
7362                    continue;
7363                }
7364                let on_top = dist <= ON_TOP_RADIUS_M;
7365                let prefix = if on_top { "On" } else { "Near" };
7366                let label = if inter.label.is_empty() {
7367                    "Quest board".to_string()
7368                } else {
7369                    inter.label.clone()
7370                };
7371                nearby.push((
7372                    dist,
7373                    ContextLine {
7374                        on_top,
7375                        text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
7376                    },
7377                ));
7378            }
7379        }
7380
7381        if self.near_liquid_fill_source() {
7382            let on_water = matches!(
7383                self.terrain_at(px, py),
7384                Some(TerrainKindView::ShallowWater | TerrainKindView::DeepWater)
7385            );
7386            let well = self.buildings.iter().find(|b| {
7387                b.tags.iter().any(|t| t == "well") && {
7388                    let hw = b.width_m * 0.5;
7389                    let hd = b.depth_m * 0.5;
7390                    let nx = px.clamp(b.x - hw, b.x + hw);
7391                    let ny = py.clamp(b.y - hd, b.y + hd);
7392                    let dx = px - nx;
7393                    let dy = py - ny;
7394                    dx * dx + dy * dy <= INTERACTION_RADIUS_M * INTERACTION_RADIUS_M
7395                }
7396            });
7397            if let Some(well) = well {
7398                let name = if well.label.trim().is_empty() {
7399                    "Well"
7400                } else {
7401                    well.label.as_str()
7402                };
7403                nearby.push((
7404                    0.0,
7405                    ContextLine {
7406                        on_top: true,
7407                        text: format!("{name} — Use a vessel from inventory to fill"),
7408                    },
7409                ));
7410            } else if on_water {
7411                if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
7412                    line.text.push_str(" — Use a vessel from inventory to fill");
7413                }
7414            } else {
7415                nearby.push((
7416                    0.0,
7417                    ContextLine {
7418                        on_top: true,
7419                        text: "Water nearby — Use a vessel from inventory to fill".into(),
7420                    },
7421                ));
7422            }
7423        }
7424
7425        if self.claim_mode.is_some() {
7426            nearby.push((
7427                0.0,
7428                ContextLine {
7429                    on_top: true,
7430                    text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
7431                        .into(),
7432                },
7433            ));
7434        } else if let Some(plot) = self.my_plot_under_player() {
7435            let name = plot_public_label(plot);
7436            let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
7437                format!("{name} — f again to sell to crown")
7438            } else {
7439                format!(
7440                    "{name} — Shift+c till · p plant · f harvest · B build · l door lock · o farm access · Shift+n rename"
7441                )
7442            };
7443            nearby.push((
7444                0.0,
7445                ContextLine {
7446                    on_top: true,
7447                    text: prompt,
7448                },
7449            ));
7450        } else if let Some(plot) = self.farmable_plot_under_player() {
7451            let name = plot_public_label(plot);
7452            let disc = if plot.farm_public {
7453                plot.public_tax_discount_bps / 100
7454            } else {
7455                plot.farm_allow
7456                    .iter()
7457                    .find(|g| Some(g.character_id) == self.character_id)
7458                    .map(|g| g.tax_discount_bps / 100)
7459                    .unwrap_or(0)
7460            };
7461            nearby.push((
7462                0.0,
7463                ContextLine {
7464                    on_top: true,
7465                    text: format!(
7466                        "{name} (farming · tax −{disc}%) — Shift+c till · p plant · f harvest"
7467                    ),
7468                },
7469            ));
7470        } else if let Some(zone) = self.free_property_zone_under_player() {
7471            let label = zone
7472                .label
7473                .as_deref()
7474                .filter(|s| !s.trim().is_empty())
7475                .unwrap_or(zone.id.as_str());
7476            nearby.push((
7477                0.0,
7478                ContextLine {
7479                    on_top: true,
7480                    text: format!("Claimable land: {label} — k buy plot"),
7481                },
7482            ));
7483        }
7484
7485        for entity in &self.entities {
7486            if entity.id == self.entity_id {
7487                continue;
7488            }
7489            let dist = distance(
7490                px,
7491                py,
7492                entity.transform.position.x,
7493                entity.transform.position.y,
7494            );
7495            if dist > NEARBY_SCAN_M {
7496                continue;
7497            }
7498            let label = if entity.label.is_empty() {
7499                format!("entity {}", entity.id)
7500            } else {
7501                entity.label.clone()
7502            };
7503            nearby.push((
7504                dist,
7505                ContextLine {
7506                    on_top: dist <= ON_TOP_RADIUS_M,
7507                    text: format!("Near: {label} ({dist:.1}m)"),
7508                },
7509            ));
7510        }
7511
7512        nearby.sort_by(|a, b| {
7513            a.0.partial_cmp(&b.0)
7514                .unwrap_or(std::cmp::Ordering::Equal)
7515                .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
7516        });
7517        lines.extend(nearby.into_iter().map(|(_, l)| l));
7518
7519        if lines.is_empty() {
7520            lines.push(ContextLine {
7521                on_top: false,
7522                text: "(nothing notable nearby)".into(),
7523            });
7524        }
7525
7526        lines
7527    }
7528}
7529
7530/// HUD line for the location / nearby panel.
7531#[derive(Debug, Clone)]
7532pub struct ContextLine {
7533    pub on_top: bool,
7534    pub text: String,
7535}
7536
7537const ON_TOP_RADIUS_M: f32 = 0.65;
7538const NEARBY_SCAN_M: f32 = 5.0;
7539
7540/// Display name for a resource node in location/nearby HUD (strips redundant growing tag).
7541pub fn resource_node_near_display_label(label: &str) -> String {
7542    label
7543        .strip_suffix(" (growing)")
7544        .unwrap_or(label)
7545        .to_string()
7546}
7547
7548fn resource_label_looks_like_raw_id(label: &str, id: &str) -> bool {
7549    let t = label.trim();
7550    if t.is_empty() || t == id {
7551        return true;
7552    }
7553    let lower = t.to_ascii_lowercase();
7554    if lower.contains("_copy") {
7555        return true;
7556    }
7557    false
7558}
7559
7560fn humanize_item_template_label(template: &str) -> String {
7561    let base = template.rsplit('/').next().unwrap_or(template).trim();
7562    if base.is_empty() {
7563        return "Resource".into();
7564    }
7565    let stripped = base
7566        .strip_prefix("crop-")
7567        .or_else(|| base.strip_prefix("crop_"))
7568        .unwrap_or(base);
7569    stripped
7570        .split(|c: char| c == '-' || c == '_')
7571        .filter(|p| !p.is_empty())
7572        .map(|p| {
7573            let mut chars = p.chars();
7574            match chars.next() {
7575                Some(c) => format!("{}{}", c.to_ascii_uppercase(), chars.as_str()),
7576                None => String::new(),
7577            }
7578        })
7579        .collect::<Vec<_>>()
7580        .join(" ")
7581}
7582
7583/// Last 4 alphanumeric chars of an id (for disambiguation in route UI).
7584pub fn resource_node_id_suffix(id: &str) -> String {
7585    let chars: Vec<char> = id
7586        .chars()
7587        .rev()
7588        .filter(|c| c.is_ascii_alphanumeric())
7589        .take(4)
7590        .collect();
7591    chars.into_iter().rev().collect()
7592}
7593
7594/// Friendly harvest/route label: prefer human label, else template; always append `(xxxx)`.
7595pub fn resource_node_route_label(node: &flatland_protocol::ResourceNodeView) -> String {
7596    resource_node_route_label_parts(&node.id, &node.label, &node.item_template)
7597}
7598
7599pub fn resource_node_route_label_parts(id: &str, label: &str, item_template: &str) -> String {
7600    let cleaned = resource_node_near_display_label(label);
7601    let friendly = if !resource_label_looks_like_raw_id(&cleaned, id) {
7602        cleaned
7603    } else if !item_template.trim().is_empty() {
7604        humanize_item_template_label(item_template)
7605    } else {
7606        id.to_string()
7607    };
7608    let suffix = resource_node_id_suffix(id);
7609    if suffix.is_empty() {
7610        friendly
7611    } else {
7612        format!("{friendly} ({suffix})")
7613    }
7614}
7615
7616/// Action / state suffix for a resource node line (`growth_progress` wins for farm crops).
7617pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
7618    use flatland_protocol::ResourceNodeState;
7619    if node.harvest_off {
7620        return " (decorative)".to_string();
7621    }
7622    if let Some(p) = node.growth_progress {
7623        if p < 1.0 - f32::EPSILON {
7624            let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
7625            return format!(" (growing, {pct}%)");
7626        }
7627        return " — f harvest".to_string();
7628    }
7629    match node.state {
7630        ResourceNodeState::Available => " — f harvest".to_string(),
7631        ResourceNodeState::Harvesting => " (being harvested)".to_string(),
7632        ResourceNodeState::Cooldown => " (depleted)".to_string(),
7633    }
7634}
7635
7636fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
7637    use flatland_protocol::TerrainKindView;
7638    match kind {
7639        TerrainKindView::Grass => "Grass",
7640        TerrainKindView::Dirt => "Dirt",
7641        TerrainKindView::Tilled => "Tilled",
7642        TerrainKindView::Desert => "Desert",
7643        TerrainKindView::Hill => "Hills",
7644        TerrainKindView::Bog => "Bog",
7645        TerrainKindView::Beach => "Beach",
7646        TerrainKindView::ShallowWater => "Shallow water",
7647        TerrainKindView::DeepWater => "Deep water",
7648        TerrainKindView::Trail => "Trail",
7649        TerrainKindView::Road => "Road",
7650        TerrainKindView::Rock => "Rock",
7651    }
7652}
7653
7654fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
7655    crate::world_zones::zone_rects_contain(rects, x, y)
7656}
7657
7658fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
7659    zone.rects
7660        .iter()
7661        .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
7662        .sum()
7663}
7664
7665fn claim_rect_fully_inside_zone(
7666    zone: &flatland_protocol::PropertyZoneView,
7667    x0: f32,
7668    y0: f32,
7669    x1: f32,
7670    y1: f32,
7671) -> bool {
7672    let mut y = y0 + 0.5;
7673    while y < y1 {
7674        let mut x = x0 + 0.5;
7675        while x < x1 {
7676            if !zone_rects_contain(&zone.rects, x, y) {
7677                return false;
7678            }
7679            x += 1.0;
7680        }
7681        y += 1.0;
7682    }
7683    true
7684}
7685
7686fn rects_overlap_half_open(
7687    ax0: f32,
7688    ay0: f32,
7689    ax1: f32,
7690    ay1: f32,
7691    bx0: f32,
7692    by0: f32,
7693    bx1: f32,
7694    by1: f32,
7695) -> bool {
7696    ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
7697}
7698
7699fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
7700    x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
7701}
7702
7703fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
7704    plot_public_label(p)
7705}
7706
7707fn plot_size_fallback_label(p: &flatland_protocol::PropertyPlotView) -> String {
7708    let w = (p.x1 - p.x0).abs();
7709    let d = (p.y1 - p.y0).abs();
7710    format!("Plot ({w:.0}×{d:.0} m)")
7711}
7712
7713/// Canonical plot name: `{owner} — {zone} — {label}`.
7714pub fn plot_public_label(p: &flatland_protocol::PropertyPlotView) -> String {
7715    let zone = p
7716        .zone_label
7717        .as_deref()
7718        .filter(|s| !s.trim().is_empty())
7719        .unwrap_or_else(|| {
7720            if p.property_zone_id.is_empty() {
7721                "Homestead"
7722            } else {
7723                p.property_zone_id.as_str()
7724            }
7725        });
7726    let label = if !p.label.trim().is_empty() {
7727        p.label.clone()
7728    } else if !p.plot_code.trim().is_empty() {
7729        p.plot_code.clone()
7730    } else {
7731        plot_size_fallback_label(p)
7732    };
7733    match p
7734        .owner_label
7735        .as_deref()
7736        .map(str::trim)
7737        .filter(|s| !s.is_empty())
7738    {
7739        Some(owner) => format!("{owner} — {zone} — {label}"),
7740        None => format!("{zone} — {label}"),
7741    }
7742}
7743
7744/// Resolve a farm-plot stop target for player-facing route UI.
7745///
7746/// Uses [`plot_public_label`] when the plot is in the observer's view; otherwise a
7747/// short constructed fallback (never the full UUID).
7748pub fn plot_stop_label(
7749    plots: &[flatland_protocol::PropertyPlotView],
7750    plot_id: uuid::Uuid,
7751) -> String {
7752    plots
7753        .iter()
7754        .find(|p| p.plot_id == plot_id)
7755        .map(plot_public_label)
7756        .unwrap_or_else(|| {
7757            let s = plot_id.to_string();
7758            format!("plot {}", s.get(..8).unwrap_or(s.as_str()))
7759        })
7760}
7761
7762/// Match `flatland_sim::economy_zones::snap_claim_rect`.
7763fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
7764    let a = x0.min(x1).floor();
7765    let b = y0.min(y1).floor();
7766    let mut c = x0.max(x1).ceil();
7767    let mut d = y0.max(y1).ceil();
7768    if (c - a) < 1.0 {
7769        c = a + 1.0;
7770    }
7771    if (d - b) < 1.0 {
7772        d = b + 1.0;
7773    }
7774    (a, b, c, d)
7775}
7776
7777fn humanize_template_id(template_id: &str) -> String {
7778    // UUID-style ids have no friendly words to title-case; avoid dumping them in UI.
7779    if looks_like_template_uuid(template_id) {
7780        return "Unknown item".into();
7781    }
7782    template_id
7783        .split('_')
7784        .map(|word| {
7785            let mut chars = word.chars();
7786            match chars.next() {
7787                None => String::new(),
7788                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
7789            }
7790        })
7791        .collect::<Vec<_>>()
7792        .join(" ")
7793}
7794
7795fn looks_like_template_uuid(template_id: &str) -> bool {
7796    let bytes = template_id.as_bytes();
7797    if bytes.len() != 36 {
7798        return false;
7799    }
7800    let is_hex = |b: u8| b.is_ascii_hexdigit();
7801    let groups = [8usize, 4, 4, 4, 12];
7802    let mut i = 0;
7803    for (gi, &len) in groups.iter().enumerate() {
7804        if gi > 0 {
7805            if bytes.get(i) != Some(&b'-') {
7806                return false;
7807            }
7808            i += 1;
7809        }
7810        for _ in 0..len {
7811            if !bytes.get(i).copied().is_some_and(is_hex) {
7812                return false;
7813            }
7814            i += 1;
7815        }
7816    }
7817    true
7818}
7819
7820/// Keep in sync with `flatland_sim::INTERACTION_RADIUS_M`.
7821const HARVEST_RANGE_M: f32 = 1.5;
7822
7823pub struct GameClient<S: PlayConnection> {
7824    session: S,
7825    seq: Seq,
7826    pub state: GameState,
7827    last_move_forward: f32,
7828    last_move_strafe: f32,
7829}
7830
7831impl<S: PlayConnection> GameClient<S> {
7832    pub fn new(session: S) -> Self {
7833        let session_id = session.session_id();
7834        let entity_id = session.entity_id();
7835        let mut client = Self {
7836            session,
7837            seq: 0,
7838            last_move_forward: 0.0,
7839            last_move_strafe: 0.0,
7840            state: GameState {
7841                session_id,
7842                entity_id,
7843                character_id: None,
7844                tick: 0,
7845                chunk_rev: 0,
7846                content_rev: 0,
7847                publish_rev: 0,
7848                entities: Vec::new(),
7849                player: None,
7850                resource_nodes: Vec::new(),
7851                harvest_route_nodes: Vec::new(),
7852                ground_drops: Vec::new(),
7853                placed_containers: Vec::new(),
7854                buildings: Vec::new(),
7855                doors: Vec::new(),
7856                interior_map: None,
7857                npcs: Vec::new(),
7858                blueprints: Vec::new(),
7859                building_materials: Vec::new(),
7860                world_x0: 0.0,
7861                world_y0: 0.0,
7862                world_width_m: 0.0,
7863                world_height_m: 0.0,
7864                terrain_zones: Vec::new(),
7865                z_platforms: Vec::new(),
7866                z_transitions: Vec::new(),
7867                z_bands_outdoor_backup: None,
7868                world_clock: flatland_protocol::WorldClock::default(),
7869                inventory: std::collections::HashMap::new(),
7870                inventory_hints: std::collections::HashMap::new(),
7871                item_catalog: std::collections::HashMap::new(),
7872                logs: VecDeque::new(),
7873                intents_sent: 0,
7874                ticks_received: 0,
7875                connected: false,
7876                disconnect_reason: None,
7877                show_stats: false,
7878                hud_log_hidden: false,
7879                show_equip_menu: false,
7880                equip_menu_index: 0,
7881                show_craft_menu: false,
7882                show_plot_build_menu: false,
7883                plot_build_focus_wall: true,
7884                plot_build_wall_index: 0,
7885                plot_build_roof_index: 0,
7886                craft_menu_index: 0,
7887                craft_batch_quantity: 1,
7888                craft_tab: CraftTab::Ready,
7889                craft_filter: String::new(),
7890                craft_filter_focused: false,
7891                craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
7892                show_shop_menu: false,
7893                shop_catalog: None,
7894                bank_panel: None,
7895                bank_menu_index: 0,
7896                bank_ui_mode: BankUiMode::Menu,
7897                storage_panel: None,
7898                market_panel: None,
7899                market_menu_index: 0,
7900                market_filter: String::new(),
7901                market_filter_focused: false,
7902                market_category_filter: None,
7903                market_buy_confirm: None,
7904                market_ui_mode: MarketUiMode::Browse,
7905                storage_menu_index: 0,
7906                storage_ui_mode: StorageUiMode::Menu,
7907                shop_tab: ShopTab::default(),
7908                shop_menu_index: 0,
7909                shop_quantity: 1,
7910                shop_trade_log: VecDeque::new(),
7911                show_npc_verb_menu: false,
7912                npc_verb_target: None,
7913                npc_verb_index: 0,
7914                npc_verb_notice: None,
7915                player_verbs: crate::social::PlayerVerbState::default(),
7916                social_chat: crate::social::SocialChatState::default(),
7917                trade_ui: crate::social::TradeUiState::default(),
7918                whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
7919                show_npc_chat: false,
7920                npc_chat: None,
7921                show_inventory_menu: false,
7922                inventory_menu_index: 0,
7923                inventory_tab: InventoryTab::OnPerson,
7924                inventory_filter: String::new(),
7925                inventory_filter_focused: false,
7926                show_move_picker: false,
7927                show_rename_prompt: false,
7928                rename_plot_id: None,
7929                highlighted_plot_id: None,
7930                show_worker_rename: false,
7931                rename_buffer: String::new(),
7932                move_picker_index: 0,
7933                move_picker: None,
7934                show_grant_picker: false,
7935                grant_picker_index: 0,
7936                grant_picker: None,
7937                show_destroy_picker: false,
7938                destroy_confirm_pending: false,
7939                destroy_picker: None,
7940                combat_target: None,
7941                combat_target_label: None,
7942                ground_target: None,
7943                combat_fx: Vec::new(),
7944                ground_hazards: Vec::new(),
7945                property_zones: Vec::new(),
7946                tax_zones: Vec::new(),
7947                growth_zones: Vec::new(),
7948                biome_zones: Vec::new(),
7949                terrain_kind_nav: Vec::new(),
7950                property_plots: Vec::new(),
7951                property_plot_settings: None,
7952                claim_mode: None,
7953                relocate_mode: None,
7954                sell_plot_confirm: None,
7955                sell_plot_armed_at: None,
7956                show_plant_menu: false,
7957                plant_menu_index: 0,
7958                show_farm_access: false,
7959                farm_access_name_draft: String::new(),
7960                farm_access_discount_bps: 0,
7961                farm_access_index: 0,
7962                plant_quantity: 1,
7963                in_combat: false,
7964                auto_attack: true,
7965                combat_has_los: false,
7966                attack_cd_ticks: 0,
7967                gcd_ticks: 0,
7968                weapon_ability_id: "unarmed".into(),
7969                mainhand_template_id: None,
7970                mainhand_label: None,
7971                mainhand_instance_id: None,
7972                offhand_template_id: None,
7973                offhand_label: None,
7974                offhand_instance_id: None,
7975                mainhand_hand_slots: 1,
7976                defense: None,
7977                worn: BTreeMap::new(),
7978                carry_mass: 0.0,
7979                carry_mass_max: 0.0,
7980                encumbrance: flatland_protocol::EncumbranceState::Light,
7981                move_speed_mps: 0.0,
7982                move_speed_mult: 0.0,
7983                inventory_stacks: Vec::new(),
7984                keychain_stacks: Vec::new(),
7985                whisper_pouch_stacks: Vec::new(),
7986                combat_target_detail: None,
7987                statuses: Vec::new(),
7988                cast_progress: None,
7989                timed_channel: None,
7990                plot_build_offer: None,
7991                ability_cooldowns: Vec::new(),
7992                blocking_active: false,
7993                max_target_slots: 1,
7994                combat_slots: Vec::new(),
7995                rotation_presets: Vec::new(),
7996                known_abilities: Vec::new(),
7997                ability_meta: std::collections::HashMap::new(),
7998                ability_mastery: std::collections::HashMap::new(),
7999                hotbar: vec![None; 9],
8000                max_abilities_per_rotation: 0,
8001                show_loadout_menu: false,
8002                show_keychain_menu: false,
8003                keychain_menu_index: 0,
8004                show_rotation_editor: false,
8005                loadout_menu_index: 0,
8006                loadout_hotbar_slot: 1,
8007                loadout_ability_index: 0,
8008                loadout_focus_presets: false,
8009                rotation_editor: RotationEditorState::default(),
8010                harvest_in_progress: false,
8011                harvest_started_at: None,
8012                pending_craft_ack: None,
8013                craft_channel_blueprint_id: None,
8014                pending_worker_job_ack: None,
8015                attending_worker_instance_id: None,
8016                quest_log: Vec::new(),
8017                interactables: Vec::new(),
8018                ledger: None,
8019                career: None,
8020                character_sheet_tab: CharacterSheetTab::Character,
8021                ledger_period: LedgerPeriod::Day,
8022                show_quest_offer: false,
8023                pending_quest_offers: Vec::new(),
8024                quest_offer_index: 0,
8025                show_quest_menu: false,
8026                quest_menu_index: 0,
8027                quest_withdraw_confirm: false,
8028                hired_workers: Vec::new(),
8029                show_workers_menu: false,
8030                workers_menu_index: 0,
8031                worker_dismiss_confirmation: None,
8032                workers_menu_compact: false,
8033                worker_step_display: BTreeMap::new(),
8034                worker_error_display: BTreeMap::new(),
8035                worker_health_ring_until: BTreeMap::new(),
8036                pending_worker_hire_since: None,
8037                show_worker_give_picker: false,
8038                worker_give_picker_index: 0,
8039                worker_give_picker: None,
8040                show_worker_give_target_picker: false,
8041                worker_give_target_picker_index: 0,
8042                worker_give_target_picker: None,
8043                show_worker_take_picker: false,
8044                worker_take_picker_index: 0,
8045                worker_take_picker: None,
8046                show_worker_teach_picker: false,
8047                worker_teach_picker_index: 0,
8048                worker_teach_picker: None,
8049                worker_route_editor: None,
8050                progression_curve: None,
8051            },
8052        };
8053        client.state.apply_client_ui_prefs();
8054        client
8055    }
8056
8057    pub fn entity_id(&self) -> EntityId {
8058        self.state.entity_id
8059    }
8060
8061    pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
8062        if self.state.connected {
8063            return Ok(());
8064        }
8065
8066        loop {
8067            match self.session.next_event().await {
8068                Some(SessionEvent::Welcome {
8069                    session_id,
8070                    entity_id,
8071                    snapshot,
8072                }) => {
8073                    self.state
8074                        .restore_from_welcome(session_id, entity_id, &snapshot);
8075                    self.state.apply_client_ui_prefs();
8076                    self.state.push_log(format!(
8077                        "Connected — session {session_id}, entity {entity_id}"
8078                    ));
8079                    return Ok(());
8080                }
8081                Some(SessionEvent::Disconnected { .. }) => {
8082                    anyhow::bail!("disconnected before welcome");
8083                }
8084                Some(_) => continue,
8085                None => anyhow::bail!("session closed before welcome"),
8086            }
8087        }
8088    }
8089
8090    /// Drain all pending server events (non-blocking).
8091    pub fn drain_events(&mut self) {
8092        while let Some(event) = self.session.try_next_event() {
8093            if self.handle_event_sync(event).is_err() {
8094                break;
8095            }
8096        }
8097    }
8098
8099    /// Wait for the next server event.
8100    pub async fn next_event(&mut self) -> Option<SessionEvent> {
8101        self.session.next_event().await
8102    }
8103
8104    pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
8105        self.handle_event_sync(event)
8106    }
8107
8108    fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
8109        match event {
8110            SessionEvent::Welcome {
8111                session_id,
8112                entity_id,
8113                snapshot,
8114            } => {
8115                let resumed = self.state.connected;
8116                self.state
8117                    .restore_from_welcome(session_id, entity_id, &snapshot);
8118                if resumed {
8119                    self.state.push_log(format!(
8120                        "Session restored — session {session_id}, entity {entity_id}"
8121                    ));
8122                }
8123            }
8124            SessionEvent::ContentUpdated { snapshot } => {
8125                self.state
8126                    .apply_snapshot_fields(&snapshot, self.state.entity_id);
8127                self.state.push_log(format!(
8128                    "World updated (content rev {})",
8129                    snapshot.content_rev
8130                ));
8131            }
8132            SessionEvent::QuestCatalogUpdated(update) => {
8133                self.state.push_log(format!(
8134                    "Quest board updated (revision {}, {} new, {} retired)",
8135                    update.revision,
8136                    update.accepted.len(),
8137                    update.retired.len()
8138                ));
8139            }
8140            SessionEvent::Tick(delta) => {
8141                self.state.apply_tick_fields(&delta, self.state.entity_id);
8142                self.state.ticks_received += 1;
8143            }
8144            SessionEvent::IntentAck {
8145                entity_id,
8146                seq,
8147                tick,
8148            } => {
8149                crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
8150                if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
8151                    if *craft_seq == seq {
8152                        let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
8153                        if batches > 1 {
8154                            self.state.push_log(format!("Crafting {label} ×{batches}…"));
8155                        } else {
8156                            self.state.push_log(format!("Crafting {label}…"));
8157                        }
8158                    }
8159                }
8160                if self
8161                    .state
8162                    .pending_worker_job_ack
8163                    .as_ref()
8164                    .is_some_and(|p| p.seq == seq)
8165                {
8166                    let pending = self.state.pending_worker_job_ack.take().unwrap();
8167                    if pending.idle {
8168                        self.state.push_log(format!(
8169                            "Route cleared for {} — worker idle",
8170                            pending.worker_label
8171                        ));
8172                    } else {
8173                        self.state.push_log(format!(
8174                            "Route saved for {} — {} stop(s), job loop active",
8175                            pending.worker_label, pending.stop_count
8176                        ));
8177                    }
8178                    if self
8179                        .state
8180                        .worker_route_editor
8181                        .as_ref()
8182                        .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
8183                    {
8184                        self.close_worker_route_editor();
8185                    }
8186                }
8187            }
8188            SessionEvent::Chat(msg) => {
8189                let label = match msg.channel {
8190                    flatland_protocol::ChatChannel::Nearby => "nearby",
8191                    flatland_protocol::ChatChannel::Direct => "speak",
8192                    flatland_protocol::ChatChannel::Whisper => "whisper",
8193                    flatland_protocol::ChatChannel::WhisperStone => "stone",
8194                };
8195                let clarity = match msg.clarity {
8196                    flatland_protocol::ChatClarity::Clear => "",
8197                    flatland_protocol::ChatClarity::Partial => "~",
8198                    flatland_protocol::ChatClarity::Heavy => "…",
8199                };
8200                self.state.push_log(format!(
8201                    "[{label}{clarity}] {}: {}",
8202                    msg.from_name, msg.text
8203                ));
8204                let now_ms = std::time::SystemTime::now()
8205                    .duration_since(std::time::UNIX_EPOCH)
8206                    .map(|d| d.as_millis() as u64)
8207                    .unwrap_or(0);
8208                self.state
8209                    .social_chat
8210                    .note_speech(&msg, self.state.entity_id, now_ms);
8211                self.state
8212                    .social_chat
8213                    .push(crate::social::ChatLogEntry::from_message(
8214                        msg,
8215                        self.state.entity_id,
8216                    ));
8217            }
8218            SessionEvent::TradeOpened(panel) => {
8219                self.state.social_chat.pending_trade = None;
8220                let peer = panel.peer_name.clone();
8221                self.state.trade_ui.open(panel);
8222                self.state.social_chat.push_system(format!(
8223                    "Trade open with {peer} — p present · r ready · Esc cancel"
8224                ));
8225                self.state
8226                    .social_chat
8227                    .push_cue(crate::social::AudioCue::TradeOpened);
8228            }
8229            SessionEvent::TradeClosed { reason } => {
8230                self.state.push_log(reason.clone());
8231                self.state.social_chat.push_system(reason);
8232                self.state.trade_ui.close();
8233            }
8234            SessionEvent::HarvestResult(result) => {
8235                self.state.clear_harvest_state();
8236                crate::harvest_trace!(
8237                    entity_id = self.state.entity_id,
8238                    node_id = %result.node_id,
8239                    template = %result.item_template,
8240                    quantity = result.quantity,
8241                    client_tick = self.state.tick,
8242                    "client applied harvest result"
8243                );
8244                let msg = if result.quantity == 0 {
8245                    format!(
8246                        "Harvested {} x0 — nothing dropped (loot table rolled empty)",
8247                        result.item_template
8248                    )
8249                } else {
8250                    format!(
8251                        "Harvested {} x{} (on the ground — press P to pick up)",
8252                        result.item_template, result.quantity
8253                    )
8254                };
8255                self.state.push_log(msg);
8256            }
8257            SessionEvent::CraftResult(result) => {
8258                for stack in &result.consumed {
8259                    if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
8260                        *qty = qty.saturating_sub(stack.quantity);
8261                        if *qty == 0 {
8262                            self.state.inventory.remove(&stack.template_id);
8263                        }
8264                    }
8265                }
8266                for stack in &result.outputs {
8267                    *self
8268                        .state
8269                        .inventory
8270                        .entry(stack.template_id.clone())
8271                        .or_insert(0) += stack.quantity;
8272                }
8273                self.state.craft_record_completed(&result.blueprint_id);
8274                if let Some(output) = result.outputs.first() {
8275                    if result.batch_total > 1 {
8276                        self.state.push_log(format!(
8277                            "Crafted {} x{} ({}/{})",
8278                            output.template_id,
8279                            output.quantity,
8280                            result.batch_index,
8281                            result.batch_total
8282                        ));
8283                    } else {
8284                        self.state.push_log(format!(
8285                            "Crafted {} x{}",
8286                            output.template_id, output.quantity
8287                        ));
8288                    }
8289                } else {
8290                    self.state
8291                        .push_log(format!("Craft finished: {}", result.blueprint_id));
8292                }
8293            }
8294            SessionEvent::Death(notice) => {
8295                self.state.clear_harvest_state();
8296                self.state.push_log(notice.message.clone());
8297                self.state.push_log(format!(
8298                    "Respawned at ({:.1}, {:.1})",
8299                    notice.respawn_x, notice.respawn_y
8300                ));
8301            }
8302            SessionEvent::Interaction(notice) => {
8303                if notice.message.starts_with("Harvest failed:") {
8304                    self.state.clear_harvest_state();
8305                }
8306                if notice.message.starts_with("Can't do that:") {
8307                    self.state.pending_worker_hire_since = None;
8308                    self.state.pending_craft_ack = None;
8309                    self.state.craft_channel_blueprint_id = None;
8310                    if let Some(pending) = self.state.pending_worker_job_ack.take() {
8311                        if let Some(w) = self
8312                            .state
8313                            .hired_workers
8314                            .iter_mut()
8315                            .find(|w| w.instance_id == pending.worker_instance_id)
8316                        {
8317                            w.route = pending.prev_route;
8318                            w.mode = pending.prev_mode;
8319                            w.step_label = pending.prev_step_label;
8320                            w.last_error = pending.prev_last_error;
8321                        }
8322                        let reason = notice
8323                            .message
8324                            .strip_prefix("Can't do that:")
8325                            .unwrap_or(&notice.message)
8326                            .trim();
8327                        self.state.push_log(format!(
8328                            "Route save failed for {}: {reason}",
8329                            pending.worker_label
8330                        ));
8331                    }
8332                    let reason = notice
8333                        .message
8334                        .strip_prefix("Can't do that:")
8335                        .unwrap_or(&notice.message)
8336                        .trim();
8337                    if reason.contains("already tilled") {
8338                        if let Some(plot) = self.state.my_plot_under_player() {
8339                            self.state.sell_plot_confirm = Some(plot.plot_id);
8340                            self.state.sell_plot_armed_at = Some(Instant::now());
8341                        }
8342                    }
8343                }
8344                if notice.message.starts_with("Cast failed:") {
8345                    self.state.cast_progress = None;
8346                }
8347                if notice.message.contains("slain the") {
8348                    self.state.combat_target = None;
8349                    self.state.combat_target_label = None;
8350                }
8351                // Inbound trade request → inline Y/N in the CHAT column (no popup).
8352                if notice.message.contains("wants to trade") {
8353                    if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
8354                        let from_name = notice
8355                            .message
8356                            .split(" wants to trade")
8357                            .next()
8358                            .unwrap_or("Player")
8359                            .to_string();
8360                        self.state.social_chat.pending_trade =
8361                            Some(crate::social::PendingTradeRequest {
8362                                from_entity,
8363                                from_name: from_name.clone(),
8364                            });
8365                        self.state.social_chat.push_system(format!(
8366                            "{from_name} wants to trade — [Y] accept · [N] decline"
8367                        ));
8368                        self.state
8369                            .social_chat
8370                            .push_cue(crate::social::AudioCue::TradeOffer);
8371                    }
8372                }
8373                if notice.message.starts_with("trade request declined") {
8374                    self.state.social_chat.push_system(notice.message.clone());
8375                    self.state
8376                        .social_chat
8377                        .push_cue(crate::social::AudioCue::TradeDeclined);
8378                }
8379                // Verb dock hides the floating LOG — keep refuse lines on the panel + toast.
8380                if self.state.show_npc_verb_menu && !notice.message.trim().is_empty() {
8381                    self.state.npc_verb_notice = Some(notice.message.clone());
8382                    self.state
8383                        .social_chat
8384                        .push_cue(crate::social::AudioCue::UiError);
8385                }
8386                self.state.apply_interaction_notice(&notice);
8387                self.state.push_log(notice.message.clone());
8388            }
8389            SessionEvent::ShopOpened(catalog) => {
8390                self.state.apply_shop_catalog(catalog);
8391            }
8392            SessionEvent::BankOpened(panel) => {
8393                self.state.apply_bank_panel(panel);
8394            }
8395            SessionEvent::StorageOpened(panel) => {
8396                self.state.apply_storage_panel(panel);
8397            }
8398            SessionEvent::MarketOpened(panel) => {
8399                self.state.apply_market_panel(panel);
8400            }
8401            SessionEvent::NpcTalkOpened(opened) => {
8402                self.state.show_npc_verb_menu = false;
8403                if self.state.npc_verb_target.is_none() {
8404                    self.state.npc_verb_target = Some(opened.npc_id.clone());
8405                }
8406                let label = opened.npc_label.clone();
8407                let banner = if !opened.trade_allowed {
8408                    Some("Trade is unavailable right now.".to_string())
8409                } else {
8410                    None
8411                };
8412                self.state.show_npc_chat = true;
8413                self.state.npc_chat = Some(NpcChatState {
8414                    npc_id: opened.npc_id,
8415                    npc_label: opened.npc_label,
8416                    lines: if opened.greeting.is_empty() {
8417                        vec![]
8418                    } else {
8419                        vec![format!("{label}: {}", opened.greeting)]
8420                    },
8421                    input: String::new(),
8422                    pending: opened.greeting.is_empty(),
8423                    talk_depth: opened.talk_depth,
8424                    trade_allowed: opened.trade_allowed,
8425                    banner,
8426                    suggested_topics: opened.suggested_topics,
8427                });
8428            }
8429            SessionEvent::NpcTalkPending(_) => {
8430                if let Some(chat) = self.state.npc_chat.as_mut() {
8431                    chat.pending = true;
8432                }
8433            }
8434            SessionEvent::NpcTalkReply(reply) => {
8435                if let Some(chat) = self.state.npc_chat.as_mut() {
8436                    if chat.npc_id == reply.npc_id {
8437                        chat.pending = false;
8438                        if reply.trade_disabled {
8439                            chat.trade_allowed = false;
8440                            chat.banner = Some("Trade is unavailable right now.".to_string());
8441                        }
8442                        if reply.wind_down {
8443                            chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
8444                            if chat.banner.is_none() {
8445                                chat.banner =
8446                                    Some("They're wrapping up — keep it brief.".to_string());
8447                            }
8448                        }
8449                        chat.lines
8450                            .push(format!("{}: {}", chat.npc_label, reply.line));
8451                    }
8452                }
8453            }
8454            SessionEvent::NpcTalkClosed(closed) => {
8455                if self
8456                    .state
8457                    .npc_chat
8458                    .as_ref()
8459                    .is_some_and(|c| c.npc_id == closed.npc_id)
8460                {
8461                    self.state.show_npc_chat = false;
8462                    self.state.npc_chat = None;
8463                }
8464            }
8465            SessionEvent::NpcTalkError(err) => {
8466                self.state.push_log(format!("Talk failed: {}", err.reason));
8467                if let Some(chat) = self.state.npc_chat.as_mut() {
8468                    chat.pending = false;
8469                }
8470            }
8471            SessionEvent::UseResult(result) => {
8472                // Inventory count hint only — the Interaction notice already logs
8473                // "Consumed …" (and drives the gfx toast). Logging again here doubled toasts.
8474                if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
8475                    *qty = qty.saturating_sub(1);
8476                    if *qty == 0 {
8477                        self.state.inventory.remove(&result.template_id);
8478                    }
8479                }
8480            }
8481            SessionEvent::QuestOffer(offer) => {
8482                let title = offer.title.clone();
8483                self.state.push_quest_offer(offer);
8484                self.state.push_log(format!("Quest offered: {title}"));
8485            }
8486            SessionEvent::QuestAccepted(notice) => {
8487                self.state.remove_quest_offer(&notice.quest_id);
8488                self.state.push_log(notice.message);
8489            }
8490            SessionEvent::QuestWithdrawn(notice) => {
8491                self.state.show_quest_menu = false;
8492                self.state.quest_withdraw_confirm = false;
8493                self.state.push_log(notice.message);
8494            }
8495            SessionEvent::QuestStepCompleted(notice) => {
8496                self.state.push_log(notice.message);
8497            }
8498            SessionEvent::QuestCompleted(notice) => {
8499                self.state.push_log(notice.message);
8500            }
8501            SessionEvent::Disconnected { reason } => {
8502                self.state.clear_harvest_state();
8503                self.state.connected = false;
8504                self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
8505                if let Some(r) = &self.state.disconnect_reason {
8506                    self.state.push_log(format!("Disconnected: {r}"));
8507                } else {
8508                    self.state.push_log("Disconnected from server");
8509                }
8510            }
8511        }
8512        Ok(())
8513    }
8514
8515    pub fn is_connected(&self) -> bool {
8516        self.state.connected
8517    }
8518
8519    pub fn close_overlays(&mut self) {
8520        self.state.show_stats = false;
8521        self.state.show_craft_menu = false;
8522        self.state.show_plot_build_menu = false;
8523        self.state.show_shop_menu = false;
8524        self.state.shop_catalog = None;
8525        self.state.show_npc_verb_menu = false;
8526        self.state.npc_verb_target = None;
8527        self.state.show_npc_chat = false;
8528        self.state.npc_chat = None;
8529        self.state.show_inventory_menu = false;
8530        self.state.show_loadout_menu = false;
8531        self.state.show_rotation_editor = false;
8532        self.state.rotation_editor.reset();
8533        self.state.show_rename_prompt = false;
8534        self.state.show_worker_rename = false;
8535        self.state.rename_buffer.clear();
8536        self.state.show_move_picker = false;
8537        self.state.move_picker = None;
8538        self.state.show_destroy_picker = false;
8539        self.state.destroy_confirm_pending = false;
8540        self.state.destroy_picker = None;
8541        self.state.show_quest_offer = false;
8542        self.state.clear_quest_offers();
8543        self.state.show_quest_menu = false;
8544        self.state.quest_withdraw_confirm = false;
8545        self.state.show_workers_menu = false;
8546        self.close_worker_give_picker();
8547        self.close_worker_give_target_picker();
8548        self.close_worker_take_picker();
8549        self.close_worker_teach_picker();
8550        self.state.worker_route_editor = None;
8551        self.state.claim_mode = None;
8552        self.state.relocate_mode = None;
8553        self.state.sell_plot_confirm = None;
8554        self.state.sell_plot_armed_at = None;
8555        self.close_farm_access_panel();
8556        if self.state.show_plant_menu {
8557            self.close_plant_menu();
8558        }
8559    }
8560
8561    /// Esc / back — pop one UI layer instead of closing every overlay at once.
8562    pub fn back_on_esc(&mut self) -> bool {
8563        if self.state.social_chat.composer_open() {
8564            self.state.social_chat.close_composer();
8565            return true;
8566        }
8567        if self.state.player_verbs.open {
8568            self.state.player_verbs.close();
8569            return true;
8570        }
8571        if self.state.whisper_pouch_ui.open {
8572            self.state.whisper_pouch_ui.open = false;
8573            return true;
8574        }
8575        if self.state.trade_ui.panel.is_some() {
8576            // async cancel — caller should prefer trade_cancel; close UI optimistically
8577            self.state.trade_ui.close();
8578            return true;
8579        }
8580        if self.state.show_rename_prompt {
8581            self.cancel_rename_prompt();
8582            return true;
8583        }
8584        if self.state.show_worker_rename {
8585            self.cancel_worker_rename();
8586            return true;
8587        }
8588        if self.state.show_destroy_picker {
8589            if self.state.destroy_confirm_pending {
8590                self.cancel_destroy_confirm();
8591            } else {
8592                self.close_destroy_picker();
8593            }
8594            return true;
8595        }
8596        if self.state.claim_mode.is_some() {
8597            self.cancel_claim_mode();
8598            return true;
8599        }
8600        if self.state.relocate_mode.is_some() {
8601            self.cancel_relocate_mode();
8602            return true;
8603        }
8604        if self.state.show_plant_menu {
8605            self.close_plant_menu();
8606            return true;
8607        }
8608        if self.state.show_farm_access {
8609            self.close_farm_access_panel();
8610            return true;
8611        }
8612        if self.state.sell_plot_confirm.is_some() {
8613            self.state.sell_plot_confirm = None;
8614            self.state.sell_plot_armed_at = None;
8615            self.state.push_log("Sell cancelled");
8616            return true;
8617        }
8618        if self.state.show_move_picker {
8619            self.close_move_picker();
8620            return true;
8621        }
8622        if self.state.show_rotation_editor {
8623            match self.state.rotation_editor.mode {
8624                RotationEditorMode::List => {
8625                    self.state.show_rotation_editor = false;
8626                    self.state.rotation_editor.reset();
8627                }
8628                RotationEditorMode::EditLabel => {
8629                    self.state.rotation_editor.label_buffer.clear();
8630                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8631                }
8632                RotationEditorMode::PickAbility => {
8633                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8634                }
8635                RotationEditorMode::EditSequence => {
8636                    self.state.rotation_editor.draft = None;
8637                    self.state.rotation_editor.mode = RotationEditorMode::List;
8638                }
8639            }
8640            return true;
8641        }
8642        if self.state.show_inventory_menu {
8643            self.close_inventory_menu();
8644            return true;
8645        }
8646        if self.state.show_craft_menu {
8647            self.close_craft_menu();
8648            return true;
8649        }
8650        if self.state.show_plot_build_menu {
8651            self.close_plot_build_menu();
8652            return true;
8653        }
8654        if self.state.show_keychain_menu {
8655            self.close_keychain_menu();
8656            return true;
8657        }
8658        if self.state.show_quest_offer {
8659            self.quest_offer_decline();
8660            return true;
8661        }
8662        if self.state.show_shop_menu {
8663            // Caller must await shop close (npc_interaction_back / play-loop).
8664            return false;
8665        }
8666        if self.state.bank_panel.is_some() {
8667            return false;
8668        }
8669        if self.state.storage_panel.is_some() {
8670            return false;
8671        }
8672        if self.state.market_panel.is_some() {
8673            return false;
8674        }
8675        if self.state.show_npc_chat {
8676            // play.rs calls npc_interaction_back().await on Esc
8677            return false;
8678        }
8679        if self.state.show_npc_verb_menu {
8680            self.state.show_npc_verb_menu = false;
8681            self.state.npc_verb_target = None;
8682            self.state.npc_verb_notice = None;
8683            return true;
8684        }
8685        if self.state.show_quest_menu {
8686            if self.state.quest_withdraw_confirm {
8687                self.state.quest_withdraw_confirm = false;
8688            } else {
8689                self.state.show_quest_menu = false;
8690            }
8691            return true;
8692        }
8693        if self.state.worker_route_editor.is_some() {
8694            // Pop one sheet level; only close the editor at the root stop list.
8695            if self.re_at_root_sheet() {
8696                let reopen = self.state.attending_worker_instance_id.clone();
8697                self.close_worker_route_editor();
8698                if let Some(id) = reopen {
8699                    if let Some(idx) = self
8700                        .state
8701                        .hired_workers
8702                        .iter()
8703                        .position(|w| w.instance_id == id)
8704                    {
8705                        self.state.workers_menu_index = idx;
8706                        self.state.show_workers_menu = true;
8707                    }
8708                }
8709            } else {
8710                self.re_sheet_back();
8711            }
8712            return true;
8713        }
8714        if self.state.show_worker_give_picker {
8715            self.close_worker_give_picker();
8716            return true;
8717        }
8718        if self.state.show_worker_give_target_picker {
8719            self.close_worker_give_target_picker();
8720            return true;
8721        }
8722        if self.state.show_worker_take_picker {
8723            self.close_worker_take_picker();
8724            return true;
8725        }
8726        if self.state.show_worker_teach_picker {
8727            self.close_worker_teach_picker();
8728            return true;
8729        }
8730        if self.state.show_workers_menu {
8731            self.close_workers_menu_ui();
8732            return true;
8733        }
8734        if self.state.show_loadout_menu {
8735            self.state.show_loadout_menu = false;
8736            return true;
8737        }
8738        if self.state.show_stats {
8739            self.state.show_stats = false;
8740            return true;
8741        }
8742        if self.state.show_equip_menu {
8743            self.state.show_equip_menu = false;
8744            return true;
8745        }
8746        false
8747    }
8748
8749    pub fn toggle_stats(&mut self) {
8750        self.state.show_stats = !self.state.show_stats;
8751        if self.state.show_stats {
8752            self.state.character_sheet_tab = CharacterSheetTab::Character;
8753            self.state.show_craft_menu = false;
8754            self.state.show_shop_menu = false;
8755            self.state.shop_catalog = None;
8756            self.state.show_inventory_menu = false;
8757            self.state.show_equip_menu = false;
8758        }
8759    }
8760
8761    pub fn toggle_equip_menu(&mut self) {
8762        self.state.show_equip_menu = !self.state.show_equip_menu;
8763        if self.state.show_equip_menu {
8764            self.state.show_stats = false;
8765            self.state.show_craft_menu = false;
8766            self.state.show_shop_menu = false;
8767            self.state.shop_catalog = None;
8768            self.state.show_inventory_menu = false;
8769            self.state.show_loadout_menu = false;
8770        }
8771    }
8772
8773    pub fn cycle_character_sheet_tab(&mut self) {
8774        if self.state.show_stats {
8775            self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
8776        }
8777    }
8778
8779    pub fn set_ledger_period_digit(&mut self, c: char) {
8780        if self.state.show_stats {
8781            if let Some(p) = LedgerPeriod::from_digit(c) {
8782                self.state.ledger_period = p;
8783                self.state.character_sheet_tab = CharacterSheetTab::Ledger;
8784            }
8785        }
8786    }
8787
8788    pub fn cycle_ledger_period(&mut self) {
8789        if self.state.show_stats && self.state.character_sheet_tab == CharacterSheetTab::Ledger {
8790            self.state.ledger_period = self.state.ledger_period.cycle();
8791        }
8792    }
8793
8794    pub fn open_inventory_menu(&mut self) {
8795        self.state.show_inventory_menu = true;
8796        self.state.show_craft_menu = false;
8797        self.state.show_shop_menu = false;
8798        self.state.shop_catalog = None;
8799        self.state.show_stats = false;
8800        self.state.show_move_picker = false;
8801        self.state.move_picker = None;
8802        self.state.show_destroy_picker = false;
8803        self.state.destroy_confirm_pending = false;
8804        self.state.destroy_picker = None;
8805        self.state.show_rename_prompt = false;
8806        self.state.rename_plot_id = None;
8807        self.state.rename_buffer.clear();
8808        self.state.inventory_filter_focused = false;
8809        self.state.clamp_inventory_indices();
8810    }
8811
8812    pub fn close_inventory_menu(&mut self) {
8813        self.state.show_inventory_menu = false;
8814        self.state.show_move_picker = false;
8815        self.state.move_picker = None;
8816        self.close_grant_picker();
8817        self.state.show_destroy_picker = false;
8818        self.state.destroy_confirm_pending = false;
8819        self.state.destroy_picker = None;
8820        self.state.show_rename_prompt = false;
8821        self.state.rename_plot_id = None;
8822        self.state.rename_buffer.clear();
8823        self.state.inventory_filter_focused = false;
8824    }
8825
8826    pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
8827        let Some(row) = self.state.inventory_selected_row() else {
8828            anyhow::bail!("inventory empty");
8829        };
8830        if GameState::is_property_deed_template(&row.stack.template_id) {
8831            let Some(plot_id) = GameState::deed_plot_id(&row.stack) else {
8832                anyhow::bail!("deed has no plot id");
8833            };
8834            let label = self
8835                .state
8836                .property_plots
8837                .iter()
8838                .find(|p| p.plot_id == plot_id)
8839                .map(|p| {
8840                    if p.label.trim().is_empty() {
8841                        p.plot_code.clone()
8842                    } else {
8843                        p.label.clone()
8844                    }
8845                })
8846                .unwrap_or_else(|| {
8847                    row.stack
8848                        .display_name
8849                        .clone()
8850                        .unwrap_or_else(|| "plot".into())
8851                });
8852            self.state.rename_buffer = label;
8853            self.state.rename_plot_id = Some(plot_id);
8854            self.state.highlighted_plot_id = Some(plot_id);
8855            self.state.show_rename_prompt = true;
8856            self.state.show_worker_rename = false;
8857            self.state.show_move_picker = false;
8858            self.state.show_destroy_picker = false;
8859            self.state.destroy_confirm_pending = false;
8860            return Ok(());
8861        }
8862        if !self.state.row_is_renameable_container(&row) {
8863            anyhow::bail!("only storage containers or deeds can be renamed");
8864        }
8865        let current = row
8866            .stack
8867            .display_name
8868            .clone()
8869            .unwrap_or_else(|| row.stack.template_id.clone());
8870        self.state.rename_buffer = current;
8871        self.state.rename_plot_id = None;
8872        self.state.show_rename_prompt = true;
8873        self.state.show_worker_rename = false;
8874        self.state.show_move_picker = false;
8875        self.state.show_destroy_picker = false;
8876        self.state.destroy_confirm_pending = false;
8877        Ok(())
8878    }
8879
8880    /// Rename the owned plot under the player's feet (`n` in world).
8881    pub fn open_plot_rename_under_player(&mut self) -> anyhow::Result<()> {
8882        let Some(plot) = self.state.my_plot_under_player().cloned() else {
8883            anyhow::bail!("stand on your plot to rename it");
8884        };
8885        let label = if plot.label.trim().is_empty() {
8886            plot.plot_code.clone()
8887        } else {
8888            plot.label.clone()
8889        };
8890        self.state.rename_buffer = label;
8891        self.state.rename_plot_id = Some(plot.plot_id);
8892        self.state.highlighted_plot_id = Some(plot.plot_id);
8893        self.state.show_rename_prompt = true;
8894        self.state.show_worker_rename = false;
8895        Ok(())
8896    }
8897
8898    pub fn cancel_rename_prompt(&mut self) {
8899        self.state.show_rename_prompt = false;
8900        self.state.rename_plot_id = None;
8901        self.state.rename_buffer.clear();
8902    }
8903
8904    pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
8905        let name = self.state.rename_buffer.trim().to_string();
8906        if name.is_empty() {
8907            anyhow::bail!("name cannot be empty");
8908        }
8909        if let Some(plot_id) = self.state.rename_plot_id {
8910            if name.chars().count() > 48 {
8911                anyhow::bail!("label must be 1–48 characters");
8912            }
8913            self.seq += 1;
8914            self.session
8915                .submit_intent(Intent::RenamePropertyPlot {
8916                    entity_id: self.state.entity_id,
8917                    plot_id,
8918                    label: name,
8919                    seq: self.seq,
8920                })
8921                .await?;
8922            self.state.intents_sent += 1;
8923            self.state.show_rename_prompt = false;
8924            self.state.rename_plot_id = None;
8925            self.state.rename_buffer.clear();
8926            return Ok(());
8927        }
8928        if name.chars().count() > 32 {
8929            anyhow::bail!("name must be 1–32 characters");
8930        }
8931        let Some(row) = self.state.inventory_selected_row() else {
8932            anyhow::bail!("inventory empty");
8933        };
8934        let Some(instance_id) = row.stack.item_instance_id else {
8935            anyhow::bail!("item has no instance id");
8936        };
8937        self.seq += 1;
8938        self.session
8939            .submit_intent(Intent::RenameContainer {
8940                entity_id: self.state.entity_id,
8941                item_instance_id: instance_id,
8942                location: row.from.clone(),
8943                name,
8944                seq: self.seq,
8945            })
8946            .await?;
8947        self.state.intents_sent += 1;
8948        self.state.show_rename_prompt = false;
8949        self.state.rename_buffer.clear();
8950        Ok(())
8951    }
8952
8953    pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
8954        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
8955            anyhow::bail!("no worker selected");
8956        };
8957        self.state.rename_buffer = worker.label.clone();
8958        self.state.show_worker_rename = true;
8959        self.state.show_rename_prompt = false;
8960        Ok(())
8961    }
8962
8963    pub fn cancel_worker_rename(&mut self) {
8964        self.state.show_worker_rename = false;
8965        self.state.rename_buffer.clear();
8966    }
8967
8968    pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
8969        let name = self.state.rename_buffer.trim().to_string();
8970        if name.is_empty() {
8971            anyhow::bail!("name cannot be empty");
8972        }
8973        if name.chars().count() > 32 {
8974            anyhow::bail!("name must be 1–32 characters");
8975        }
8976        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
8977            anyhow::bail!("no worker selected");
8978        };
8979        let worker_instance_id = worker.instance_id.clone();
8980        self.seq += 1;
8981        self.session
8982            .submit_intent(Intent::RenameHiredWorker {
8983                entity_id: self.state.entity_id,
8984                worker_instance_id: worker_instance_id.clone(),
8985                name: name.clone(),
8986                seq: self.seq,
8987            })
8988            .await?;
8989        self.state.intents_sent += 1;
8990        if let Some(w) = self
8991            .state
8992            .hired_workers
8993            .iter_mut()
8994            .find(|w| w.instance_id == worker_instance_id)
8995        {
8996            w.label = name.clone();
8997        }
8998        if let Some(ed) = self.state.worker_route_editor.as_mut() {
8999            if ed.worker_instance_id == worker_instance_id {
9000                ed.worker_label = name.clone();
9001            }
9002        }
9003        self.state.show_worker_rename = false;
9004        self.state.rename_buffer.clear();
9005        self.state.push_log(format!("Renamed worker to \"{name}\""));
9006        Ok(())
9007    }
9008
9009    pub fn toggle_inventory_menu(&mut self) {
9010        if self.state.show_inventory_menu {
9011            self.close_inventory_menu();
9012        } else {
9013            self.open_inventory_menu();
9014        }
9015    }
9016
9017    /// ↑/↓ in the inventory browser, or within the "move to…" / grant picker when open.
9018    pub fn inventory_menu_move(&mut self, delta: i32) {
9019        if self.state.show_grant_picker {
9020            let Some(picker) = self.state.grant_picker.as_ref() else {
9021                return;
9022            };
9023            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
9024            let filter = picker.filter.clone();
9025            let n = labels.len();
9026            if n == 0 {
9027                return;
9028            }
9029            self.state.grant_picker_index =
9030                step_filtered_index(self.state.grant_picker_index, delta, n, |i| {
9031                    list_label_matches(&labels[i], &filter)
9032                });
9033            return;
9034        }
9035        if self.state.show_move_picker {
9036            let Some(picker) = self.state.move_picker.as_ref() else {
9037                return;
9038            };
9039            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
9040            let filter = picker.filter.clone();
9041            let n = labels.len();
9042            if n == 0 {
9043                return;
9044            }
9045            self.state.move_picker_index =
9046                step_filtered_index(self.state.move_picker_index, delta, n, |i| {
9047                    list_label_matches(&labels[i], &filter)
9048                });
9049            self.state.clamp_move_picker_quantity();
9050            return;
9051        }
9052        let n = self.state.inventory_selectable_rows().len();
9053        if n == 0 {
9054            return;
9055        }
9056        let idx = self.state.inventory_menu_index as i32;
9057        self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9058    }
9059
9060    /// PageUp / PageDown (±1 page) for inventory / move / grant lists.
9061    pub fn inventory_menu_page(&mut self, pages: i32) {
9062        if self.state.show_grant_picker {
9063            let Some(picker) = self.state.grant_picker.as_ref() else {
9064                return;
9065            };
9066            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
9067            let filter = picker.filter.clone();
9068            let n = labels.len();
9069            self.state.grant_picker_index =
9070                page_filtered_index(self.state.grant_picker_index, pages, n, |i| {
9071                    list_label_matches(&labels[i], &filter)
9072                });
9073            return;
9074        }
9075        if self.state.show_move_picker {
9076            let Some(picker) = self.state.move_picker.as_ref() else {
9077                return;
9078            };
9079            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
9080            let filter = picker.filter.clone();
9081            let n = labels.len();
9082            self.state.move_picker_index =
9083                page_filtered_index(self.state.move_picker_index, pages, n, |i| {
9084                    list_label_matches(&labels[i], &filter)
9085                });
9086            self.state.clamp_move_picker_quantity();
9087            return;
9088        }
9089        let n = self.state.inventory_selectable_rows().len();
9090        self.state.inventory_menu_index =
9091            page_list_index(self.state.inventory_menu_index, pages, n);
9092    }
9093
9094    pub fn cycle_inventory_tab(&mut self, forward: bool) {
9095        if self.state.show_move_picker
9096            || self.state.show_grant_picker
9097            || self.state.show_destroy_picker
9098            || self.state.show_rename_prompt
9099            || self.state.inventory_filter_focused
9100        {
9101            return;
9102        }
9103        self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
9104        self.state.inventory_menu_index = 0;
9105        self.state.clamp_inventory_indices();
9106    }
9107
9108    pub fn focus_inventory_filter(&mut self) {
9109        if self.state.show_grant_picker {
9110            if let Some(p) = self.state.grant_picker.as_mut() {
9111                p.filter_focused = true;
9112            }
9113            return;
9114        }
9115        if self.state.show_move_picker {
9116            if let Some(p) = self.state.move_picker.as_mut() {
9117                p.filter_focused = true;
9118            }
9119            return;
9120        }
9121        self.state.inventory_filter_focused = true;
9122    }
9123
9124    pub fn set_inventory_filter(&mut self, filter: String) {
9125        self.state.inventory_filter = filter;
9126        self.state.inventory_menu_index = 0;
9127        self.state.clamp_inventory_indices();
9128    }
9129
9130    pub fn append_inventory_filter_char(&mut self, ch: char) {
9131        if !is_list_filter_char(ch) {
9132            return;
9133        }
9134        if self.state.show_grant_picker {
9135            if let Some(p) = self.state.grant_picker.as_mut() {
9136                if p.filter_focused {
9137                    p.filter.push(ch);
9138                    self.state.grant_picker_index = 0;
9139                }
9140            }
9141            return;
9142        }
9143        if self.state.show_move_picker {
9144            if let Some(p) = self.state.move_picker.as_mut() {
9145                if p.filter_focused {
9146                    p.filter.push(ch);
9147                    self.state.move_picker_index = 0;
9148                    self.state.clamp_move_picker_quantity();
9149                }
9150            }
9151            return;
9152        }
9153        if !self.state.inventory_filter_focused {
9154            return;
9155        }
9156        self.state.inventory_filter.push(ch);
9157        self.state.inventory_menu_index = 0;
9158        self.state.clamp_inventory_indices();
9159    }
9160
9161    pub fn inventory_filter_backspace(&mut self) {
9162        if self.state.show_grant_picker {
9163            if let Some(p) = self.state.grant_picker.as_mut() {
9164                if p.filter_focused {
9165                    p.filter.pop();
9166                    self.state.grant_picker_index = 0;
9167                }
9168            }
9169            return;
9170        }
9171        if self.state.show_move_picker {
9172            if let Some(p) = self.state.move_picker.as_mut() {
9173                if p.filter_focused {
9174                    p.filter.pop();
9175                    self.state.move_picker_index = 0;
9176                    self.state.clamp_move_picker_quantity();
9177                }
9178            }
9179            return;
9180        }
9181        if !self.state.inventory_filter_focused {
9182            return;
9183        }
9184        self.state.inventory_filter.pop();
9185        self.state.inventory_menu_index = 0;
9186        self.state.clamp_inventory_indices();
9187    }
9188
9189    /// Esc while filter focused: clear filter then blur. Returns true if handled.
9190    pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
9191        if self.state.show_grant_picker {
9192            if let Some(p) = self.state.grant_picker.as_mut() {
9193                if p.filter_focused {
9194                    if !p.filter.is_empty() {
9195                        p.filter.clear();
9196                        self.state.grant_picker_index = 0;
9197                    } else {
9198                        p.filter_focused = false;
9199                    }
9200                    return true;
9201                }
9202                if !p.filter.is_empty() {
9203                    p.filter.clear();
9204                    self.state.grant_picker_index = 0;
9205                    return true;
9206                }
9207            }
9208            return false;
9209        }
9210        if self.state.show_move_picker {
9211            if let Some(p) = self.state.move_picker.as_mut() {
9212                if p.filter_focused {
9213                    if !p.filter.is_empty() {
9214                        p.filter.clear();
9215                        self.state.move_picker_index = 0;
9216                        self.state.clamp_move_picker_quantity();
9217                    } else {
9218                        p.filter_focused = false;
9219                    }
9220                    return true;
9221                }
9222                if !p.filter.is_empty() {
9223                    p.filter.clear();
9224                    self.state.move_picker_index = 0;
9225                    self.state.clamp_move_picker_quantity();
9226                    return true;
9227                }
9228            }
9229            return false;
9230        }
9231        if self.state.inventory_filter_focused {
9232            if !self.state.inventory_filter.is_empty() {
9233                self.state.inventory_filter.clear();
9234                self.state.inventory_menu_index = 0;
9235                self.state.clamp_inventory_indices();
9236            } else {
9237                self.state.inventory_filter_focused = false;
9238            }
9239            return true;
9240        }
9241        if !self.state.inventory_filter.is_empty() {
9242            self.state.inventory_filter.clear();
9243            self.state.inventory_menu_index = 0;
9244            self.state.clamp_inventory_indices();
9245            return true;
9246        }
9247        false
9248    }
9249
9250    pub fn craft_menu_page(&mut self, pages: i32) {
9251        let n = self.state.craft_filtered_indices().len();
9252        self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
9253        self.state.clamp_craft_batch_quantity();
9254    }
9255
9256    pub fn shop_menu_page(&mut self, pages: i32) {
9257        let n = self.state.shop_list_len();
9258        self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
9259        self.state.clamp_shop_quantity();
9260    }
9261
9262    pub fn workers_menu_page(&mut self, pages: i32) {
9263        let n = self.state.hired_workers.len();
9264        self.state.workers_menu_index = page_list_index(self.state.workers_menu_index, pages, n);
9265    }
9266
9267    /// Enter: unequip a worn bag, equip a weapon, wear/place a loose bag or
9268    /// chest — or fall back to the "move to…" destination picker (including
9269    /// loose consumables; pick "Use" in that list or press `e` to eat/drink).
9270    /// Placed chest shells open a pick-up destination picker (`l` still locks).
9271    pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
9272        if self.state.show_destroy_picker {
9273            if self.state.destroy_confirm_pending {
9274                return self.confirm_destroy_item().await;
9275            }
9276            return self.request_destroy_confirm();
9277        }
9278        if self.state.show_grant_picker {
9279            return self.confirm_grant_picker().await;
9280        }
9281        if self.state.show_move_picker {
9282            return self.confirm_move_picker().await;
9283        }
9284        let Some(row) = self.state.inventory_selected_row() else {
9285            anyhow::bail!("inventory empty");
9286        };
9287        if row.is_equip_shell {
9288            let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
9289                anyhow::bail!("not a worn item");
9290            };
9291            return self.equip_worn(slot, None).await;
9292        }
9293        if row.is_chest_shell {
9294            return self.open_chest_pickup_picker();
9295        }
9296        let template_id = row.stack.template_id.clone();
9297        let instance_id = row.stack.item_instance_id;
9298        let category = self.state.inventory_item_category(&template_id);
9299        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9300
9301        if category == Some("weapon") {
9302            return self.equip_mainhand(Some(template_id)).await;
9303        }
9304        if category == Some("lodging") && on_person {
9305            if let Some(inst) = instance_id {
9306                return self.place_container(inst).await;
9307            }
9308        }
9309        // Placeable workshop tools (kiln, furnace) — Enter places them like chests.
9310        if on_person {
9311            if let Some(inst) = instance_id {
9312                if row.stack.world_placeable == Some(true) {
9313                    return self.place_container(inst).await;
9314                }
9315            }
9316        }
9317        if (category == Some("container") || category == Some("armor")) && on_person {
9318            if let Some(inst) = instance_id {
9319                let world_placeable =
9320                    row.stack.world_placeable == Some(true) || template_id.contains("chest");
9321                if world_placeable {
9322                    return self.place_container(inst).await;
9323                }
9324                // Pouches no longer equip directly — they clip onto a worn belt's loops
9325                // instead, so fall through to the move picker (offers "belt loop" when a
9326                // belt is worn). Backpacks/belts/armor equip straight to their body slot.
9327                if let Some(slot) = guess_body_slot(&template_id) {
9328                    return self.equip_worn(slot, Some(inst)).await;
9329                }
9330            }
9331        }
9332        // Anything else (materials, consumables, pouches, items nested in a bag/chest,
9333        // weapons you'd rather stash than wield, ...) — offer explicit places to move it
9334        // instead of guessing.
9335        self.open_move_picker()
9336    }
9337
9338    /// `e`: eat/drink a consumable, or open grant-target picker for grant oils/scrolls.
9339    pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
9340        let Some(row) = self.state.inventory_selected_row() else {
9341            anyhow::bail!("inventory empty");
9342        };
9343        if row.from != flatland_protocol::InventoryLocation::Root {
9344            anyhow::bail!("select a consumable on your person");
9345        }
9346        if GameState::stack_is_item_grant(&row.stack) {
9347            return self.open_grant_target_picker();
9348        }
9349        if GameState::is_property_deed_template(&row.stack.template_id) {
9350            return self.open_move_picker();
9351        }
9352        let category = self.state.inventory_item_category(&row.stack.template_id);
9353        if category != Some("consumable") && !GameState::stack_is_serving(&row.stack) {
9354            anyhow::bail!("selected item is not usable");
9355        }
9356        self.use_item(&row.stack.template_id).await
9357    }
9358
9359    /// Open picker: apply selected grant item onto inventory / worn gear.
9360    pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
9361        let Some(row) = self.state.inventory_selected_row() else {
9362            anyhow::bail!("inventory empty");
9363        };
9364        if row.from != flatland_protocol::InventoryLocation::Root {
9365            anyhow::bail!("select a grant item on your person");
9366        }
9367        if !GameState::stack_is_item_grant(&row.stack) {
9368            anyhow::bail!("selected item does not grant onto gear");
9369        }
9370        let Some(grant_instance_id) = row.stack.item_instance_id else {
9371            anyhow::bail!("grant has no instance id");
9372        };
9373        let effect_id = GameState::grant_effect_id(&row.stack)
9374            .unwrap_or("?")
9375            .to_string();
9376        let mode = GameState::grant_mode(&row.stack).to_string();
9377        let options = self.state.grant_target_options(&row.stack);
9378        if options.is_empty() {
9379            anyhow::bail!("no valid gear to apply {effect_id} to");
9380        }
9381        let grant_label = row
9382            .stack
9383            .display_name
9384            .clone()
9385            .unwrap_or_else(|| row.stack.template_id.clone());
9386        self.state.show_grant_picker = true;
9387        self.state.grant_picker_index = 0;
9388        self.state.grant_picker = Some(GrantTargetPicker {
9389            grant_instance_id,
9390            grant_label,
9391            effect_id,
9392            mode,
9393            options,
9394            filter: String::new(),
9395            filter_focused: false,
9396        });
9397        Ok(())
9398    }
9399
9400    pub fn close_grant_picker(&mut self) {
9401        self.state.show_grant_picker = false;
9402        self.state.grant_picker = None;
9403        self.state.grant_picker_index = 0;
9404    }
9405
9406    pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
9407        let Some(picker) = self.state.grant_picker.clone() else {
9408            self.close_grant_picker();
9409            return Ok(());
9410        };
9411        let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
9412            self.close_grant_picker();
9413            return Ok(());
9414        };
9415        self.close_grant_picker();
9416        self.use_grant(picker.grant_instance_id, opt.target_instance_id)
9417            .await?;
9418        self.state
9419            .push_log(format!("Applying {} onto {}…", picker.effect_id, opt.label));
9420        Ok(())
9421    }
9422
9423    /// `m`: always open the "move to…" picker for the selected item, even for
9424    /// weapons/wearables that Enter would otherwise equip/wear directly.
9425    /// Placed chests open the pick-up destination picker instead.
9426    pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
9427        let Some(row) = self.state.inventory_selected_row() else {
9428            anyhow::bail!("inventory empty");
9429        };
9430        if row.is_equip_shell {
9431            anyhow::bail!("this is a worn bag — press Enter to unequip it");
9432        }
9433        if row.is_chest_shell {
9434            return self.open_chest_pickup_picker();
9435        }
9436        let Some(instance_id) = row.stack.item_instance_id else {
9437            anyhow::bail!("item has no instance id");
9438        };
9439        let mut options = self.state.move_destinations_for(
9440            &row.from,
9441            row.from_parent_instance_id,
9442            row.stack.item_instance_id,
9443            &row.stack.template_id,
9444        );
9445        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9446        let category = self.state.inventory_item_category(&row.stack.template_id);
9447        if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
9448            if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
9449                options.insert(
9450                    0,
9451                    MoveOption::action(
9452                        "Sell plot to crown…",
9453                        MoveOptionKind::SellPlotToCrown { plot_id },
9454                    ),
9455                );
9456            }
9457        }
9458        if on_person && category == Some("consumable") {
9459            if GameState::stack_is_item_grant(&row.stack) {
9460                options.insert(
9461                    0,
9462                    MoveOption::action("Apply onto gear…", MoveOptionKind::GrantApply),
9463                );
9464            } else {
9465                let study = GameState::stack_is_blueprint_scroll(&row.stack);
9466                options.insert(
9467                    0,
9468                    MoveOption::action(
9469                        if study { "Study" } else { "Use (eat / drink)" },
9470                        MoveOptionKind::Use,
9471                    ),
9472                );
9473            }
9474        } else if on_person && GameState::stack_is_serving(&row.stack) {
9475            let label = if GameState::stack_is_food_serving(&row.stack) {
9476                "Use (eat)"
9477            } else {
9478                "Use (fill / drink)"
9479            };
9480            options.insert(0, MoveOption::action(label, MoveOptionKind::Use));
9481        }
9482        let item_label = row
9483            .stack
9484            .display_name
9485            .clone()
9486            .unwrap_or_else(|| row.stack.template_id.clone());
9487        // Default to 1 so withdrawing from storage is partial unless the player
9488        // presses `a` for max fit (full stack when the destination allows it).
9489        let initial_qty = if row.stack.quantity > 1 {
9490            1
9491        } else {
9492            row.stack.quantity
9493        };
9494        self.state.move_picker = Some(MovePicker {
9495            item_instance_id: instance_id,
9496            from: row.from,
9497            item_label,
9498            template_id: row.stack.template_id.clone(),
9499            stack_quantity: row.stack.quantity,
9500            quantity: initial_qty.max(1),
9501            options,
9502            filter: String::new(),
9503            filter_focused: false,
9504        });
9505        self.state.move_picker_index = 0;
9506        self.state.show_move_picker = true;
9507        self.state.show_destroy_picker = false;
9508        self.state.destroy_confirm_pending = false;
9509        self.state.destroy_picker = None;
9510        self.state.clamp_move_picker_quantity();
9511        Ok(())
9512    }
9513
9514    /// Enter/`m` on a placed chest shell: pick destinations to take it into inventory.
9515    pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
9516        let Some(row) = self.state.inventory_selected_row() else {
9517            anyhow::bail!("inventory empty");
9518        };
9519        if !row.is_chest_shell {
9520            anyhow::bail!("not a placed chest");
9521        }
9522        let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
9523            anyhow::bail!("not a placed chest");
9524        };
9525        let Some(instance_id) = row.stack.item_instance_id else {
9526            anyhow::bail!("chest has no instance id");
9527        };
9528        let chest = self
9529            .state
9530            .placed_containers
9531            .iter()
9532            .find(|c| c.id == *container_id)
9533            .cloned()
9534            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
9535        let (px, py) = self.state.player_position();
9536        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
9537            anyhow::bail!("too far from {}", chest.display_name);
9538        }
9539        if chest.locked && !chest.accessible {
9540            anyhow::bail!(
9541                "need the matching key for {} before picking it up",
9542                chest.display_name
9543            );
9544        }
9545        let options = self.state.chest_pickup_destinations(container_id);
9546        let item_label = row
9547            .stack
9548            .display_name
9549            .clone()
9550            .unwrap_or_else(|| row.stack.template_id.clone());
9551        self.state.move_picker = Some(MovePicker {
9552            item_instance_id: instance_id,
9553            from: row.from.clone(),
9554            item_label,
9555            template_id: row.stack.template_id.clone(),
9556            stack_quantity: 1,
9557            quantity: 1,
9558            options,
9559            filter: String::new(),
9560            filter_focused: false,
9561        });
9562        self.state.move_picker_index = 0;
9563        self.state.show_move_picker = true;
9564        self.state.show_destroy_picker = false;
9565        self.state.destroy_confirm_pending = false;
9566        self.state.destroy_picker = None;
9567        Ok(())
9568    }
9569
9570    pub fn close_move_picker(&mut self) {
9571        self.state.show_move_picker = false;
9572        self.state.move_picker = None;
9573    }
9574
9575    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
9576        self.state.move_picker_adjust_quantity(delta);
9577    }
9578
9579    pub fn move_picker_set_quantity_max(&mut self) {
9580        self.state.move_picker_set_quantity_max();
9581    }
9582
9583    pub fn move_picker_set_quantity_min(&mut self) {
9584        self.state.move_picker_set_quantity_min();
9585    }
9586
9587    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
9588        self.state.destroy_picker_adjust_quantity(delta);
9589    }
9590
9591    pub fn destroy_picker_set_quantity_max(&mut self) {
9592        self.state.destroy_picker_set_quantity_max();
9593    }
9594
9595    pub fn destroy_picker_set_quantity_min(&mut self) {
9596        self.state.destroy_picker_set_quantity_min();
9597    }
9598
9599    async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
9600        let Some(picker) = self.state.move_picker.clone() else {
9601            self.close_move_picker();
9602            return Ok(());
9603        };
9604        let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
9605            self.close_move_picker();
9606            return Ok(());
9607        };
9608        match option.kind {
9609            MoveOptionKind::Cancel => {
9610                self.close_move_picker();
9611            }
9612            MoveOptionKind::Use => {
9613                self.close_move_picker();
9614                self.use_item(&picker.template_id).await?;
9615            }
9616            MoveOptionKind::GrantApply => {
9617                self.close_move_picker();
9618                self.open_grant_target_picker()?;
9619            }
9620            MoveOptionKind::SellPlotToCrown { plot_id } => {
9621                self.close_move_picker();
9622                self.confirm_sell_plot_to_crown(plot_id).await?;
9623            }
9624            MoveOptionKind::RelocatePlaced { container_id } => {
9625                self.close_move_picker();
9626                self.state.show_inventory_menu = false;
9627                self.begin_relocate_container(&container_id)?;
9628            }
9629            MoveOptionKind::Drop => {
9630                self.close_move_picker();
9631                if self
9632                    .state
9633                    .hand_equipped_instance_ids()
9634                    .contains(&picker.item_instance_id)
9635                {
9636                    anyhow::bail!("unequip that item first");
9637                }
9638                if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
9639                    if self.state.deed_bound(&stack) {
9640                        anyhow::bail!(
9641                            "cannot drop a property deed — store it or trade it to another player"
9642                        );
9643                    }
9644                    if self.state.key_drop_blocked(&stack) {
9645                        anyhow::bail!("cannot drop the key while its chest is locked");
9646                    }
9647                }
9648                self.drop_item(picker.item_instance_id, picker.from).await?;
9649                self.state
9650                    .push_log(format!("Dropped {}", picker.item_label));
9651            }
9652            MoveOptionKind::PickupPlaced {
9653                container_id,
9654                nest_location,
9655                nest_parent_instance_id,
9656            } => {
9657                self.close_move_picker();
9658                self.pickup_container(container_id.clone()).await?;
9659                let nest_into_bag = nest_parent_instance_id.is_some()
9660                    || !matches!(nest_location, flatland_protocol::InventoryLocation::Root);
9661                if nest_into_bag {
9662                    self.move_item(
9663                        picker.item_instance_id,
9664                        flatland_protocol::InventoryLocation::Root,
9665                        nest_location,
9666                        nest_parent_instance_id,
9667                        None,
9668                    )
9669                    .await?;
9670                    self.state
9671                        .push_log(format!("Picked up {} into bag", picker.item_label));
9672                } else {
9673                    self.state
9674                        .push_log(format!("Picked up {}", picker.item_label));
9675                }
9676            }
9677            MoveOptionKind::Move {
9678                location,
9679                parent_instance_id,
9680            } => {
9681                self.close_move_picker();
9682                let qty = if picker.quantity >= picker.stack_quantity {
9683                    None
9684                } else {
9685                    Some(picker.quantity)
9686                };
9687                self.move_item(
9688                    picker.item_instance_id,
9689                    picker.from,
9690                    location,
9691                    parent_instance_id,
9692                    qty,
9693                )
9694                .await?;
9695                let moved = qty.unwrap_or(picker.stack_quantity);
9696                if moved >= picker.stack_quantity {
9697                    self.state.push_log(format!("Moved {}", picker.item_label));
9698                } else {
9699                    self.state.push_log(format!(
9700                        "Moved {} ×{} of {}",
9701                        picker.item_label, moved, picker.stack_quantity
9702                    ));
9703                }
9704            }
9705        }
9706        Ok(())
9707    }
9708
9709    /// `d`: place furniture (kiln, chest, bed) at your feet, or drop other items
9710    /// as ground loot. Placeable tools must not become loot piles — companions
9711    /// would vacuum them and they'd look "gone".
9712    pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
9713        let Some(row) = self.state.inventory_selected_row() else {
9714            anyhow::bail!("inventory empty");
9715        };
9716        if row.is_equip_shell {
9717            anyhow::bail!("unequip the bag first (Enter), then drop from your person");
9718        }
9719        if row.is_chest_shell {
9720            anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
9721        }
9722        let Some(inst) = row.stack.item_instance_id else {
9723            anyhow::bail!("item has no instance id");
9724        };
9725        if self.state.hand_equipped_instance_ids().contains(&inst) {
9726            anyhow::bail!("unequip that item first");
9727        }
9728        if self.state.deed_bound(&row.stack) {
9729            anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
9730        }
9731        if self.state.key_drop_blocked(&row.stack) {
9732            anyhow::bail!("cannot drop the key while its chest is locked");
9733        }
9734        let label = row
9735            .stack
9736            .display_name
9737            .clone()
9738            .unwrap_or_else(|| row.stack.template_id.clone());
9739        let placeable = row.stack.world_placeable == Some(true)
9740            || row.from == flatland_protocol::InventoryLocation::Root
9741                && matches!(
9742                    self.state
9743                        .inventory_item_category(&row.stack.template_id)
9744                        .as_deref(),
9745                    Some("lodging")
9746                );
9747        if placeable && row.from == flatland_protocol::InventoryLocation::Root {
9748            self.place_container(inst).await?;
9749            self.state.push_log(format!("Placed {label}"));
9750            return Ok(());
9751        }
9752        self.drop_item(inst, row.from).await?;
9753        self.state.push_log(format!("Dropped {label}"));
9754        Ok(())
9755    }
9756
9757    pub async fn drop_item(
9758        &mut self,
9759        item_instance_id: uuid::Uuid,
9760        from: flatland_protocol::InventoryLocation,
9761    ) -> anyhow::Result<()> {
9762        self.seq += 1;
9763        self.session
9764            .submit_intent(Intent::DropItem {
9765                entity_id: self.state.entity_id,
9766                item_instance_id,
9767                from,
9768                seq: self.seq,
9769            })
9770            .await?;
9771        self.state.intents_sent += 1;
9772        Ok(())
9773    }
9774
9775    /// `x`: open permanent-delete picker for the selected item (quantity + confirm).
9776    pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
9777        let Some(row) = self.state.inventory_selected_row() else {
9778            anyhow::bail!("inventory empty");
9779        };
9780        if row.is_equip_shell {
9781            anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
9782        }
9783        if row.is_chest_shell {
9784            anyhow::bail!("can't destroy a placed chest from the inventory list");
9785        }
9786        let Some(instance_id) = row.stack.item_instance_id else {
9787            anyhow::bail!("item has no instance id");
9788        };
9789        if self
9790            .state
9791            .hand_equipped_instance_ids()
9792            .contains(&instance_id)
9793        {
9794            anyhow::bail!("unequip that item first");
9795        }
9796        if self.state.deed_bound(&row.stack) {
9797            anyhow::bail!(
9798                "cannot destroy a property deed — store it or trade it to another player"
9799            );
9800        }
9801        if self.state.key_drop_blocked(&row.stack) {
9802            anyhow::bail!("cannot destroy the key while its chest is locked");
9803        }
9804        let item_label = row
9805            .stack
9806            .display_name
9807            .clone()
9808            .unwrap_or_else(|| row.stack.template_id.clone());
9809        self.state.destroy_picker = Some(DestroyPicker {
9810            item_instance_id: instance_id,
9811            from: row.from,
9812            item_label,
9813            stack_quantity: row.stack.quantity,
9814            quantity: row.stack.quantity,
9815        });
9816        self.state.destroy_confirm_pending = false;
9817        self.state.show_destroy_picker = true;
9818        self.state.show_move_picker = false;
9819        self.state.move_picker = None;
9820        Ok(())
9821    }
9822
9823    pub fn close_destroy_picker(&mut self) {
9824        self.state.show_destroy_picker = false;
9825        self.state.destroy_confirm_pending = false;
9826        self.state.destroy_picker = None;
9827    }
9828
9829    pub fn cancel_destroy_confirm(&mut self) {
9830        self.state.destroy_confirm_pending = false;
9831    }
9832
9833    pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
9834        if self.state.destroy_picker.is_none() {
9835            self.close_destroy_picker();
9836            return Ok(());
9837        }
9838        self.state.destroy_confirm_pending = true;
9839        Ok(())
9840    }
9841
9842    pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
9843        let Some(picker) = self.state.destroy_picker.clone() else {
9844            self.close_destroy_picker();
9845            return Ok(());
9846        };
9847        let qty = if picker.quantity >= picker.stack_quantity {
9848            None
9849        } else {
9850            Some(picker.quantity)
9851        };
9852        self.destroy_item(picker.item_instance_id, picker.from, qty)
9853            .await?;
9854        let destroyed = qty.unwrap_or(picker.stack_quantity);
9855        if destroyed >= picker.stack_quantity {
9856            self.state
9857                .push_log(format!("Destroyed {}", picker.item_label));
9858        } else {
9859            self.state.push_log(format!(
9860                "Destroyed {} ×{} of {}",
9861                picker.item_label, destroyed, picker.stack_quantity
9862            ));
9863        }
9864        self.close_destroy_picker();
9865        Ok(())
9866    }
9867
9868    pub async fn destroy_item(
9869        &mut self,
9870        item_instance_id: uuid::Uuid,
9871        from: flatland_protocol::InventoryLocation,
9872        quantity: Option<u32>,
9873    ) -> anyhow::Result<()> {
9874        self.seq += 1;
9875        self.session
9876            .submit_intent(Intent::DestroyItem {
9877                entity_id: self.state.entity_id,
9878                item_instance_id,
9879                from,
9880                quantity,
9881                seq: self.seq,
9882            })
9883            .await?;
9884        self.state.intents_sent += 1;
9885        Ok(())
9886    }
9887
9888    /// `l`: lock/unlock a placed chest — selected chest in inventory UI, else nearest.
9889    pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
9890        if let Some(row) = self.state.inventory_selected_row() {
9891            if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
9892                return self.toggle_placed_chest_lock(container_id).await;
9893            }
9894        }
9895        self.toggle_nearby_chest_lock().await
9896    }
9897
9898    pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
9899        let chest = self
9900            .state
9901            .placed_containers
9902            .iter()
9903            .find(|c| c.id == container_id)
9904            .cloned()
9905            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
9906        let (px, py) = self.state.player_position();
9907        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
9908            anyhow::bail!("too far from {}", chest.display_name);
9909        }
9910        if !chest.accessible && chest.locked {
9911            anyhow::bail!(
9912                "need the matching key for {} (each crafted chest has its own key)",
9913                chest.display_name
9914            );
9915        }
9916        let lock = !chest.locked;
9917        self.set_container_locked(
9918            flatland_protocol::InventoryLocation::Placed {
9919                container_id: chest.id.clone(),
9920            },
9921            lock,
9922        )
9923        .await?;
9924        self.state.push_log(if lock {
9925            format!("Locked {}", chest.display_name)
9926        } else {
9927            format!("Unlocked {}", chest.display_name)
9928        });
9929        Ok(())
9930    }
9931
9932    /// `l` outside inventory: lock/unlock the nearest placed chest (within `CONTAINER_RANGE_M`).
9933    pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
9934        let chest = self
9935            .state
9936            .nearest_placed_container(CONTAINER_RANGE_M)
9937            .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
9938        self.toggle_placed_chest_lock(&chest.id).await
9939    }
9940
9941    pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
9942        self.equip_mainhand(None).await
9943    }
9944
9945    pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
9946        if !self.state.is_alive() {
9947            anyhow::bail!("you are dead");
9948        }
9949        self.seq += 1;
9950        self.session
9951            .submit_intent(Intent::EquipOffhand {
9952                entity_id: self.state.entity_id,
9953                template_id,
9954                instance_id: None,
9955                seq: self.seq,
9956            })
9957            .await?;
9958        self.state.intents_sent += 1;
9959        Ok(())
9960    }
9961
9962    pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
9963        self.equip_offhand(None).await
9964    }
9965
9966    pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
9967        let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
9968        for slot in slots {
9969            self.equip_worn(slot, None).await?;
9970        }
9971        Ok(())
9972    }
9973
9974    pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
9975        let (px, py) = self.state.player_position();
9976        let in_range: Vec<_> = self
9977            .state
9978            .placed_containers
9979            .iter()
9980            .filter(|c| self.state.placed_container_in_current_space(c))
9981            .filter(|c| (c.x - px).hypot(c.y - py) <= 2.0)
9982            .collect();
9983        let nearest_free = in_range
9984            .iter()
9985            .copied()
9986            .filter(|c| !self.state.lodging_is_occupied(&c.id))
9987            .min_by(|a, b| {
9988                let da = (a.x - px).hypot(a.y - py);
9989                let db = (b.x - px).hypot(b.y - py);
9990                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
9991            })
9992            .cloned();
9993        if let Some(chest) = nearest_free {
9994            return self.pickup_container(chest.id).await;
9995        }
9996        if in_range
9997            .iter()
9998            .any(|c| self.state.lodging_is_occupied(&c.id))
9999        {
10000            anyhow::bail!("dismiss or reassign workers before picking up lodging");
10001        }
10002        if in_range.is_empty() {
10003            anyhow::bail!("no chest nearby");
10004        }
10005        anyhow::bail!("too far from chest");
10006    }
10007
10008    pub async fn equip_worn(
10009        &mut self,
10010        slot: BodySlot,
10011        instance_id: Option<uuid::Uuid>,
10012    ) -> anyhow::Result<()> {
10013        self.seq += 1;
10014        self.session
10015            .submit_intent(Intent::EquipWorn {
10016                entity_id: self.state.entity_id,
10017                slot,
10018                instance_id,
10019                seq: self.seq,
10020            })
10021            .await?;
10022        self.state.intents_sent += 1;
10023        Ok(())
10024    }
10025
10026    pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
10027        self.seq += 1;
10028        self.session
10029            .submit_intent(Intent::PlaceContainer {
10030                entity_id: self.state.entity_id,
10031                item_instance_id,
10032                seq: self.seq,
10033            })
10034            .await?;
10035        self.state.intents_sent += 1;
10036        Ok(())
10037    }
10038
10039    pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
10040        self.seq += 1;
10041        self.session
10042            .submit_intent(Intent::PickupContainer {
10043                entity_id: self.state.entity_id,
10044                container_id,
10045                seq: self.seq,
10046            })
10047            .await?;
10048        self.state.intents_sent += 1;
10049        Ok(())
10050    }
10051
10052    pub async fn move_item(
10053        &mut self,
10054        item_instance_id: uuid::Uuid,
10055        from: flatland_protocol::InventoryLocation,
10056        to: flatland_protocol::InventoryLocation,
10057        to_parent_instance_id: Option<uuid::Uuid>,
10058        quantity: Option<u32>,
10059    ) -> anyhow::Result<()> {
10060        self.seq += 1;
10061        self.session
10062            .submit_intent(Intent::MoveItem {
10063                entity_id: self.state.entity_id,
10064                item_instance_id,
10065                from,
10066                to,
10067                to_parent_instance_id,
10068                quantity,
10069                seq: self.seq,
10070            })
10071            .await?;
10072        self.state.intents_sent += 1;
10073        Ok(())
10074    }
10075
10076    pub async fn set_container_locked(
10077        &mut self,
10078        location: flatland_protocol::InventoryLocation,
10079        locked: bool,
10080    ) -> anyhow::Result<()> {
10081        self.seq += 1;
10082        self.session
10083            .submit_intent(Intent::SetContainerLocked {
10084                entity_id: self.state.entity_id,
10085                location,
10086                locked,
10087                seq: self.seq,
10088            })
10089            .await?;
10090        self.state.intents_sent += 1;
10091        Ok(())
10092    }
10093
10094    pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
10095        if !self.state.is_alive() {
10096            anyhow::bail!("you are dead");
10097        }
10098        self.seq += 1;
10099        self.session
10100            .submit_intent(Intent::Use {
10101                entity_id: self.state.entity_id,
10102                template_id: template_id.to_string(),
10103                seq: self.seq,
10104            })
10105            .await?;
10106        self.state.intents_sent += 1;
10107        Ok(())
10108    }
10109
10110    /// Bind a grant consumable onto a specific item instance (unique gear status).
10111    pub async fn use_grant(
10112        &mut self,
10113        grant_instance_id: uuid::Uuid,
10114        target_instance_id: uuid::Uuid,
10115    ) -> anyhow::Result<()> {
10116        if !self.state.is_alive() {
10117            anyhow::bail!("you are dead");
10118        }
10119        self.seq += 1;
10120        self.session
10121            .submit_intent(Intent::UseGrant {
10122                entity_id: self.state.entity_id,
10123                grant_instance_id,
10124                target_instance_id,
10125                seq: self.seq,
10126            })
10127            .await?;
10128        self.state.intents_sent += 1;
10129        Ok(())
10130    }
10131
10132    pub fn open_craft_menu(&mut self) {
10133        self.state.show_craft_menu = true;
10134        self.state.show_shop_menu = false;
10135        self.state.shop_catalog = None;
10136        self.state.show_stats = false;
10137        self.state.show_inventory_menu = false;
10138        self.state.reload_craft_prefs();
10139        self.state.craft_tab = CraftTab::Ready;
10140        self.state.craft_filter.clear();
10141        self.state.craft_filter_focused = false;
10142        self.state.craft_menu_index = 0;
10143        self.state.clamp_craft_menu_index();
10144        self.state.craft_batch_quantity = 1;
10145        self.state.clamp_craft_batch_quantity();
10146    }
10147
10148    pub fn close_craft_menu(&mut self) {
10149        self.state.show_craft_menu = false;
10150        self.state.craft_filter_focused = false;
10151    }
10152
10153    pub fn toggle_keychain_menu(&mut self) {
10154        if self.state.show_keychain_menu {
10155            self.close_keychain_menu();
10156        } else {
10157            self.state.show_keychain_menu = true;
10158            self.state.show_craft_menu = false;
10159            self.state.show_shop_menu = false;
10160            self.state.show_inventory_menu = false;
10161            let n = self.state.keychain_entries().len();
10162            if n == 0 {
10163                self.state.keychain_menu_index = 0;
10164            } else {
10165                self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
10166            }
10167        }
10168    }
10169
10170    pub fn close_keychain_menu(&mut self) {
10171        self.state.show_keychain_menu = false;
10172    }
10173
10174    pub fn keychain_menu_move(&mut self, delta: i32) {
10175        let n = self.state.keychain_entries().len();
10176        if n == 0 {
10177            self.state.keychain_menu_index = 0;
10178            return;
10179        }
10180        let idx = self.state.keychain_menu_index as i32 + delta;
10181        self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
10182    }
10183
10184    pub fn keychain_menu_page(&mut self, pages: i32) {
10185        let n = self.state.keychain_entries().len();
10186        self.state.keychain_menu_index = page_list_index(self.state.keychain_menu_index, pages, n);
10187    }
10188
10189    pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
10190        if !self.state.is_alive() {
10191            anyhow::bail!("you are dead");
10192        }
10193        let entries = self.state.keychain_entries();
10194        let Some(entry) = entries.get(self.state.keychain_menu_index) else {
10195            anyhow::bail!("nothing selected");
10196        };
10197        let Some(instance_id) = entry.stack.item_instance_id else {
10198            anyhow::bail!("key has no instance id");
10199        };
10200        if entry.stowed {
10201            self.move_item(
10202                instance_id,
10203                flatland_protocol::InventoryLocation::Keychain,
10204                flatland_protocol::InventoryLocation::Root,
10205                None,
10206                Some(1),
10207            )
10208            .await
10209        } else {
10210            self.move_item(
10211                instance_id,
10212                flatland_protocol::InventoryLocation::Root,
10213                flatland_protocol::InventoryLocation::Keychain,
10214                None,
10215                Some(1),
10216            )
10217            .await
10218        }
10219    }
10220
10221    pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
10222        let npc_id = self.state.shop_catalog.as_ref().map(|c| c.npc_id.clone());
10223        self.state.show_shop_menu = false;
10224        self.state.shop_catalog = None;
10225        self.state.clear_shop_trade_log();
10226        if let Some(npc_id) = npc_id {
10227            self.seq += 1;
10228            self.session
10229                .submit_intent(Intent::ShopClose {
10230                    entity_id: self.state.entity_id,
10231                    npc_id,
10232                    seq: self.seq,
10233                })
10234                .await?;
10235            self.state.intents_sent += 1;
10236        }
10237        Ok(())
10238    }
10239
10240    pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
10241        let Some(panel) = self.state.bank_panel.clone() else {
10242            return Ok(());
10243        };
10244        self.seq += 1;
10245        self.session
10246            .submit_intent(Intent::BankDeposit {
10247                entity_id: self.state.entity_id,
10248                npc_id: panel.npc_id,
10249                amount_copper,
10250                seq: self.seq,
10251            })
10252            .await?;
10253        self.state.intents_sent += 1;
10254        Ok(())
10255    }
10256
10257    pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
10258        let Some(panel) = self.state.bank_panel.clone() else {
10259            return Ok(());
10260        };
10261        self.seq += 1;
10262        self.session
10263            .submit_intent(Intent::BankWithdraw {
10264                entity_id: self.state.entity_id,
10265                npc_id: panel.npc_id,
10266                amount_copper,
10267                seq: self.seq,
10268            })
10269            .await?;
10270        self.state.intents_sent += 1;
10271        Ok(())
10272    }
10273
10274    pub async fn bank_transfer(
10275        &mut self,
10276        to_character_id: Option<uuid::Uuid>,
10277        to_name: String,
10278        amount_copper: u64,
10279    ) -> anyhow::Result<()> {
10280        let Some(panel) = self.state.bank_panel.clone() else {
10281            return Ok(());
10282        };
10283        self.seq += 1;
10284        self.session
10285            .submit_intent(Intent::BankTransfer {
10286                entity_id: self.state.entity_id,
10287                npc_id: panel.npc_id,
10288                to_character_id,
10289                to_name,
10290                amount_copper,
10291                seq: self.seq,
10292            })
10293            .await?;
10294        self.state.intents_sent += 1;
10295        Ok(())
10296    }
10297
10298    pub fn bank_menu_move(&mut self, delta: i32) {
10299        let n = self.state.bank_menu_options().len();
10300        if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
10301            return;
10302        }
10303        let idx = self.state.bank_menu_index as i32;
10304        self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10305    }
10306
10307    pub fn storage_menu_move(&mut self, delta: i32) {
10308        let n = self.state.storage_menu_options().len();
10309        if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
10310            return;
10311        }
10312        let idx = self.state.storage_menu_index as i32;
10313        self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10314    }
10315
10316    pub fn storage_pick_move(&mut self, delta: i32) {
10317        let n = match &self.state.storage_ui_mode {
10318            StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
10319            StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
10320                self.state.storage_vault_options().len()
10321            }
10322            StorageUiMode::Menu
10323            | StorageUiMode::StoreAmount { .. }
10324            | StorageUiMode::TakeAmount { .. }
10325            | StorageUiMode::ShipAmount { .. } => 0,
10326        };
10327        if n == 0 {
10328            return;
10329        }
10330        match &mut self.state.storage_ui_mode {
10331            StorageUiMode::StorePick { index }
10332            | StorageUiMode::TakePick { index }
10333            | StorageUiMode::ShipPick { index, .. } => {
10334                *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10335            }
10336            StorageUiMode::Menu
10337            | StorageUiMode::StoreAmount { .. }
10338            | StorageUiMode::TakeAmount { .. }
10339            | StorageUiMode::ShipAmount { .. } => {}
10340        }
10341    }
10342
10343    pub fn storage_ui_back(&mut self) {
10344        self.state.storage_ui_mode = match &self.state.storage_ui_mode {
10345            StorageUiMode::StoreAmount { pick_index, .. } => {
10346                StorageUiMode::StorePick { index: *pick_index }
10347            }
10348            StorageUiMode::TakeAmount { pick_index, .. } => {
10349                StorageUiMode::TakePick { index: *pick_index }
10350            }
10351            StorageUiMode::ShipAmount {
10352                dest_building_id,
10353                dest_label,
10354                pick_index,
10355                ..
10356            } => StorageUiMode::ShipPick {
10357                dest_building_id: dest_building_id.clone(),
10358                dest_label: dest_label.clone(),
10359                index: *pick_index,
10360            },
10361            StorageUiMode::StorePick { .. }
10362            | StorageUiMode::TakePick { .. }
10363            | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
10364            StorageUiMode::Menu => StorageUiMode::Menu,
10365        };
10366    }
10367
10368    pub fn storage_amount_append_char(&mut self, c: char) {
10369        match &mut self.state.storage_ui_mode {
10370            StorageUiMode::StoreAmount { input, .. }
10371            | StorageUiMode::TakeAmount { input, .. }
10372            | StorageUiMode::ShipAmount { input, .. } => {
10373                if c.is_ascii_digit() && input.len() < 8 {
10374                    input.push(c);
10375                }
10376            }
10377            _ => {}
10378        }
10379    }
10380
10381    pub fn storage_amount_backspace(&mut self) {
10382        match &mut self.state.storage_ui_mode {
10383            StorageUiMode::StoreAmount { input, .. }
10384            | StorageUiMode::TakeAmount { input, .. }
10385            | StorageUiMode::ShipAmount { input, .. } => {
10386                input.pop();
10387            }
10388            _ => {}
10389        }
10390    }
10391
10392    pub fn storage_ui_typing(&self) -> bool {
10393        matches!(
10394            self.state.storage_ui_mode,
10395            StorageUiMode::StoreAmount { .. }
10396                | StorageUiMode::TakeAmount { .. }
10397                | StorageUiMode::ShipAmount { .. }
10398        )
10399    }
10400
10401    pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
10402        match self.state.storage_ui_mode.clone() {
10403            StorageUiMode::Menu => {
10404                let index = self.state.storage_menu_index;
10405                match index {
10406                    0 => {
10407                        let opts = self.state.storage_store_options();
10408                        if opts.is_empty() {
10409                            self.state.push_log("Nothing loose to store.");
10410                            return Ok(());
10411                        }
10412                        self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
10413                    }
10414                    1 => {
10415                        let opts = self.state.storage_vault_options();
10416                        if opts.is_empty() {
10417                            self.state.push_log("Vault is empty.");
10418                            return Ok(());
10419                        }
10420                        self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
10421                    }
10422                    n => {
10423                        let dest = self
10424                            .state
10425                            .storage_panel
10426                            .as_ref()
10427                            .and_then(|p| p.ship_destinations.get(n - 2))
10428                            .cloned();
10429                        let Some(dest) = dest else {
10430                            return Ok(());
10431                        };
10432                        let opts = self.state.storage_vault_options();
10433                        if opts.is_empty() {
10434                            self.state.push_log("Vault is empty — nothing to ship.");
10435                            return Ok(());
10436                        }
10437                        self.state.storage_ui_mode = StorageUiMode::ShipPick {
10438                            dest_building_id: dest.building_id,
10439                            dest_label: dest.label,
10440                            index: 0,
10441                        };
10442                    }
10443                }
10444            }
10445            StorageUiMode::StorePick { index } => {
10446                let opts = self.state.storage_store_options();
10447                let Some(opt) = opts.get(index) else {
10448                    self.state.push_log("Nothing loose to store.");
10449                    self.state.storage_ui_mode = StorageUiMode::Menu;
10450                    return Ok(());
10451                };
10452                self.state.storage_ui_mode = StorageUiMode::StoreAmount {
10453                    pick_index: index,
10454                    item_instance_id: opt.item_instance_id,
10455                    label: opt.label.clone(),
10456                    max_qty: opt.quantity.max(1),
10457                    input: String::new(),
10458                };
10459            }
10460            StorageUiMode::TakePick { index } => {
10461                let opts = self.state.storage_vault_options();
10462                let Some(opt) = opts.get(index) else {
10463                    self.state.push_log("Vault is empty.");
10464                    self.state.storage_ui_mode = StorageUiMode::Menu;
10465                    return Ok(());
10466                };
10467                self.state.storage_ui_mode = StorageUiMode::TakeAmount {
10468                    pick_index: index,
10469                    item_instance_id: opt.item_instance_id,
10470                    label: opt.label.clone(),
10471                    max_qty: opt.quantity.max(1),
10472                    input: String::new(),
10473                };
10474            }
10475            StorageUiMode::ShipPick {
10476                dest_building_id,
10477                dest_label,
10478                index,
10479            } => {
10480                let opts = self.state.storage_vault_options();
10481                let Some(opt) = opts.get(index) else {
10482                    self.state.push_log("Vault is empty — nothing to ship.");
10483                    self.state.storage_ui_mode = StorageUiMode::Menu;
10484                    return Ok(());
10485                };
10486                self.state.storage_ui_mode = StorageUiMode::ShipAmount {
10487                    dest_building_id,
10488                    dest_label,
10489                    pick_index: index,
10490                    item_instance_id: opt.item_instance_id,
10491                    label: opt.label.clone(),
10492                    max_qty: opt.quantity.max(1),
10493                    input: String::new(),
10494                };
10495            }
10496            StorageUiMode::StoreAmount {
10497                item_instance_id,
10498                max_qty,
10499                input,
10500                ..
10501            } => {
10502                let Some(qty) = parse_storage_quantity(&input) else {
10503                    self.state.push_log("Enter a quantity (blank or 0 = all).");
10504                    return Ok(());
10505                };
10506                let qty = qty.map(|n| n.min(max_qty).max(1));
10507                self.storage_store(item_instance_id, qty).await?;
10508                self.state.storage_ui_mode = StorageUiMode::Menu;
10509            }
10510            StorageUiMode::TakeAmount {
10511                item_instance_id,
10512                max_qty,
10513                input,
10514                ..
10515            } => {
10516                let Some(qty) = parse_storage_quantity(&input) else {
10517                    self.state.push_log("Enter a quantity (blank or 0 = all).");
10518                    return Ok(());
10519                };
10520                let qty = qty.map(|n| n.min(max_qty).max(1));
10521                self.storage_take(item_instance_id, qty).await?;
10522                self.state.storage_ui_mode = StorageUiMode::Menu;
10523            }
10524            StorageUiMode::ShipAmount {
10525                dest_building_id,
10526                item_instance_id,
10527                max_qty,
10528                input,
10529                ..
10530            } => {
10531                let Some(qty) = parse_storage_quantity(&input) else {
10532                    self.state.push_log("Enter a quantity (blank or 0 = all).");
10533                    return Ok(());
10534                };
10535                let qty = qty.map(|n| n.min(max_qty).max(1));
10536                self.storage_ship(dest_building_id, item_instance_id, qty)
10537                    .await?;
10538                self.state.storage_ui_mode = StorageUiMode::Menu;
10539            }
10540        }
10541        Ok(())
10542    }
10543
10544    pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
10545        match self.state.bank_ui_mode.clone() {
10546            BankUiMode::Menu => {
10547                let choice = self
10548                    .state
10549                    .bank_menu_options()
10550                    .get(self.state.bank_menu_index)
10551                    .copied()
10552                    .unwrap_or("Deposit…");
10553                match choice {
10554                    "Withdraw…" => {
10555                        self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
10556                            input: String::new(),
10557                        };
10558                    }
10559                    "Deposit all" => self.bank_deposit(0).await?,
10560                    "Withdraw all" => self.bank_withdraw(0).await?,
10561                    "Transfer…" => {
10562                        self.state.bank_ui_mode = BankUiMode::TransferName {
10563                            input: String::new(),
10564                        };
10565                    }
10566                    _ => {
10567                        self.state.bank_ui_mode = BankUiMode::DepositAmount {
10568                            input: String::new(),
10569                        };
10570                    }
10571                }
10572            }
10573            BankUiMode::DepositAmount { input } => {
10574                let Some(amount) = parse_bank_copper_amount(&input) else {
10575                    self.state
10576                        .push_log("Enter a copper amount (blank or 0 = everything on person).");
10577                    return Ok(());
10578                };
10579                self.bank_deposit(amount).await?;
10580                self.state.bank_ui_mode = BankUiMode::Menu;
10581            }
10582            BankUiMode::WithdrawAmount { input } => {
10583                let Some(amount) = parse_bank_copper_amount(&input) else {
10584                    self.state
10585                        .push_log("Enter a copper amount (blank or 0 = full ledger).");
10586                    return Ok(());
10587                };
10588                self.bank_withdraw(amount).await?;
10589                self.state.bank_ui_mode = BankUiMode::Menu;
10590            }
10591            BankUiMode::TransferName { input } => {
10592                let name = input.trim().to_string();
10593                if name.is_empty() {
10594                    self.state.push_log("Enter the recipient character name.");
10595                    return Ok(());
10596                }
10597                self.state.bank_ui_mode = BankUiMode::TransferAmount {
10598                    to_name: name,
10599                    input: String::new(),
10600                };
10601            }
10602            BankUiMode::TransferAmount { to_name, input } => {
10603                let amount: u64 = match input.trim().parse() {
10604                    Ok(v) if v > 0 => v,
10605                    _ => {
10606                        self.state
10607                            .push_log("Enter a positive copper amount to transfer.");
10608                        return Ok(());
10609                    }
10610                };
10611                self.bank_transfer(None, to_name, amount).await?;
10612                self.state.bank_ui_mode = BankUiMode::Menu;
10613            }
10614        }
10615        Ok(())
10616    }
10617
10618    pub fn bank_transfer_back(&mut self) {
10619        match &self.state.bank_ui_mode {
10620            BankUiMode::TransferAmount { to_name, .. } => {
10621                self.state.bank_ui_mode = BankUiMode::TransferName {
10622                    input: to_name.clone(),
10623                };
10624            }
10625            BankUiMode::TransferName { .. }
10626            | BankUiMode::DepositAmount { .. }
10627            | BankUiMode::WithdrawAmount { .. } => {
10628                self.state.bank_ui_mode = BankUiMode::Menu;
10629            }
10630            BankUiMode::Menu => {}
10631        }
10632    }
10633
10634    pub fn bank_transfer_append_char(&mut self, c: char) {
10635        match &mut self.state.bank_ui_mode {
10636            BankUiMode::TransferName { input } => {
10637                if input.len() < 32 && !c.is_control() {
10638                    input.push(c);
10639                }
10640            }
10641            BankUiMode::DepositAmount { input }
10642            | BankUiMode::WithdrawAmount { input }
10643            | BankUiMode::TransferAmount { input, .. } => {
10644                if c.is_ascii_digit() && input.len() < 12 {
10645                    input.push(c);
10646                }
10647            }
10648            BankUiMode::Menu => {}
10649        }
10650    }
10651
10652    pub fn bank_transfer_backspace(&mut self) {
10653        match &mut self.state.bank_ui_mode {
10654            BankUiMode::TransferName { input }
10655            | BankUiMode::DepositAmount { input }
10656            | BankUiMode::WithdrawAmount { input }
10657            | BankUiMode::TransferAmount { input, .. } => {
10658                input.pop();
10659            }
10660            BankUiMode::Menu => {}
10661        }
10662    }
10663
10664    pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
10665        let npc_id = self.state.bank_panel.as_ref().map(|p| p.npc_id.clone());
10666        self.state.clear_bank_panel();
10667        if let Some(npc_id) = npc_id {
10668            self.seq += 1;
10669            self.session
10670                .submit_intent(Intent::BankClose {
10671                    entity_id: self.state.entity_id,
10672                    npc_id,
10673                    seq: self.seq,
10674                })
10675                .await?;
10676            self.state.intents_sent += 1;
10677        }
10678        Ok(())
10679    }
10680
10681    pub async fn storage_store(
10682        &mut self,
10683        item_instance_id: uuid::Uuid,
10684        quantity: Option<u32>,
10685    ) -> anyhow::Result<()> {
10686        let Some(panel) = self.state.storage_panel.clone() else {
10687            return Ok(());
10688        };
10689        self.seq += 1;
10690        self.session
10691            .submit_intent(Intent::StorageStore {
10692                entity_id: self.state.entity_id,
10693                npc_id: panel.npc_id,
10694                item_instance_id,
10695                quantity,
10696                seq: self.seq,
10697            })
10698            .await?;
10699        self.state.intents_sent += 1;
10700        Ok(())
10701    }
10702
10703    pub async fn storage_take(
10704        &mut self,
10705        item_instance_id: uuid::Uuid,
10706        quantity: Option<u32>,
10707    ) -> anyhow::Result<()> {
10708        let Some(panel) = self.state.storage_panel.clone() else {
10709            return Ok(());
10710        };
10711        self.seq += 1;
10712        self.session
10713            .submit_intent(Intent::StorageTake {
10714                entity_id: self.state.entity_id,
10715                npc_id: panel.npc_id,
10716                item_instance_id,
10717                quantity,
10718                seq: self.seq,
10719            })
10720            .await?;
10721        self.state.intents_sent += 1;
10722        Ok(())
10723    }
10724
10725    pub async fn storage_ship(
10726        &mut self,
10727        dest_building_id: String,
10728        item_instance_id: uuid::Uuid,
10729        quantity: Option<u32>,
10730    ) -> anyhow::Result<()> {
10731        let Some(panel) = self.state.storage_panel.clone() else {
10732            return Ok(());
10733        };
10734        self.seq += 1;
10735        self.session
10736            .submit_intent(Intent::StorageShip {
10737                entity_id: self.state.entity_id,
10738                npc_id: panel.npc_id,
10739                dest_building_id,
10740                item_instance_id,
10741                quantity,
10742                seq: self.seq,
10743            })
10744            .await?;
10745        self.state.intents_sent += 1;
10746        Ok(())
10747    }
10748
10749    pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
10750        let npc_id = self.state.storage_panel.as_ref().map(|p| p.npc_id.clone());
10751        self.state.clear_storage_panel();
10752        if let Some(npc_id) = npc_id {
10753            self.seq += 1;
10754            self.session
10755                .submit_intent(Intent::StorageClose {
10756                    entity_id: self.state.entity_id,
10757                    npc_id,
10758                    seq: self.seq,
10759                })
10760                .await?;
10761            self.state.intents_sent += 1;
10762        }
10763        Ok(())
10764    }
10765
10766    pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
10767        let npc_id = self.state.market_panel.as_ref().map(|p| p.npc_id.clone());
10768        self.state.clear_market_panel();
10769        if let Some(npc_id) = npc_id {
10770            self.seq += 1;
10771            self.session
10772                .submit_intent(Intent::MarketClose {
10773                    entity_id: self.state.entity_id,
10774                    npc_id,
10775                    seq: self.seq,
10776                })
10777                .await?;
10778            self.state.intents_sent += 1;
10779        }
10780        Ok(())
10781    }
10782
10783    pub fn market_move_selection(&mut self, delta: i32) {
10784        let indices = self.state.market_filtered_listing_indices();
10785        let n = indices.len();
10786        if n == 0 {
10787            self.state.market_menu_index = 0;
10788            return;
10789        }
10790        let cur = self.state.market_menu_index as i32;
10791        self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
10792    }
10793
10794    pub fn market_page_selection(&mut self, pages: i32) {
10795        let indices = self.state.market_filtered_listing_indices();
10796        let n = indices.len();
10797        if n == 0 {
10798            self.state.market_menu_index = 0;
10799            return;
10800        }
10801        self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
10802    }
10803
10804    pub fn market_list_page(&mut self, pages: i32) {
10805        match &self.state.market_ui_mode {
10806            MarketUiMode::ListSource { index } => {
10807                let n = self.state.market_list_source_options().len();
10808                if n == 0 {
10809                    return;
10810                }
10811                let next = page_list_index(*index, pages, n);
10812                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
10813            }
10814            MarketUiMode::ListPricingMode { index, .. } => {
10815                let next = page_list_index(*index, pages, 2);
10816                if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
10817                {
10818                    *index = next;
10819                }
10820            }
10821            MarketUiMode::ListPick { source, index } => {
10822                let opts = self.state.market_list_item_options(source);
10823                let n = opts.len();
10824                if n == 0 {
10825                    return;
10826                }
10827                let next = page_list_index(*index, pages, n);
10828                self.state.market_ui_mode = MarketUiMode::ListPick {
10829                    source: source.clone(),
10830                    index: next,
10831                };
10832            }
10833            _ => {}
10834        }
10835    }
10836
10837    pub fn market_cycle_category(&mut self, delta: i32) {
10838        let groups = self.state.market_available_category_groups();
10839        // All + groups
10840        let mut labels: Vec<Option<&'static str>> = vec![None];
10841        labels.extend(groups.into_iter().map(Some));
10842        let n = labels.len() as i32;
10843        let cur = labels
10844            .iter()
10845            .position(|g| *g == self.state.market_category_filter)
10846            .unwrap_or(0) as i32;
10847        let next = (cur + delta).rem_euclid(n) as usize;
10848        self.state.market_category_filter = labels[next];
10849        self.state.market_menu_index = 0;
10850        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10851            let source = source.clone();
10852            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10853        }
10854    }
10855
10856    pub fn focus_market_filter(&mut self) {
10857        self.state.market_filter_focused = true;
10858    }
10859
10860    pub fn append_market_filter_char(&mut self, ch: char) {
10861        if !self.state.market_filter_focused {
10862            return;
10863        }
10864        if !is_list_filter_char(ch) {
10865            return;
10866        }
10867        if self.state.market_filter.len() < 48 {
10868            self.state.market_filter.push(ch);
10869            self.state.market_menu_index = 0;
10870            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10871                let source = source.clone();
10872                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10873            }
10874        }
10875    }
10876
10877    pub fn market_filter_backspace(&mut self) {
10878        if !self.state.market_filter_focused {
10879            return;
10880        }
10881        self.state.market_filter.pop();
10882        self.state.market_menu_index = 0;
10883        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10884            let source = source.clone();
10885            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10886        }
10887    }
10888
10889    /// Clears filter text, blurs search, or returns false if already idle.
10890    pub fn clear_or_blur_market_filter(&mut self) -> bool {
10891        if self.state.market_filter_focused {
10892            if !self.state.market_filter.is_empty() {
10893                self.state.market_filter.clear();
10894                self.state.market_menu_index = 0;
10895                if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10896                    let source = source.clone();
10897                    self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10898                }
10899                return true;
10900            }
10901            self.state.market_filter_focused = false;
10902            return true;
10903        }
10904        if !self.state.market_filter.is_empty() {
10905            self.state.market_filter.clear();
10906            self.state.market_menu_index = 0;
10907            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10908                let source = source.clone();
10909                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10910            }
10911            return true;
10912        }
10913        false
10914    }
10915
10916    pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
10917        if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
10918            return self.market_confirm_buy(listing_id, qty).await;
10919        }
10920        let Some(panel) = self.state.market_panel.clone() else {
10921            return Ok(());
10922        };
10923        let indices = self.state.market_filtered_listing_indices();
10924        let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
10925            return Ok(());
10926        };
10927        let Some(listing) = panel.listings.get(raw_idx) else {
10928            return Ok(());
10929        };
10930        if listing.mine {
10931            self.seq += 1;
10932            self.session
10933                .submit_intent(Intent::MarketDelist {
10934                    entity_id: self.state.entity_id,
10935                    npc_id: panel.npc_id.clone(),
10936                    listing_id: listing.listing_id,
10937                    dest: flatland_protocol::GoodsLocation::Person,
10938                    seq: self.seq,
10939                })
10940                .await?;
10941            self.state.intents_sent += 1;
10942            return Ok(());
10943        }
10944        if listing.npc_price {
10945            self.state
10946                .push_log("NPC-price listings are bought by merchants only.");
10947            return Ok(());
10948        }
10949        let qty = 1u32.min(listing.quantity).max(1);
10950        let line = listing.unit_price_copper.saturating_mul(qty as u64);
10951        self.state.market_buy_confirm = Some((
10952            listing.listing_id,
10953            qty,
10954            listing.unit_price_copper,
10955            line,
10956            listing.display_name.clone(),
10957        ));
10958        Ok(())
10959    }
10960
10961    pub async fn market_confirm_buy(
10962        &mut self,
10963        listing_id: uuid::Uuid,
10964        quantity: u32,
10965    ) -> anyhow::Result<()> {
10966        let Some(panel) = self.state.market_panel.clone() else {
10967            self.state.market_buy_confirm = None;
10968            return Ok(());
10969        };
10970        self.state.market_buy_confirm = None;
10971        self.seq += 1;
10972        self.session
10973            .submit_intent(Intent::MarketBuy {
10974                entity_id: self.state.entity_id,
10975                npc_id: panel.npc_id,
10976                listing_id,
10977                quantity,
10978                dest: flatland_protocol::GoodsLocation::Person,
10979                seq: self.seq,
10980            })
10981            .await?;
10982        self.state.intents_sent += 1;
10983        Ok(())
10984    }
10985
10986    /// Begin the list wizard (`l` in market browse).
10987    pub fn market_begin_list(&mut self) {
10988        if self.state.market_panel.is_none() {
10989            return;
10990        }
10991        let sources = self.state.market_list_source_options();
10992        if sources.is_empty() {
10993            self.state.push_log("Nothing to list from.");
10994            return;
10995        }
10996        // One source (person only) → skip straight to item pick when it has stacks.
10997        if sources.len() == 1 {
10998            let (source, _) = sources[0].clone();
10999            let opts = self.state.market_list_item_options(&source);
11000            if opts.is_empty() {
11001                self.state.push_log("Nothing loose to list.");
11002                return;
11003            }
11004            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11005            self.state.market_buy_confirm = None;
11006            return;
11007        }
11008        self.state.market_buy_confirm = None;
11009        self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
11010    }
11011
11012    pub fn market_ui_back(&mut self) {
11013        self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
11014            MarketUiMode::Browse => MarketUiMode::Browse,
11015            MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
11016            MarketUiMode::ListPick { .. } => {
11017                if self.state.market_list_source_options().len() <= 1 {
11018                    MarketUiMode::Browse
11019                } else {
11020                    MarketUiMode::ListSource { index: 0 }
11021                }
11022            }
11023            MarketUiMode::ListAmount {
11024                source, pick_index, ..
11025            } => MarketUiMode::ListPick {
11026                source,
11027                index: pick_index,
11028            },
11029            MarketUiMode::ListPricingMode {
11030                source,
11031                item_instance_id,
11032                template_id,
11033                label,
11034                max_qty,
11035                quantity,
11036                pick_index,
11037                ..
11038            } => {
11039                let input = quantity.map(|q| q.to_string()).unwrap_or_default();
11040                MarketUiMode::ListAmount {
11041                    source,
11042                    pick_index,
11043                    item_instance_id,
11044                    template_id,
11045                    label,
11046                    max_qty,
11047                    input,
11048                }
11049            }
11050            MarketUiMode::ListPrice {
11051                source,
11052                pick_index,
11053                item_instance_id,
11054                template_id,
11055                label,
11056                max_qty,
11057                quantity,
11058                ..
11059            } => MarketUiMode::ListPricingMode {
11060                source,
11061                pick_index,
11062                item_instance_id,
11063                template_id,
11064                label,
11065                quantity,
11066                max_qty,
11067                index: 1,
11068            },
11069        };
11070    }
11071
11072    pub fn market_list_move(&mut self, delta: i32) {
11073        match &self.state.market_ui_mode {
11074            MarketUiMode::ListSource { index } => {
11075                let n = self.state.market_list_source_options().len();
11076                if n == 0 {
11077                    return;
11078                }
11079                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
11080                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
11081            }
11082            MarketUiMode::ListPricingMode { index, .. } => {
11083                let next = (*index as i32 + delta).rem_euclid(2) as usize;
11084                if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
11085                {
11086                    *index = next;
11087                }
11088            }
11089            MarketUiMode::ListPick { source, index } => {
11090                let opts = self.state.market_list_item_options(source);
11091                let n = opts.len();
11092                if n == 0 {
11093                    return;
11094                }
11095                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
11096                self.state.market_ui_mode = MarketUiMode::ListPick {
11097                    source: source.clone(),
11098                    index: next,
11099                };
11100            }
11101            _ => {}
11102        }
11103    }
11104
11105    pub fn market_list_amount_append_char(&mut self, c: char) {
11106        if !c.is_ascii_digit() {
11107            return;
11108        }
11109        match &mut self.state.market_ui_mode {
11110            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
11111                if input.len() < 12 {
11112                    input.push(c);
11113                }
11114            }
11115            _ => {}
11116        }
11117    }
11118
11119    pub fn market_list_amount_backspace(&mut self) {
11120        match &mut self.state.market_ui_mode {
11121            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
11122                input.pop();
11123            }
11124            _ => {}
11125        }
11126    }
11127
11128    pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
11129        match self.state.market_ui_mode.clone() {
11130            MarketUiMode::Browse => Ok(()),
11131            MarketUiMode::ListSource { index } => {
11132                let sources = self.state.market_list_source_options();
11133                let Some((source, _)) = sources.get(index).cloned() else {
11134                    return Ok(());
11135                };
11136                let opts = self.state.market_list_item_options(&source);
11137                if opts.is_empty() {
11138                    self.state.push_log("Nothing to list from that source.");
11139                    return Ok(());
11140                }
11141                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11142                Ok(())
11143            }
11144            MarketUiMode::ListPick { source, index } => {
11145                let opts = self.state.market_list_item_options(&source);
11146                let Some(opt) = opts.get(index) else {
11147                    self.state.push_log("Nothing to list.");
11148                    self.state.market_ui_mode = MarketUiMode::Browse;
11149                    return Ok(());
11150                };
11151                self.state.market_ui_mode = MarketUiMode::ListAmount {
11152                    source,
11153                    pick_index: index,
11154                    item_instance_id: opt.item_instance_id,
11155                    template_id: opt.template_id.clone(),
11156                    label: opt.label.clone(),
11157                    max_qty: opt.quantity.max(1),
11158                    input: String::new(),
11159                };
11160                Ok(())
11161            }
11162            MarketUiMode::ListAmount {
11163                source,
11164                pick_index,
11165                item_instance_id,
11166                template_id,
11167                label,
11168                max_qty,
11169                input,
11170                ..
11171            } => {
11172                let Some(qty_opt) = parse_storage_quantity(&input) else {
11173                    self.state.push_log("Enter a quantity (blank = all).");
11174                    return Ok(());
11175                };
11176                if let Some(q) = qty_opt {
11177                    if q > max_qty {
11178                        self.state.push_log(format!("Only {max_qty} available."));
11179                        return Ok(());
11180                    }
11181                }
11182                self.state.market_ui_mode = MarketUiMode::ListPricingMode {
11183                    source,
11184                    pick_index,
11185                    item_instance_id,
11186                    template_id,
11187                    label,
11188                    quantity: qty_opt,
11189                    max_qty,
11190                    index: 0,
11191                };
11192                Ok(())
11193            }
11194            MarketUiMode::ListPricingMode {
11195                source,
11196                pick_index,
11197                item_instance_id,
11198                template_id,
11199                label,
11200                quantity,
11201                max_qty,
11202                index,
11203            } => {
11204                if index == 0 {
11205                    if self
11206                        .state
11207                        .npc_market_dump_unit_estimate(&template_id)
11208                        .is_none()
11209                    {
11210                        self.state
11211                            .push_log("That item has no NPC value — use a fixed price instead.");
11212                        return Ok(());
11213                    }
11214                    return self
11215                        .submit_market_list_intent(
11216                            source,
11217                            item_instance_id,
11218                            quantity,
11219                            0,
11220                            true,
11221                            &label,
11222                        )
11223                        .await;
11224                }
11225                self.state.market_ui_mode = MarketUiMode::ListPrice {
11226                    source,
11227                    pick_index,
11228                    item_instance_id,
11229                    template_id,
11230                    label,
11231                    quantity,
11232                    max_qty,
11233                    input: String::new(),
11234                };
11235                Ok(())
11236            }
11237            MarketUiMode::ListPrice {
11238                source,
11239                item_instance_id,
11240                label,
11241                quantity,
11242                input,
11243                ..
11244            } => {
11245                let price = input.trim().parse::<u64>().unwrap_or(0);
11246                if price == 0 {
11247                    self.state
11248                        .push_log("Enter a unit price of at least 1 copper.");
11249                    return Ok(());
11250                }
11251                self.submit_market_list_intent(
11252                    source,
11253                    item_instance_id,
11254                    quantity,
11255                    price,
11256                    false,
11257                    &label,
11258                )
11259                .await
11260            }
11261        }
11262    }
11263
11264    async fn submit_market_list_intent(
11265        &mut self,
11266        source: MarketListSourceKind,
11267        item_instance_id: uuid::Uuid,
11268        quantity: Option<u32>,
11269        unit_price_copper: u64,
11270        npc_price: bool,
11271        label: &str,
11272    ) -> anyhow::Result<()> {
11273        let Some(panel) = self.state.market_panel.clone() else {
11274            self.state.market_ui_mode = MarketUiMode::Browse;
11275            return Ok(());
11276        };
11277        let goods = match source {
11278            MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
11279            MarketListSourceKind::TownStorage { building_id } => {
11280                flatland_protocol::GoodsLocation::TownStorage { building_id }
11281            }
11282        };
11283        self.seq += 1;
11284        self.session
11285            .submit_intent(Intent::MarketList {
11286                entity_id: self.state.entity_id,
11287                npc_id: panel.npc_id,
11288                source: goods,
11289                item_instance_id,
11290                quantity,
11291                unit_price_copper,
11292                npc_price,
11293                seq: self.seq,
11294            })
11295            .await?;
11296        self.state.intents_sent += 1;
11297        if npc_price {
11298            self.state
11299                .push_log(format!("Listing {label} at NPC price…"));
11300        } else {
11301            self.state
11302                .push_log(format!("Listing {label} @ {unit_price_copper} cp…"));
11303        }
11304        self.state.market_ui_mode = MarketUiMode::Browse;
11305        Ok(())
11306    }
11307
11308    /// Close shop and return to the Talk/Trade verb menu when inside an NPC session.
11309    pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
11310        let return_to_verbs = self.state.npc_verb_target.is_some();
11311        self.close_shop_menu().await?;
11312        if return_to_verbs {
11313            self.state.show_npc_verb_menu = true;
11314            self.state.npc_verb_notice = None;
11315        }
11316        Ok(())
11317    }
11318
11319    pub fn shop_tab_toggle(&mut self) {
11320        self.state.shop_tab = match self.state.shop_tab {
11321            ShopTab::Buy => ShopTab::Sell,
11322            ShopTab::Sell => ShopTab::Buy,
11323        };
11324        self.state.shop_menu_index = 0;
11325        if self.state.shop_tab == ShopTab::Sell {
11326            self.state.shop_quantity_set_max();
11327        }
11328        self.state.clamp_shop_selection();
11329    }
11330
11331    pub fn shop_menu_move(&mut self, delta: i32) {
11332        self.state.shop_menu_move(delta);
11333    }
11334
11335    pub fn shop_quantity_adjust(&mut self, delta: i32) {
11336        self.state.shop_quantity_adjust(delta);
11337    }
11338
11339    pub fn shop_quantity_set_max(&mut self) {
11340        self.state.shop_quantity_set_max();
11341    }
11342
11343    pub fn shop_quantity_set_min(&mut self) {
11344        self.state.shop_quantity_set_min();
11345    }
11346
11347    pub fn toggle_quest_menu(&mut self) {
11348        self.state.show_quest_menu = !self.state.show_quest_menu;
11349        if self.state.show_quest_menu {
11350            self.state.quest_menu_index = 0;
11351            self.state.quest_withdraw_confirm = false;
11352            self.state.show_workers_menu = false;
11353        }
11354    }
11355
11356    pub fn toggle_workers_menu(&mut self) {
11357        if self.state.show_workers_menu {
11358            self.close_workers_menu_ui();
11359        } else {
11360            self.state.show_workers_menu = true;
11361            // Keep prior selection/scroll focus; only clamp if the roster shrank.
11362            if self.state.workers_menu_index >= self.state.hired_workers.len() {
11363                self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11364            }
11365            self.state.show_quest_menu = false;
11366            self.close_worker_give_picker();
11367            self.close_worker_give_target_picker();
11368            self.close_worker_take_picker();
11369            self.close_worker_teach_picker();
11370            self.cancel_worker_rename();
11371        }
11372    }
11373
11374    /// Close workers UI layers without network (caller should release attend).
11375    pub fn close_workers_menu_ui(&mut self) {
11376        self.state.show_workers_menu = false;
11377        self.cancel_worker_dismissal();
11378        self.close_worker_give_picker();
11379        self.close_worker_give_target_picker();
11380        self.close_worker_take_picker();
11381        self.close_worker_teach_picker();
11382        self.cancel_worker_rename();
11383    }
11384
11385    /// Open the workers menu focused on `instance_id` and pause that worker's job.
11386    pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
11387        let Some(idx) = self
11388            .state
11389            .hired_workers
11390            .iter()
11391            .position(|w| w.instance_id == instance_id)
11392        else {
11393            anyhow::bail!("worker not found");
11394        };
11395        let label = self.state.hired_workers[idx].label.clone();
11396        self.state.show_workers_menu = true;
11397        self.state.workers_menu_index = idx;
11398        self.state.show_quest_menu = false;
11399        self.close_worker_give_picker();
11400        self.close_worker_give_target_picker();
11401        self.close_worker_take_picker();
11402        self.close_worker_teach_picker();
11403        self.cancel_worker_rename();
11404        self.set_worker_attending(instance_id, true).await?;
11405        self.state
11406            .push_log(format!("Managing {label} — job paused while menu is open"));
11407        Ok(())
11408    }
11409
11410    /// Close workers menu and release any attend pause.
11411    pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
11412        self.close_workers_menu_ui();
11413        self.release_worker_attend().await
11414    }
11415
11416    async fn set_worker_attending(
11417        &mut self,
11418        instance_id: &str,
11419        attending: bool,
11420    ) -> anyhow::Result<()> {
11421        if attending {
11422            if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11423                return Ok(());
11424            }
11425            // Switch target: release previous first.
11426            if let Some(prev) = self.state.attending_worker_instance_id.clone() {
11427                if prev != instance_id {
11428                    self.send_attend_hired_worker(&prev, false).await?;
11429                }
11430            }
11431            self.send_attend_hired_worker(instance_id, true).await?;
11432            self.state.attending_worker_instance_id = Some(instance_id.to_string());
11433        } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11434            self.send_attend_hired_worker(instance_id, false).await?;
11435            self.state.attending_worker_instance_id = None;
11436        }
11437        Ok(())
11438    }
11439
11440    pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
11441        let Some(id) = self.state.attending_worker_instance_id.take() else {
11442            return Ok(());
11443        };
11444        self.send_attend_hired_worker(&id, false).await
11445    }
11446
11447    async fn send_attend_hired_worker(
11448        &mut self,
11449        worker_instance_id: &str,
11450        attending: bool,
11451    ) -> anyhow::Result<()> {
11452        self.seq += 1;
11453        self.session
11454            .submit_intent(Intent::AttendHiredWorker {
11455                entity_id: self.state.entity_id,
11456                worker_instance_id: worker_instance_id.to_string(),
11457                attending,
11458                seq: self.seq,
11459            })
11460            .await?;
11461        self.state.intents_sent += 1;
11462        Ok(())
11463    }
11464
11465    pub fn workers_menu_move(&mut self, delta: i32) {
11466        let n = self.state.hired_workers.len();
11467        if n == 0 {
11468            return;
11469        }
11470        let idx = self.state.workers_menu_index as i32;
11471        self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
11472    }
11473
11474    pub fn toggle_workers_menu_compact(&mut self) {
11475        self.state.workers_menu_compact = !self.state.workers_menu_compact;
11476        let mut cfg = crate::client_config::ClientConfig::load();
11477        let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
11478    }
11479
11480    pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
11481        let Some(worker) = self
11482            .state
11483            .hired_workers
11484            .get(self.state.workers_menu_index)
11485            .cloned()
11486        else {
11487            anyhow::bail!("no worker selected");
11488        };
11489        self.dismiss_worker_by_id(&worker.instance_id, &worker.label)
11490            .await
11491    }
11492
11493    /// Open a confirmation dialog before permanently dismissing the selected worker.
11494    pub fn request_worker_dismissal(&mut self) -> anyhow::Result<()> {
11495        let Some(worker) = self
11496            .state
11497            .hired_workers
11498            .get(self.state.workers_menu_index)
11499            .cloned()
11500        else {
11501            anyhow::bail!("no worker selected");
11502        };
11503        self.state.worker_dismiss_confirmation = Some(WorkerDismissConfirmation {
11504            worker_instance_id: worker.instance_id,
11505            worker_label: worker.label,
11506        });
11507        Ok(())
11508    }
11509
11510    pub fn cancel_worker_dismissal(&mut self) {
11511        self.state.worker_dismiss_confirmation = None;
11512    }
11513
11514    pub async fn confirm_worker_dismissal(&mut self) -> anyhow::Result<()> {
11515        let Some(confirm) = self.state.worker_dismiss_confirmation.clone() else {
11516            return Ok(());
11517        };
11518        self.dismiss_worker_by_id(&confirm.worker_instance_id, &confirm.worker_label)
11519            .await?;
11520        self.cancel_worker_dismissal();
11521        Ok(())
11522    }
11523
11524    async fn dismiss_worker_by_id(
11525        &mut self,
11526        worker_instance_id: &str,
11527        worker_label: &str,
11528    ) -> anyhow::Result<()> {
11529        self.seq += 1;
11530        self.session
11531            .submit_intent(Intent::DismissWorker {
11532                entity_id: self.state.entity_id,
11533                worker_instance_id: worker_instance_id.to_string(),
11534                seq: self.seq,
11535            })
11536            .await?;
11537        self.state.intents_sent += 1;
11538        self.state
11539            .hired_workers
11540            .retain(|w| w.instance_id != worker_instance_id);
11541        if self.state.workers_menu_index >= self.state.hired_workers.len() {
11542            self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11543        }
11544        self.state.push_log(format!("Dismissed {worker_label}"));
11545        Ok(())
11546    }
11547
11548    pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
11549        let Some(worker) = self
11550            .state
11551            .hired_workers
11552            .get(self.state.workers_menu_index)
11553            .cloned()
11554        else {
11555            anyhow::bail!("no worker selected");
11556        };
11557        let mode = match worker.mode {
11558            flatland_protocol::WorkerModeView::Companion => "defender",
11559            flatland_protocol::WorkerModeView::Defender => "job_loop",
11560            flatland_protocol::WorkerModeView::JobLoop => "idle",
11561            flatland_protocol::WorkerModeView::Idle => "companion",
11562        };
11563        self.seq += 1;
11564        self.session
11565            .submit_intent(Intent::SetWorkerMode {
11566                entity_id: self.state.entity_id,
11567                worker_instance_id: worker.instance_id,
11568                mode: mode.into(),
11569                seq: self.seq,
11570            })
11571            .await?;
11572        self.state.intents_sent += 1;
11573        Ok(())
11574    }
11575
11576    pub async fn workers_deliver_selected_to_storage(&mut self) -> anyhow::Result<()> {
11577        let Some(worker) = self
11578            .state
11579            .hired_workers
11580            .get(self.state.workers_menu_index)
11581            .cloned()
11582        else {
11583            anyhow::bail!("no worker selected");
11584        };
11585        if !matches!(worker.mode, flatland_protocol::WorkerModeView::Companion) {
11586            anyhow::bail!("switch the worker to companion mode first");
11587        }
11588        if worker.step_label.starts_with("delivering to ")
11589            || worker.step_label == "returning to you"
11590        {
11591            anyhow::bail!("worker is already delivering to storage");
11592        }
11593        self.seq += 1;
11594        self.session
11595            .submit_intent(Intent::DeliverWorkerToNearestStorage {
11596                entity_id: self.state.entity_id,
11597                worker_instance_id: worker.instance_id.clone(),
11598                seq: self.seq,
11599            })
11600            .await?;
11601        self.state.intents_sent += 1;
11602        self.state.push_log(format!(
11603            "{} is delivering carried items to storage",
11604            worker.label
11605        ));
11606        Ok(())
11607    }
11608
11609    pub async fn workers_cancel_delivery_selected(&mut self) -> anyhow::Result<()> {
11610        let Some(worker) = self
11611            .state
11612            .hired_workers
11613            .get(self.state.workers_menu_index)
11614            .cloned()
11615        else {
11616            anyhow::bail!("no worker selected");
11617        };
11618        if !(worker.step_label.starts_with("delivering to ")
11619            || worker.step_label == "returning to you")
11620        {
11621            anyhow::bail!("worker has no active delivery");
11622        }
11623        self.seq += 1;
11624        self.session
11625            .submit_intent(Intent::CancelWorkerDelivery {
11626                entity_id: self.state.entity_id,
11627                worker_instance_id: worker.instance_id,
11628                seq: self.seq,
11629            })
11630            .await?;
11631        self.state.intents_sent += 1;
11632        self.state
11633            .push_log(format!("Canceled delivery for {}", worker.label));
11634        Ok(())
11635    }
11636
11637    pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
11638        if self.state.hired_workers.is_empty() {
11639            return self.hire_worker_laborer().await;
11640        }
11641        self.workers_toggle_mode_selected().await
11642    }
11643
11644    /// Open a nearby-worker picker for the selected inventory stack (inventory `g`).
11645    /// Always shows a chooser so you can pick Bruce vs Cookie when several are close.
11646    pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11647        let row = self
11648            .state
11649            .inventory_selected_row()
11650            .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
11651            .clone();
11652        if row.from != flatland_protocol::InventoryLocation::Root {
11653            anyhow::bail!("select a carried item to give");
11654        }
11655        let Some(instance_id) = row.stack.item_instance_id else {
11656            anyhow::bail!("that stack can't be given");
11657        };
11658        let options = self.nearby_worker_give_targets();
11659        if options.is_empty() {
11660            anyhow::bail!(
11661                "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
11662            );
11663        }
11664        let item_label = row
11665            .stack
11666            .display_name
11667            .as_deref()
11668            .unwrap_or(&row.stack.template_id)
11669            .to_string();
11670        self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
11671            item_instance_id: instance_id,
11672            item_label,
11673            quantity: None,
11674            options,
11675        });
11676        self.state.worker_give_target_picker_index = 0;
11677        self.state.show_worker_give_target_picker = true;
11678        // Let the target picker own keys (inventory would otherwise swallow ↑↓/Enter).
11679        self.state.show_inventory_menu = false;
11680        Ok(())
11681    }
11682
11683    /// Hired workers within give/take range, nearest first.
11684    pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
11685        let (px, py, _) = self.state.player_position_with_z();
11686        let mut options: Vec<WorkerGiveTargetOption> = self
11687            .state
11688            .hired_workers
11689            .iter()
11690            .filter_map(|w| {
11691                let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
11692                if dist > WORKER_GIVE_RANGE_M {
11693                    return None;
11694                }
11695                Some(WorkerGiveTargetOption {
11696                    instance_id: w.instance_id.clone(),
11697                    label: w.label.clone(),
11698                    distance_m: dist,
11699                })
11700            })
11701            .collect();
11702        options.sort_by(|a, b| {
11703            a.distance_m
11704                .partial_cmp(&b.distance_m)
11705                .unwrap_or(std::cmp::Ordering::Equal)
11706        });
11707        options
11708    }
11709
11710    pub fn close_worker_give_target_picker(&mut self) {
11711        self.state.show_worker_give_target_picker = false;
11712        self.state.worker_give_target_picker = None;
11713        self.state.worker_give_target_picker_index = 0;
11714    }
11715
11716    pub fn worker_give_target_picker_move(&mut self, delta: i32) {
11717        let Some(picker) = &self.state.worker_give_target_picker else {
11718            return;
11719        };
11720        let n = picker.options.len();
11721        if n == 0 {
11722            return;
11723        }
11724        let idx = self.state.worker_give_target_picker_index as i32;
11725        self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11726    }
11727
11728    pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11729        let Some(picker) = self.state.worker_give_target_picker.clone() else {
11730            anyhow::bail!("give target picker not open");
11731        };
11732        let Some(opt) = picker
11733            .options
11734            .get(self.state.worker_give_target_picker_index)
11735            .cloned()
11736        else {
11737            anyhow::bail!("no worker selected");
11738        };
11739        let Some(worker) = self
11740            .state
11741            .hired_workers
11742            .iter()
11743            .find(|w| w.instance_id == opt.instance_id)
11744            .cloned()
11745        else {
11746            self.close_worker_give_target_picker();
11747            anyhow::bail!("worker no longer hired");
11748        };
11749        self.give_item_to_worker(
11750            &worker.instance_id,
11751            &worker.label,
11752            worker.x,
11753            worker.y,
11754            picker.item_instance_id,
11755            &picker.item_label,
11756            picker.quantity,
11757        )
11758        .await?;
11759        self.close_worker_give_target_picker();
11760        Ok(())
11761    }
11762
11763    /// Give the selected inventory stack to a hired worker (opens nearby-worker picker).
11764    pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
11765        self.open_worker_give_target_picker()
11766    }
11767
11768    /// Open the workers-menu give picker for the selected worker (must be in range).
11769    pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
11770        let Some(worker) = self
11771            .state
11772            .hired_workers
11773            .get(self.state.workers_menu_index)
11774            .cloned()
11775        else {
11776            anyhow::bail!("select a hired worker first");
11777        };
11778        let (px, py, _) = self.state.player_position_with_z();
11779        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11780        if dist > WORKER_GIVE_RANGE_M {
11781            anyhow::bail!(
11782                "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
11783                worker.label
11784            );
11785        }
11786        let options = self.state.giveable_inventory_options();
11787        if options.is_empty() {
11788            anyhow::bail!("nothing in inventory to give");
11789        }
11790        self.state.worker_give_picker = Some(WorkerGivePicker {
11791            worker_instance_id: worker.instance_id,
11792            worker_label: worker.label,
11793            options,
11794        });
11795        self.state.worker_give_picker_index = 0;
11796        self.state.show_worker_give_picker = true;
11797        Ok(())
11798    }
11799
11800    pub fn close_worker_give_picker(&mut self) {
11801        self.state.show_worker_give_picker = false;
11802        self.state.worker_give_picker = None;
11803        self.state.worker_give_picker_index = 0;
11804    }
11805
11806    pub fn worker_give_picker_move(&mut self, delta: i32) {
11807        let Some(picker) = &self.state.worker_give_picker else {
11808            return;
11809        };
11810        let n = picker.options.len();
11811        if n == 0 {
11812            return;
11813        }
11814        let idx = self.state.worker_give_picker_index as i32;
11815        self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11816    }
11817
11818    /// Confirm the selected row in the workers-menu give picker.
11819    pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
11820        let Some(picker) = self.state.worker_give_picker.clone() else {
11821            anyhow::bail!("give picker not open");
11822        };
11823        let Some(opt) = picker
11824            .options
11825            .get(self.state.worker_give_picker_index)
11826            .cloned()
11827        else {
11828            anyhow::bail!("no item selected");
11829        };
11830        let Some(worker) = self
11831            .state
11832            .hired_workers
11833            .iter()
11834            .find(|w| w.instance_id == picker.worker_instance_id)
11835            .cloned()
11836        else {
11837            self.close_worker_give_picker();
11838            anyhow::bail!("worker no longer hired");
11839        };
11840        self.give_item_to_worker(
11841            &worker.instance_id,
11842            &worker.label,
11843            worker.x,
11844            worker.y,
11845            opt.item_instance_id,
11846            &opt.label,
11847            None,
11848        )
11849        .await?;
11850        // Refresh options (stack may be gone / reduced) or close when empty.
11851        let options = self.state.giveable_inventory_options();
11852        if options.is_empty() {
11853            self.close_worker_give_picker();
11854        } else {
11855            self.state.worker_give_picker = Some(WorkerGivePicker {
11856                worker_instance_id: picker.worker_instance_id,
11857                worker_label: picker.worker_label,
11858                options,
11859            });
11860            if self.state.worker_give_picker_index
11861                >= self
11862                    .state
11863                    .worker_give_picker
11864                    .as_ref()
11865                    .map(|p| p.options.len())
11866                    .unwrap_or(0)
11867            {
11868                self.state.worker_give_picker_index = self
11869                    .state
11870                    .worker_give_picker
11871                    .as_ref()
11872                    .map(|p| p.options.len().saturating_sub(1))
11873                    .unwrap_or(0);
11874            }
11875        }
11876        Ok(())
11877    }
11878
11879    /// Open the workers-menu teach picker for the selected worker (must be in range).
11880    pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
11881        let Some(worker) = self
11882            .state
11883            .hired_workers
11884            .get(self.state.workers_menu_index)
11885            .cloned()
11886        else {
11887            anyhow::bail!("select a hired worker first");
11888        };
11889        let (px, py, _) = self.state.player_position_with_z();
11890        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11891        if dist > WORKER_GIVE_RANGE_M {
11892            anyhow::bail!(
11893                "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
11894                worker.label
11895            );
11896        }
11897        let options = self.state.teachable_blueprint_options(&worker);
11898        if options.is_empty() {
11899            anyhow::bail!("no recipes you know that {} still needs", worker.label);
11900        }
11901        self.state.worker_teach_picker = Some(WorkerTeachPicker {
11902            worker_instance_id: worker.instance_id,
11903            worker_label: worker.label,
11904            worker_level: worker.level,
11905            options,
11906        });
11907        self.state.worker_teach_picker_index = 0;
11908        self.state.show_worker_teach_picker = true;
11909        Ok(())
11910    }
11911
11912    pub fn close_worker_teach_picker(&mut self) {
11913        self.state.show_worker_teach_picker = false;
11914        self.state.worker_teach_picker = None;
11915        self.state.worker_teach_picker_index = 0;
11916    }
11917
11918    pub fn worker_teach_picker_move(&mut self, delta: i32) {
11919        let Some(picker) = &self.state.worker_teach_picker else {
11920            return;
11921        };
11922        let n = picker.options.len();
11923        if n == 0 {
11924            return;
11925        }
11926        let idx = self.state.worker_teach_picker_index as i32;
11927        self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11928    }
11929
11930    pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
11931        let Some(picker) = self.state.worker_teach_picker.clone() else {
11932            anyhow::bail!("teach picker not open");
11933        };
11934        let Some(opt) = picker
11935            .options
11936            .get(self.state.worker_teach_picker_index)
11937            .cloned()
11938        else {
11939            anyhow::bail!("nothing selected");
11940        };
11941        if !opt.level_ok {
11942            anyhow::bail!(
11943                "{} needs level {} (is level {})",
11944                picker.worker_label,
11945                opt.min_level,
11946                opt.worker_level
11947            );
11948        }
11949        if !opt.can_afford {
11950            anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
11951        }
11952        let Some(worker) = self
11953            .state
11954            .hired_workers
11955            .iter()
11956            .find(|w| w.instance_id == picker.worker_instance_id)
11957            .cloned()
11958        else {
11959            anyhow::bail!("worker gone");
11960        };
11961        let (px, py, _) = self.state.player_position_with_z();
11962        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11963        if dist > WORKER_GIVE_RANGE_M {
11964            anyhow::bail!("worker {} too far — stand next to them", worker.label);
11965        }
11966        self.seq += 1;
11967        self.session
11968            .submit_intent(Intent::TeachWorkerBlueprint {
11969                entity_id: self.state.entity_id,
11970                worker_instance_id: picker.worker_instance_id.clone(),
11971                blueprint_id: opt.blueprint_id.clone(),
11972                seq: self.seq,
11973            })
11974            .await?;
11975        self.state.intents_sent += 1;
11976        self.state.push_log(format!(
11977            "Teaching {} to {} ({} cp)",
11978            opt.label, picker.worker_label, opt.cost_copper
11979        ));
11980        self.close_worker_teach_picker();
11981        Ok(())
11982    }
11983
11984    async fn give_item_to_worker(
11985        &mut self,
11986        worker_instance_id: &str,
11987        worker_label: &str,
11988        worker_x: f32,
11989        worker_y: f32,
11990        item_instance_id: uuid::Uuid,
11991        item_label: &str,
11992        quantity: Option<u32>,
11993    ) -> anyhow::Result<()> {
11994        let (px, py, _) = self.state.player_position_with_z();
11995        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
11996        if dist > WORKER_GIVE_RANGE_M {
11997            anyhow::bail!("worker {worker_label} too far — stand next to them");
11998        }
11999        self.seq += 1;
12000        self.session
12001            .submit_intent(Intent::GiveWorkerItem {
12002                entity_id: self.state.entity_id,
12003                worker_instance_id: worker_instance_id.to_string(),
12004                item_instance_id,
12005                quantity,
12006                seq: self.seq,
12007            })
12008            .await?;
12009        self.state.intents_sent += 1;
12010        self.state
12011            .remove_carried_instance(item_instance_id, quantity);
12012        self.state
12013            .push_log(format!("Gave {item_label} to {worker_label}"));
12014        Ok(())
12015    }
12016
12017    /// Equip one item from the player's inventory on the selected worker.
12018    /// The server validates the requested hand/body slot and replaces the
12019    /// previous item into the worker's pack.
12020    pub async fn equip_item_on_worker(
12021        &mut self,
12022        worker_instance_id: &str,
12023        item_instance_id: uuid::Uuid,
12024        slot: &str,
12025    ) -> anyhow::Result<()> {
12026        let Some(worker) = self
12027            .state
12028            .hired_workers
12029            .iter()
12030            .find(|worker| worker.instance_id == worker_instance_id)
12031            .cloned()
12032        else {
12033            anyhow::bail!("worker not found");
12034        };
12035        let (px, py, _) = self.state.player_position_with_z();
12036        if (worker.x - px).hypot(worker.y - py) > WORKER_GIVE_RANGE_M {
12037            anyhow::bail!("worker {} too far — stand next to them", worker.label);
12038        }
12039        self.seq += 1;
12040        self.session
12041            .submit_intent(Intent::EquipWorkerItem {
12042                entity_id: self.state.entity_id,
12043                worker_instance_id: worker.instance_id.clone(),
12044                item_instance_id,
12045                slot: slot.to_string(),
12046                seq: self.seq,
12047            })
12048            .await?;
12049        self.state.intents_sent += 1;
12050        self.state
12051            .push_log(format!("Equipped {slot} on {}", worker.label));
12052        Ok(())
12053    }
12054
12055    /// Open the workers-menu take picker for the selected worker (must be in range).
12056    pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
12057        let Some(worker) = self
12058            .state
12059            .hired_workers
12060            .get(self.state.workers_menu_index)
12061            .cloned()
12062        else {
12063            anyhow::bail!("select a hired worker first");
12064        };
12065        let (px, py, _) = self.state.player_position_with_z();
12066        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
12067        if dist > WORKER_GIVE_RANGE_M {
12068            anyhow::bail!(
12069                "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
12070                worker.label
12071            );
12072        }
12073        let options = Self::worker_inventory_options(&worker);
12074        if options.is_empty() {
12075            anyhow::bail!("{} isn't carrying anything", worker.label);
12076        }
12077        let initial_qty = options
12078            .first()
12079            .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
12080            .unwrap_or(1);
12081        self.state.worker_take_picker = Some(WorkerTakePicker {
12082            worker_instance_id: worker.instance_id,
12083            worker_label: worker.label,
12084            options,
12085            quantity: initial_qty,
12086        });
12087        self.state.worker_take_picker_index = 0;
12088        self.state.show_worker_take_picker = true;
12089        Ok(())
12090    }
12091
12092    fn worker_inventory_options(
12093        worker: &flatland_protocol::HiredWorkerView,
12094    ) -> Vec<WorkerGiveOption> {
12095        worker
12096            .inventory
12097            .iter()
12098            .filter_map(|stack| {
12099                let item_instance_id = stack.item_instance_id?;
12100                let label = stack
12101                    .display_name
12102                    .clone()
12103                    .unwrap_or_else(|| stack.template_id.clone());
12104                let label = if stack.quantity > 1 {
12105                    format!("{label} ×{}", stack.quantity)
12106                } else {
12107                    label
12108                };
12109                Some(WorkerGiveOption {
12110                    item_instance_id,
12111                    label,
12112                    quantity: stack.quantity,
12113                    template_id: stack.template_id.clone(),
12114                })
12115            })
12116            .collect()
12117    }
12118
12119    pub fn close_worker_take_picker(&mut self) {
12120        self.state.show_worker_take_picker = false;
12121        self.state.worker_take_picker = None;
12122        self.state.worker_take_picker_index = 0;
12123    }
12124
12125    pub fn worker_take_picker_move(&mut self, delta: i32) {
12126        let Some(picker) = &self.state.worker_take_picker else {
12127            return;
12128        };
12129        let n = picker.options.len();
12130        if n == 0 {
12131            return;
12132        }
12133        let idx = self.state.worker_take_picker_index as i32;
12134        self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
12135        self.clamp_worker_take_quantity();
12136    }
12137
12138    pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
12139        let Some(picker) = &mut self.state.worker_take_picker else {
12140            return;
12141        };
12142        let max = picker
12143            .options
12144            .get(self.state.worker_take_picker_index)
12145            .map(|o| o.quantity.max(1))
12146            .unwrap_or(1);
12147        let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
12148        picker.quantity = next as u32;
12149    }
12150
12151    pub fn worker_take_picker_set_quantity_max(&mut self) {
12152        let Some(picker) = &mut self.state.worker_take_picker else {
12153            return;
12154        };
12155        let max = picker
12156            .options
12157            .get(self.state.worker_take_picker_index)
12158            .map(|o| o.quantity.max(1))
12159            .unwrap_or(1);
12160        picker.quantity = max;
12161    }
12162
12163    pub fn worker_take_picker_set_quantity_min(&mut self) {
12164        let Some(picker) = &mut self.state.worker_take_picker else {
12165            return;
12166        };
12167        picker.quantity = 1;
12168        self.clamp_worker_take_quantity();
12169    }
12170
12171    fn clamp_worker_take_quantity(&mut self) {
12172        let Some(picker) = &mut self.state.worker_take_picker else {
12173            return;
12174        };
12175        let max = picker
12176            .options
12177            .get(self.state.worker_take_picker_index)
12178            .map(|o| o.quantity.max(1))
12179            .unwrap_or(1);
12180        if picker.quantity == 0 || picker.quantity > max {
12181            picker.quantity = if max > 1 { 1 } else { max };
12182        }
12183    }
12184
12185    pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
12186        let Some(picker) = self.state.worker_take_picker.clone() else {
12187            anyhow::bail!("take picker not open");
12188        };
12189        let Some(opt) = picker
12190            .options
12191            .get(self.state.worker_take_picker_index)
12192            .cloned()
12193        else {
12194            anyhow::bail!("no item selected");
12195        };
12196        let Some(worker) = self
12197            .state
12198            .hired_workers
12199            .iter()
12200            .find(|w| w.instance_id == picker.worker_instance_id)
12201            .cloned()
12202        else {
12203            self.close_worker_take_picker();
12204            anyhow::bail!("worker no longer hired");
12205        };
12206        let qty = picker.quantity.clamp(1, opt.quantity.max(1));
12207        let intent_qty = if qty >= opt.quantity { None } else { Some(qty) };
12208        self.take_item_from_worker(
12209            &worker.instance_id,
12210            &worker.label,
12211            worker.x,
12212            worker.y,
12213            opt.item_instance_id,
12214            &opt.label,
12215            intent_qty,
12216        )
12217        .await?;
12218        // Leave the picker open — server Interaction / hired-workers sync refresh
12219        // options. Do not optimistic-strip stacks (rejects used to look like success).
12220        Ok(())
12221    }
12222
12223    async fn take_item_from_worker(
12224        &mut self,
12225        worker_instance_id: &str,
12226        worker_label: &str,
12227        worker_x: f32,
12228        worker_y: f32,
12229        item_instance_id: uuid::Uuid,
12230        item_label: &str,
12231        quantity: Option<u32>,
12232    ) -> anyhow::Result<()> {
12233        let (px, py, _) = self.state.player_position_with_z();
12234        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
12235        if dist > WORKER_GIVE_RANGE_M {
12236            anyhow::bail!("worker {worker_label} too far — stand next to them");
12237        }
12238        self.seq += 1;
12239        self.session
12240            .submit_intent(Intent::TakeWorkerItem {
12241                entity_id: self.state.entity_id,
12242                worker_instance_id: worker_instance_id.to_string(),
12243                item_instance_id,
12244                quantity,
12245                seq: self.seq,
12246            })
12247            .await?;
12248        self.state.intents_sent += 1;
12249        let qty_note = quantity.map(|q| format!(" ×{q}")).unwrap_or_default();
12250        self.state.push_log(format!(
12251            "Taking {item_label}{qty_note} from {worker_label}…"
12252        ));
12253        Ok(())
12254    }
12255
12256    pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
12257        if let Some(since) = self.state.pending_worker_hire_since {
12258            if since.elapsed() < WORKER_HIRE_PENDING_TIMEOUT {
12259                anyhow::bail!("hire request still pending — wait for the worker roster update");
12260            }
12261            self.state.pending_worker_hire_since = None;
12262        }
12263        if !self.state.has_worker_lodging() {
12264            anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
12265        }
12266        self.seq += 1;
12267        self.session
12268            .submit_intent(Intent::HireWorker {
12269                entity_id: self.state.entity_id,
12270                def_id: "worker_laborer".into(),
12271                wage_copper_per_interval: 8,
12272                lodging_container_id: None,
12273                job_yaml: None,
12274                seq: self.seq,
12275            })
12276            .await?;
12277        self.state.intents_sent += 1;
12278        self.state.pending_worker_hire_since = Some(Instant::now());
12279        Ok(())
12280    }
12281
12282    pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
12283        let Some(worker) = self
12284            .state
12285            .hired_workers
12286            .get(self.state.workers_menu_index)
12287            .cloned()
12288        else {
12289            anyhow::bail!("select a hired worker first");
12290        };
12291        let lodging = worker.lodging_container_id.clone().or_else(|| {
12292            crate::worker_route_editor::owned_lodging_container_ids(
12293                &self.state.placed_containers,
12294                self.state.character_id,
12295            )
12296            .into_iter()
12297            .next()
12298            .map(|(id, _)| id)
12299        });
12300        let label = worker.label.clone();
12301        let editor = if let Some(route) = &worker.route {
12302            crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
12303                worker.instance_id,
12304                worker.label,
12305                route,
12306                lodging,
12307            )
12308        } else {
12309            crate::worker_route_editor::WorkerRouteEditorState::new(
12310                worker.instance_id,
12311                worker.label,
12312                lodging,
12313            )
12314        };
12315        self.state.worker_route_editor = Some(editor);
12316        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12317            if let Some(collapsed) =
12318                crate::client_config::ClientConfig::load().worker_route_panel_collapsed
12319            {
12320                ed.panel_collapsed = collapsed;
12321            }
12322        }
12323        self.state.show_workers_menu = false;
12324        self.state.push_log(format!(
12325            "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
12326        ));
12327        Ok(())
12328    }
12329
12330    pub fn close_worker_route_editor(&mut self) {
12331        self.state.worker_route_editor = None;
12332    }
12333
12334    pub fn worker_route_editor_toggle_panel(&mut self) {
12335        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12336            ed.toggle_panel_collapsed();
12337            let collapsed = ed.panel_collapsed;
12338            let mut cfg = crate::client_config::ClientConfig::load();
12339            let _ = cfg.save_worker_route_panel_collapsed(collapsed);
12340        }
12341    }
12342
12343    pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
12344        let n = {
12345            let Some(ed) = self.state.worker_route_editor.as_mut() else {
12346                return;
12347            };
12348            ed.append_waypoint(x, y, z);
12349            ed.stop_count()
12350        };
12351        self.state
12352            .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
12353    }
12354
12355    // ---- picker candidate snapshots ----------------------------------
12356
12357    fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
12358        let (px, py, _) = self.state.player_position_with_z();
12359        let inside = self.state.effective_inside_building();
12360        crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
12361            &self.state.placed_containers,
12362            &self.state.buildings,
12363            self.state.character_id,
12364            px,
12365            py,
12366            &self.state.hired_workers,
12367            inside.as_deref(),
12368        )
12369    }
12370
12371    fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
12372        self.state.route_editor_node_candidates()
12373    }
12374
12375    fn re_open_harvest_picker(&mut self, index: usize, picked: std::collections::BTreeSet<String>) {
12376        use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
12377        let nodes = self.state.route_editor_node_candidates();
12378        let index = if nodes.is_empty() {
12379            ROUTE_PICKER_DONE_ROW
12380        } else {
12381            index.max(1).min(nodes.len())
12382        };
12383        self.re_open_sheet(S::HarvestPicker {
12384            index,
12385            picked,
12386            nodes,
12387        });
12388    }
12389
12390    fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
12391        let (px, py, _) = self.state.player_position_with_z();
12392        let templates = self.re_template_candidates();
12393        crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py, &templates)
12394    }
12395
12396    fn re_template_candidates(&self) -> Vec<String> {
12397        let mut extra = Vec::new();
12398        if let Some(ed) = self.state.worker_route_editor.as_ref() {
12399            for stop in &ed.stops {
12400                match stop {
12401                    crate::worker_route_editor::WorkerRouteStop::DepositAt {
12402                        filter: Some(filter),
12403                        ..
12404                    } => extra.extend(filter.iter().cloned()),
12405                    crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. }
12406                    | crate::worker_route_editor::WorkerRouteStop::ListOnMarket {
12407                        template, ..
12408                    } => {
12409                        extra.push(template.clone());
12410                    }
12411                    crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
12412                        if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint)
12413                        {
12414                            extra.push(bp.output.clone());
12415                            for input in &bp.inputs {
12416                                extra.push(input.template_id.clone());
12417                            }
12418                        }
12419                    }
12420                    crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
12421                        for it in items {
12422                            extra.push(it.template.clone());
12423                        }
12424                    }
12425                    _ => {}
12426                }
12427            }
12428            // Worker-known recipe outputs even if the player hasn't learned them yet.
12429            if let Some(worker) = self
12430                .state
12431                .hired_workers
12432                .iter()
12433                .find(|w| w.instance_id == ed.worker_instance_id)
12434            {
12435                for recipe in &worker.known_blueprint_ids {
12436                    if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
12437                        extra.push(bp.output.clone());
12438                    }
12439                }
12440                for stack in &worker.inventory {
12441                    if !stack.template_id.is_empty() && stack.quantity > 0 {
12442                        extra.push(stack.template_id.clone());
12443                    }
12444                }
12445            }
12446        }
12447        crate::worker_route_editor::route_item_template_candidates(
12448            &self.state.placed_containers,
12449            self.state.character_id,
12450            &self.state.inventory,
12451            &self.state.blueprints,
12452            if self.state.harvest_route_nodes.is_empty() {
12453                &self.state.resource_nodes
12454            } else {
12455                &self.state.harvest_route_nodes
12456            },
12457            &extra,
12458            Some(&self.state.item_catalog),
12459        )
12460    }
12461
12462    fn re_blueprint_ids(&self) -> Vec<String> {
12463        let worker_known: Option<&[String]> = self
12464            .state
12465            .worker_route_editor
12466            .as_ref()
12467            .and_then(|ed| {
12468                self.state
12469                    .hired_workers
12470                    .iter()
12471                    .find(|w| w.instance_id == ed.worker_instance_id)
12472            })
12473            .map(|w| w.known_blueprint_ids.as_slice());
12474        crate::worker_route_editor::worker_craft_blueprint_ids(&self.state.blueprints, worker_known)
12475    }
12476
12477    fn re_bed_candidates(&self) -> Vec<(String, String)> {
12478        crate::worker_route_editor::owned_lodging_container_ids(
12479            &self.state.placed_containers,
12480            self.state.character_id,
12481        )
12482    }
12483
12484    fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
12485        self.state
12486            .placed_containers
12487            .iter()
12488            .find(|c| c.id == container_id)
12489            .map(|c| c.contents.clone())
12490            .unwrap_or_default()
12491    }
12492
12493    // ---- sheet navigation (`plans/33`) --------------------------------
12494
12495    fn re_sheet_supports_filter(&self) -> bool {
12496        use crate::worker_route_editor::RouteEditorSheet as S;
12497        self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12498            matches!(
12499                ed.sheet,
12500                S::HarvestPicker { .. }
12501                    | S::SellItem { .. }
12502                    | S::MarketListItem { .. }
12503                    | S::DepositFilter { .. }
12504                    | S::WithdrawItems { .. }
12505                    | S::WithdrawContainers { .. }
12506                    | S::DepositContainers { .. }
12507                    | S::SellNpcs { .. }
12508                    | S::CraftBlueprint { .. }
12509                    | S::BedPicker { .. }
12510            )
12511        })
12512    }
12513
12514    /// Whether a sheet row is shown under the current `/` filter.
12515    pub fn re_sheet_row_visible(&self, row: usize) -> bool {
12516        use crate::worker_route_editor::{
12517            harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
12518            ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
12519        };
12520        let Some(ed) = self.state.worker_route_editor.as_ref() else {
12521            return false;
12522        };
12523        let filter = &ed.sheet_filter;
12524        match &ed.sheet {
12525            S::HarvestPicker { nodes, .. } => harvest_picker_row_matches(nodes, row, filter),
12526            S::SellItem { templates, .. } | S::MarketListItem { templates, .. } => {
12527                if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
12528                    return true;
12529                }
12530                let slot = row.saturating_sub(2);
12531                templates.get(slot).is_some_and(|t| {
12532                    let label = self.state.template_display_name(t);
12533                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12534                })
12535            }
12536            S::DepositFilter { rows, .. } => {
12537                if row >= rows.len() {
12538                    return true;
12539                }
12540                rows.get(row).is_some_and(|(t, _)| {
12541                    let label = self.state.template_display_name(t);
12542                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12543                })
12544            }
12545            S::WithdrawItems { lines, .. } => {
12546                if row >= lines.len() {
12547                    return true;
12548                }
12549                lines.get(row).is_some_and(|l| {
12550                    let label = self.state.template_display_name(&l.template);
12551                    list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
12552                })
12553            }
12554            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12555                self.re_container_candidates().get(row).is_some_and(|c| {
12556                    list_filter_row_matches(
12557                        filter,
12558                        Some(c.dist),
12559                        &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
12560                    )
12561                })
12562            }
12563            S::SellNpcs { .. } => {
12564                if row == 0 {
12565                    return true;
12566                }
12567                self.re_npc_candidates().get(row - 1).is_some_and(|n| {
12568                    list_filter_row_matches(
12569                        filter,
12570                        Some(n.dist),
12571                        &[n.label.as_str(), n.id.as_str()],
12572                    )
12573                })
12574            }
12575            S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
12576                let label = self
12577                    .state
12578                    .blueprints
12579                    .iter()
12580                    .find(|b| &b.id == id)
12581                    .map(|b| {
12582                        if b.label.is_empty() {
12583                            id.as_str()
12584                        } else {
12585                            b.label.as_str()
12586                        }
12587                    })
12588                    .unwrap_or(id.as_str());
12589                list_filter_row_matches(filter, None, &[id.as_str(), label])
12590            }),
12591            S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
12592                list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
12593            }),
12594            _ => true,
12595        }
12596    }
12597
12598    fn re_sheet_clamp_index(&mut self) {
12599        let count = self.re_sheet_row_count();
12600        if count == 0 {
12601            return;
12602        }
12603        let cur = self.re_sheet_index();
12604        if self.re_sheet_row_visible(cur) {
12605            return;
12606        }
12607        for offset in 1..count {
12608            if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
12609                self.re_sheet_set_index(cur + offset);
12610                return;
12611            }
12612            if cur >= offset && self.re_sheet_row_visible(cur - offset) {
12613                self.re_sheet_set_index(cur - offset);
12614                return;
12615            }
12616        }
12617    }
12618
12619    fn re_sheet_set_index(&mut self, index: usize) {
12620        use crate::worker_route_editor::RouteEditorSheet as S;
12621        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12622            return;
12623        };
12624        match &mut ed.sheet {
12625            S::AddMenu { index: slot }
12626            | S::WaypointMenu { index: slot }
12627            | S::HarvestPicker { index: slot, .. }
12628            | S::WithdrawContainers { index: slot }
12629            | S::DepositContainers { index: slot }
12630            | S::SellNpcs { index: slot }
12631            | S::CraftBlueprint { index: slot }
12632            | S::BedPicker { index: slot }
12633            | S::FarmPlotPicker { index: slot, .. }
12634            | S::FarmPlantSeed { index: slot, .. }
12635            | S::WithdrawItems { index: slot, .. }
12636            | S::DepositFilter { index: slot, .. }
12637            | S::SellItem { index: slot, .. }
12638            | S::MarketListItem { index: slot, .. } => *slot = index,
12639            _ => {}
12640        }
12641    }
12642
12643    pub fn re_focus_sheet_filter(&mut self) {
12644        if !self.re_sheet_supports_filter() {
12645            return;
12646        }
12647        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12648            ed.sheet_filter_focused = true;
12649        }
12650    }
12651
12652    pub fn re_blur_sheet_filter_keep_text(&mut self) {
12653        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12654            return;
12655        };
12656        if !ed.sheet_filter_focused {
12657            return;
12658        }
12659        ed.sheet_filter_focused = false;
12660        self.re_sheet_clamp_index();
12661    }
12662
12663    pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
12664        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12665            return false;
12666        };
12667        if ed.sheet_filter_focused {
12668            ed.sheet_filter_focused = false;
12669            self.re_sheet_clamp_index();
12670            return true;
12671        }
12672        if !ed.sheet_filter.is_empty() {
12673            ed.sheet_filter.clear();
12674            self.re_sheet_clamp_index();
12675            return true;
12676        }
12677        false
12678    }
12679
12680    pub fn re_append_sheet_filter_char(&mut self, ch: char) {
12681        if ch.is_control() {
12682            return;
12683        }
12684        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12685            return;
12686        };
12687        if !ed.sheet_filter_focused {
12688            return;
12689        }
12690        ed.sheet_filter.push(ch);
12691        self.re_sheet_set_index(0);
12692        self.re_sheet_clamp_index();
12693    }
12694
12695    pub fn re_sheet_filter_backspace(&mut self) {
12696        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12697            return;
12698        };
12699        if !ed.sheet_filter_focused {
12700            return;
12701        }
12702        ed.sheet_filter.pop();
12703        self.re_sheet_set_index(0);
12704        self.re_sheet_clamp_index();
12705    }
12706
12707    /// Row count of the current sheet (for cursor wrapping).
12708    pub fn re_sheet_row_count(&self) -> usize {
12709        use crate::worker_route_editor::{
12710            harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
12711        };
12712        let Some(ed) = self.state.worker_route_editor.as_ref() else {
12713            return 0;
12714        };
12715        match &ed.sheet {
12716            S::Stops => ed.stops.len(),
12717            S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
12718            S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
12719            S::WaypointMapPick => 0,
12720            S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
12721            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12722                self.re_container_candidates().len()
12723            }
12724            S::WithdrawItems { lines, .. } => lines.len() + 1, // + Done row
12725            S::DepositFilter { rows, .. } => rows.len() + 1,   // + Done row
12726            S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, // + auto row
12727            S::SellItem { templates, .. } | S::MarketListItem { templates, .. } => {
12728                sell_item_picker_row_count(templates.len())
12729            }
12730            S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
12731            S::WaitEntry { .. } => 1,
12732            S::BedPicker { .. } => self.re_bed_candidates().len(),
12733            S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
12734            S::FarmPlantSeed { seeds, .. } => seeds.len(),
12735        }
12736    }
12737
12738    /// Current sheet cursor index (0 for sheets without one).
12739    pub fn re_sheet_index(&self) -> usize {
12740        use crate::worker_route_editor::RouteEditorSheet as S;
12741        let Some(ed) = self.state.worker_route_editor.as_ref() else {
12742            return 0;
12743        };
12744        match &ed.sheet {
12745            S::AddMenu { index }
12746            | S::WaypointMenu { index }
12747            | S::HarvestPicker { index, .. }
12748            | S::WithdrawContainers { index }
12749            | S::DepositContainers { index }
12750            | S::SellNpcs { index }
12751            | S::CraftBlueprint { index }
12752            | S::BedPicker { index }
12753            | S::FarmPlotPicker { index, .. }
12754            | S::FarmPlantSeed { index, .. }
12755            | S::WithdrawItems { index, .. }
12756            | S::DepositFilter { index, .. }
12757            | S::SellItem { index, .. }
12758            | S::MarketListItem { index, .. } => *index,
12759            _ => 0,
12760        }
12761    }
12762
12763    /// Move the current sheet's cursor, wrapping within its rows.
12764    pub fn re_sheet_move(&mut self, delta: i32) {
12765        let count = self.re_sheet_row_count();
12766        if count == 0 {
12767            return;
12768        }
12769        let cur = self.re_sheet_index();
12770        let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
12771        self.re_sheet_set_index(next);
12772    }
12773
12774    pub fn re_sheet_page(&mut self, pages: i32) {
12775        let count = self.re_sheet_row_count();
12776        if count == 0 {
12777            return;
12778        }
12779        let cur = self.re_sheet_index();
12780        let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
12781        self.re_sheet_set_index(next);
12782    }
12783
12784    /// `[`/`]` on a sheet: adjust quantity (withdraw lines, wait ticks).
12785    pub fn re_sheet_adjust(&mut self, delta: i32) {
12786        use crate::worker_route_editor::RouteEditorSheet as S;
12787        let index = self.re_sheet_index();
12788        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12789            return;
12790        };
12791        match &mut ed.sheet {
12792            S::WithdrawItems { lines, .. } => {
12793                if let Some(line) = lines.get_mut(index) {
12794                    line.adjust_qty(delta);
12795                }
12796            }
12797            S::WaitEntry { ticks } => {
12798                *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
12799            }
12800            _ => {}
12801        }
12802    }
12803
12804    pub fn re_sheet_back(&mut self) {
12805        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12806            return;
12807        };
12808        use crate::worker_route_editor::RouteEditorSheet as S;
12809        let was_editing = ed.editing_index.is_some();
12810        let from_top_picker = matches!(
12811            ed.sheet,
12812            S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
12813        );
12814        ed.sheet_back();
12815        if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
12816            // Chest retarget commits on pick; Esc here just ends the edit sheet.
12817            self.state
12818                .push_log("Route: left edit sheet — press s to save current stops".to_string());
12819        }
12820    }
12821
12822    /// True when the editor is at its root sheet (Esc should close it).
12823    pub fn re_at_root_sheet(&self) -> bool {
12824        self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12825            matches!(
12826                ed.sheet,
12827                crate::worker_route_editor::RouteEditorSheet::Stops
12828            )
12829        })
12830    }
12831
12832    pub fn re_open_add_menu(&mut self) {
12833        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12834            ed.open_add_menu();
12835        }
12836    }
12837
12838    pub fn re_open_bed_picker(&mut self) {
12839        let beds = self.re_bed_candidates();
12840        if beds.is_empty() {
12841            self.state
12842                .push_log("Route: place a camp bed first".to_string());
12843            return;
12844        }
12845        let current = self
12846            .state
12847            .worker_route_editor
12848            .as_ref()
12849            .and_then(|ed| ed.lodging_container_id.clone());
12850        let index = current
12851            .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
12852            .unwrap_or(0);
12853        self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
12854    }
12855
12856    fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
12857        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12858            ed.open_sheet(sheet);
12859        }
12860    }
12861
12862    /// Confirm a sheet's stop and log the outcome.
12863    fn re_confirm_stop(&mut self, stop: crate::worker_route_editor::WorkerRouteStop, what: String) {
12864        let appended = self
12865            .state
12866            .worker_route_editor
12867            .as_mut()
12868            .is_some_and(|ed| ed.confirm_stop(stop));
12869        if appended {
12870            self.state.push_log(format!("Route: + {what}"));
12871        } else {
12872            self.state
12873                .push_log(format!("Route: {what} already in route — selected it"));
12874        }
12875    }
12876
12877    fn re_open_withdraw_items(&mut self, container_id: String) {
12878        use crate::worker_route_editor::{
12879            RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
12880        };
12881        let contents = self.re_container_contents(&container_id);
12882        // When editing, keep the stop's item picks even if the player retargets
12883        // to a different (possibly empty) chest — otherwise Done has nothing to
12884        // confirm and the edit appears to "revert".
12885        let existing = self
12886            .state
12887            .worker_route_editor
12888            .as_ref()
12889            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12890            .and_then(|stop| match stop {
12891                WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
12892                _ => None,
12893            })
12894            .unwrap_or_default();
12895        let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
12896        // Commit the new chest immediately while editing so Esc-before-Done still
12897        // keeps the retarget (items update only when Done is pressed).
12898        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12899            let _ = ed.retarget_withdraw_container(container_id.clone());
12900        }
12901        self.re_open_sheet(S::WithdrawItems {
12902            container_id,
12903            lines,
12904            index: 0,
12905        });
12906    }
12907
12908    fn re_withdraw_items_activate(&mut self, index: usize) {
12909        use crate::worker_route_editor::{
12910            RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
12911        };
12912        enum Outcome {
12913            Cycled,
12914            Confirmed(WorkerRouteStop),
12915            Empty,
12916        }
12917        let outcome = {
12918            let Some(ed) = self.state.worker_route_editor.as_mut() else {
12919                return;
12920            };
12921            let S::WithdrawItems {
12922                container_id,
12923                lines,
12924                index: sheet_index,
12925            } = &mut ed.sheet
12926            else {
12927                return;
12928            };
12929            *sheet_index = index;
12930            if index < lines.len() {
12931                lines[index].cycle();
12932                Outcome::Cycled
12933            } else {
12934                let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
12935                if items.is_empty() {
12936                    Outcome::Empty
12937                } else {
12938                    let stop = WorkerRouteStop::WithdrawFrom {
12939                        container_id: container_id.clone(),
12940                        items,
12941                    };
12942                    ed.confirm_stop(stop.clone());
12943                    Outcome::Confirmed(stop)
12944                }
12945            }
12946        };
12947        match outcome {
12948            Outcome::Cycled => {}
12949            Outcome::Confirmed(stop) => {
12950                let what = self.state.worker_route_stop_summary(&stop);
12951                self.state.push_log(format!("Route: + {what}"));
12952            }
12953            Outcome::Empty => self.state.push_log(
12954                "Route: pick at least one item (Space/Enter toggles All/qty)".to_string(),
12955            ),
12956        }
12957    }
12958
12959    fn re_open_deposit_filter(&mut self, container_id: String) {
12960        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12961        // Preserve filter when retargeting the deposit chest while editing.
12962        let existing_filter = self
12963            .state
12964            .worker_route_editor
12965            .as_ref()
12966            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12967            .and_then(|stop| match stop {
12968                WorkerRouteStop::DepositAt { filter, .. } => {
12969                    Some(filter.clone().unwrap_or_default())
12970                }
12971                _ => None,
12972            });
12973        let mut candidates = self.re_template_candidates();
12974        if let Some(ref chosen) = existing_filter {
12975            for t in chosen {
12976                if !candidates.iter().any(|c| c == t) {
12977                    candidates.push(t.clone());
12978                }
12979            }
12980            candidates.sort();
12981            candidates.dedup();
12982        }
12983        let rows: Vec<(String, bool)> = match existing_filter {
12984            Some(chosen) => candidates
12985                .iter()
12986                .map(|t| (t.clone(), chosen.contains(t)))
12987                .collect(),
12988            None => candidates.into_iter().map(|t| (t, false)).collect(),
12989        };
12990        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12991            let _ = ed.retarget_deposit_container(container_id.clone());
12992        }
12993        self.re_open_sheet(S::DepositFilter {
12994            container_id,
12995            rows,
12996            index: 0,
12997        });
12998    }
12999
13000    fn re_deposit_filter_activate(&mut self, index: usize) {
13001        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13002        let mut confirmed: Option<WorkerRouteStop> = None;
13003        {
13004            let Some(ed) = self.state.worker_route_editor.as_mut() else {
13005                return;
13006            };
13007            let S::DepositFilter {
13008                container_id,
13009                rows,
13010                index: sheet_index,
13011            } = &mut ed.sheet
13012            else {
13013                return;
13014            };
13015            *sheet_index = index;
13016            if index < rows.len() {
13017                rows[index].1 = !rows[index].1;
13018            } else {
13019                // "Done" row.
13020                let chosen: Vec<String> = rows
13021                    .iter()
13022                    .filter(|(_, on)| *on)
13023                    .map(|(t, _)| t.clone())
13024                    .collect();
13025                let filter = if chosen.is_empty() {
13026                    None
13027                } else {
13028                    Some(chosen)
13029                };
13030                let stop = WorkerRouteStop::DepositAt {
13031                    container_id: container_id.clone(),
13032                    filter,
13033                };
13034                confirmed = Some(stop.clone());
13035                ed.confirm_stop(stop);
13036            }
13037        }
13038        if let Some(stop) = confirmed {
13039            let what = self.state.worker_route_stop_summary(&stop);
13040            self.state.push_log(format!("Route: + {what}"));
13041        }
13042    }
13043
13044    fn re_open_sell_item(&mut self, npc_id: Option<String>) {
13045        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13046        // Prefill when editing an existing sell stop.
13047        let (pre_npc, pre_template, pre_all) = self
13048            .state
13049            .worker_route_editor
13050            .as_ref()
13051            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
13052            .and_then(|stop| match stop {
13053                WorkerRouteStop::TradeWith {
13054                    npc_id,
13055                    template,
13056                    sell_all,
13057                } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
13058                _ => None,
13059            })
13060            .unwrap_or((None, None, true));
13061        let npc_id = npc_id.or(pre_npc);
13062        let mut templates = crate::worker_route_editor::sellable_route_item_template_candidates(
13063            &self.re_template_candidates(),
13064            &self.state.npcs,
13065            npc_id.as_deref(),
13066        );
13067        // Keep an existing invalid selection visible long enough to remove it
13068        // when editing a route after NPC buy lists change.
13069        if let Some(template) = pre_template.as_ref() {
13070            if !templates.iter().any(|candidate| candidate == template) {
13071                templates.push(template.clone());
13072                templates.sort();
13073            }
13074        }
13075        if templates.is_empty() {
13076            let msg = crate::worker_route_editor::sell_merchant_empty_reason(
13077                npc_id.as_deref(),
13078                &self.state.npcs,
13079                &self.re_template_candidates(),
13080            );
13081            self.state.push_log(msg);
13082            return;
13083        }
13084        let mut picked = std::collections::BTreeSet::new();
13085        if let Some(t) = pre_template {
13086            picked.insert(t);
13087        }
13088        self.re_open_sheet(S::SellItem {
13089            npc_id,
13090            templates,
13091            index: if picked.is_empty() {
13092                crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
13093            } else {
13094                2
13095            },
13096            sell_all: pre_all,
13097            picked,
13098        });
13099    }
13100
13101    fn re_sell_item_activate(&mut self, index: usize) {
13102        use crate::worker_route_editor::{
13103            RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
13104        };
13105        let mut batch_log: Option<String> = None;
13106        {
13107            let Some(ed) = self.state.worker_route_editor.as_mut() else {
13108                return;
13109            };
13110            let S::SellItem {
13111                npc_id,
13112                templates,
13113                index: sheet_index,
13114                sell_all,
13115                picked,
13116            } = &mut ed.sheet
13117            else {
13118                return;
13119            };
13120            *sheet_index = index;
13121            if index == ROUTE_PICKER_DONE_ROW {
13122                if picked.is_empty() {
13123                    batch_log =
13124                        Some("Route: pick at least one item (Space toggles, Done confirms)".into());
13125                } else {
13126                    let picks: Vec<String> = picked.iter().cloned().collect();
13127                    let npc = npc_id.clone();
13128                    let all = *sell_all;
13129                    let added = ed.confirm_trade_picks(npc, &picks, all);
13130                    batch_log = Some(format!("Route: + {added} sell stop(s)"));
13131                }
13132            } else if index == SELL_ITEM_TOGGLE_ROW {
13133                *sell_all = !*sell_all;
13134            } else if let Some(template) = templates.get(index.saturating_sub(2)) {
13135                let sellable = crate::worker_route_editor::sellable_route_item_template_candidates(
13136                    std::slice::from_ref(template),
13137                    &self.state.npcs,
13138                    npc_id.as_deref(),
13139                )
13140                .iter()
13141                .any(|candidate| candidate == template);
13142                if !sellable && !picked.contains(template) {
13143                    return;
13144                }
13145                if picked.contains(template) {
13146                    picked.remove(template);
13147                } else {
13148                    picked.insert(template.clone());
13149                }
13150            }
13151        }
13152        if let Some(msg) = batch_log {
13153            self.state.push_log(msg);
13154        }
13155    }
13156
13157    fn re_open_market_list_item(&mut self) {
13158        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13159        let (pre_hall, pre_template, pre_all) = self
13160            .state
13161            .worker_route_editor
13162            .as_ref()
13163            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
13164            .and_then(|stop| match stop {
13165                WorkerRouteStop::ListOnMarket {
13166                    hall_id,
13167                    template,
13168                    list_all,
13169                } => Some((hall_id.clone(), Some(template.clone()), *list_all)),
13170                _ => None,
13171            })
13172            .unwrap_or((None, None, true));
13173        let mut templates = self.re_template_candidates();
13174        // Prefer items with a known NPC-value hint when available; keep the rest
13175        // so the stop can still be authored (sim rejects no-value / unlistable).
13176        templates.sort_by_key(|t| {
13177            std::cmp::Reverse(self.state.item_base_value_copper_hint(t).unwrap_or(0))
13178        });
13179        if let Some(template) = pre_template.as_ref() {
13180            if !templates.iter().any(|c| c == template) {
13181                templates.push(template.clone());
13182            }
13183        }
13184        if templates.is_empty() {
13185            self.state
13186                .push_log("Route: no item templates available for market list".to_string());
13187            return;
13188        }
13189        let mut picked = std::collections::BTreeSet::new();
13190        if let Some(t) = pre_template {
13191            picked.insert(t);
13192        }
13193        self.re_open_sheet(S::MarketListItem {
13194            hall_id: pre_hall,
13195            templates,
13196            index: if picked.is_empty() {
13197                crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
13198            } else {
13199                2
13200            },
13201            list_all: pre_all,
13202            picked,
13203        });
13204    }
13205
13206    fn re_market_list_item_activate(&mut self, index: usize) {
13207        use crate::worker_route_editor::{
13208            RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
13209        };
13210        let mut batch_log: Option<String> = None;
13211        {
13212            let Some(ed) = self.state.worker_route_editor.as_mut() else {
13213                return;
13214            };
13215            let S::MarketListItem {
13216                hall_id,
13217                templates,
13218                index: sheet_index,
13219                list_all,
13220                picked,
13221            } = &mut ed.sheet
13222            else {
13223                return;
13224            };
13225            *sheet_index = index;
13226            if index == ROUTE_PICKER_DONE_ROW {
13227                if picked.is_empty() {
13228                    batch_log =
13229                        Some("Route: pick at least one item (Space toggles, Done confirms)".into());
13230                } else {
13231                    let picks: Vec<String> = picked.iter().cloned().collect();
13232                    let hall = hall_id.clone();
13233                    let all = *list_all;
13234                    let added = ed.confirm_market_list_picks(hall, &picks, all);
13235                    batch_log = Some(format!("Route: + {added} market-list stop(s)"));
13236                }
13237            } else if index == SELL_ITEM_TOGGLE_ROW {
13238                *list_all = !*list_all;
13239            } else if let Some(template) = templates.get(index.saturating_sub(2)) {
13240                if picked.contains(template) {
13241                    picked.remove(template);
13242                } else {
13243                    picked.insert(template.clone());
13244                }
13245            }
13246        }
13247        if let Some(msg) = batch_log {
13248            self.state.push_log(msg);
13249        }
13250    }
13251
13252    /// Enter on the stop list: open the selected stop's sheet, prefilled.
13253    pub fn re_edit_selected_stop(&mut self) {
13254        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13255        let Some(stop) = self
13256            .state
13257            .worker_route_editor
13258            .as_ref()
13259            .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
13260        else {
13261            self.state
13262                .push_log("Route: no stop selected — press a to add one".to_string());
13263            return;
13264        };
13265        if let Some(ed) = self.state.worker_route_editor.as_mut() {
13266            ed.begin_edit_selected();
13267        }
13268        match stop {
13269            WorkerRouteStop::Waypoint { .. } => {
13270                self.re_open_sheet(S::WaypointMenu { index: 0 });
13271            }
13272            WorkerRouteStop::HarvestNode { node_id } => {
13273                let nodes = self.state.route_editor_node_candidates();
13274                if nodes.is_empty() {
13275                    self.re_cancel_edit();
13276                    self.state.push_log(
13277                        "Route: no harvestable nodes in this region to retarget".to_string(),
13278                    );
13279                } else {
13280                    let mut picked = std::collections::BTreeSet::new();
13281                    picked.insert(node_id.clone());
13282                    let index = nodes
13283                        .iter()
13284                        .position(|n| n.id == node_id)
13285                        .map(|i| i + 1)
13286                        .unwrap_or(1);
13287                    self.re_open_harvest_picker(index, picked);
13288                }
13289            }
13290            WorkerRouteStop::WithdrawFrom { container_id, .. } => {
13291                // Open the container picker so the player can retarget storage
13292                // (previously jumped straight to items on the same chest).
13293                let containers = self.re_container_candidates();
13294                if containers.is_empty() {
13295                    self.re_cancel_edit();
13296                    self.state
13297                        .push_log("Route: place a storage chest first".to_string());
13298                } else {
13299                    let index = containers
13300                        .iter()
13301                        .position(|c| c.id == container_id)
13302                        .unwrap_or(0);
13303                    self.re_open_sheet(S::WithdrawContainers { index });
13304                }
13305            }
13306            WorkerRouteStop::DepositAt { container_id, .. } => {
13307                let containers = self.re_container_candidates();
13308                if containers.is_empty() {
13309                    self.re_cancel_edit();
13310                    self.state
13311                        .push_log("Route: place a storage chest first".to_string());
13312                } else {
13313                    let index = containers
13314                        .iter()
13315                        .position(|c| c.id == container_id)
13316                        .unwrap_or(0);
13317                    self.re_open_sheet(S::DepositContainers { index });
13318                }
13319            }
13320            WorkerRouteStop::TradeWith { npc_id, .. } => {
13321                let npcs = self.re_npc_candidates();
13322                // Row 0 is "auto / nearest"; rows 1.. map to npcs.
13323                let index = npc_id
13324                    .as_ref()
13325                    .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
13326                    .unwrap_or(0);
13327                self.re_open_sheet(S::SellNpcs { index });
13328            }
13329            WorkerRouteStop::ListOnMarket { .. } => {
13330                self.re_open_market_list_item();
13331            }
13332            WorkerRouteStop::CraftAt { blueprint, .. } => {
13333                let bps = self.re_blueprint_ids();
13334                let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
13335                if bps.is_empty() {
13336                    self.re_cancel_edit();
13337                    self.state
13338                        .push_log("Route: no known blueprints to retarget".to_string());
13339                } else {
13340                    self.re_open_sheet(S::CraftBlueprint { index });
13341                }
13342            }
13343            WorkerRouteStop::CultivatePlot { .. } => {
13344                self.re_open_farm_plot_picker(
13345                    crate::worker_route_editor::FarmPlotAction::Cultivate,
13346                );
13347            }
13348            WorkerRouteStop::PlantPlot { .. } => {
13349                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
13350            }
13351            WorkerRouteStop::HarvestPlot { .. } => {
13352                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
13353            }
13354            WorkerRouteStop::RestIfNeeded => {
13355                self.re_cancel_edit();
13356                self.state
13357                    .push_log("Route: rest has no settings (change the bed with l)".to_string());
13358            }
13359            WorkerRouteStop::Wait { wait_ticks } => {
13360                self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
13361            }
13362        }
13363    }
13364
13365    fn re_cancel_edit(&mut self) {
13366        if let Some(ed) = self.state.worker_route_editor.as_mut() {
13367            ed.editing_index = None;
13368        }
13369    }
13370
13371    // ---- mouse clicks inside the overlay ------------------------------
13372
13373    pub fn worker_route_editor_ui_click(
13374        &mut self,
13375        click: crate::worker_route_editor::RouteEditorClick,
13376    ) {
13377        use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
13378        match click {
13379            RouteEditorClick::SelectStop(i) => {
13380                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13381                    ed.sheet = S::Stops;
13382                    ed.select_stop(i);
13383                }
13384            }
13385            RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
13386            RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
13387            RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
13388        }
13389    }
13390
13391    /// Activate (Enter / click) a row of the current sheet.
13392    pub fn re_sheet_row_activate(&mut self, row: usize) {
13393        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13394        let Some(sheet) = self
13395            .state
13396            .worker_route_editor
13397            .as_ref()
13398            .map(|ed| ed.sheet.clone())
13399        else {
13400            return;
13401        };
13402        match sheet {
13403            S::Stops => {
13404                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13405                    ed.select_stop(row);
13406                }
13407            }
13408            S::AddMenu { .. } => match row {
13409                0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
13410                1 => {
13411                    if self.re_node_candidates().is_empty() {
13412                        self.state
13413                            .push_log("Route: no harvestable nodes in this region".to_string());
13414                    } else {
13415                        self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
13416                    }
13417                }
13418                2 | 3 => {
13419                    if self.re_container_candidates().is_empty() {
13420                        self.state
13421                            .push_log("Route: place a storage chest first".to_string());
13422                    } else if row == 2 {
13423                        self.re_open_sheet(S::WithdrawContainers { index: 0 });
13424                    } else {
13425                        self.re_open_sheet(S::DepositContainers { index: 0 });
13426                    }
13427                }
13428                4 => {
13429                    if self.re_template_candidates().is_empty() {
13430                        self.state.push_log(
13431                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
13432                                .to_string(),
13433                        );
13434                    } else {
13435                        self.re_open_sheet(S::SellNpcs { index: 0 });
13436                    }
13437                }
13438                5 => {
13439                    if self.re_template_candidates().is_empty() {
13440                        self.state.push_log(
13441                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
13442                                .to_string(),
13443                        );
13444                    } else {
13445                        self.re_open_market_list_item();
13446                    }
13447                }
13448                6 => {
13449                    if self.re_blueprint_ids().is_empty() {
13450                        self.state.push_log(
13451                            "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
13452                                .to_string(),
13453                        );
13454                    } else {
13455                        self.re_open_sheet(S::CraftBlueprint { index: 0 });
13456                    }
13457                }
13458                7 => self.re_confirm_stop(
13459                    WorkerRouteStop::RestIfNeeded,
13460                    "rest at lodging (if needed)".into(),
13461                ),
13462                8 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
13463                9 => self.re_open_farm_plot_picker(
13464                    crate::worker_route_editor::FarmPlotAction::Cultivate,
13465                ),
13466                10 => {
13467                    self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant)
13468                }
13469                11 => self
13470                    .re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
13471                _ => {}
13472            },
13473            S::WaypointMenu { .. } => match row {
13474                0 => {
13475                    let (x, y, z) = self.state.player_position_with_z();
13476                    let stop = WorkerRouteStop::Waypoint { x, y, z };
13477                    self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13478                }
13479                1 => {
13480                    self.re_open_sheet(S::WaypointMapPick);
13481                    self.state.push_log(
13482                        "Route: click the map to place the waypoint (Esc to finish)".to_string(),
13483                    );
13484                }
13485                _ => {}
13486            },
13487            S::HarvestPicker { .. } => {
13488                let mut log: Option<String> = None;
13489                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13490                    let S::HarvestPicker {
13491                        index: sheet_index,
13492                        picked,
13493                        nodes,
13494                    } = &mut ed.sheet
13495                    else {
13496                        return;
13497                    };
13498                    *sheet_index = row;
13499                    if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
13500                        if picked.is_empty() {
13501                            log = Some(
13502                                "Route: pick at least one node (Space toggles, Done confirms)"
13503                                    .into(),
13504                            );
13505                        } else {
13506                            let ids: Vec<String> = picked.iter().cloned().collect();
13507                            let added = ed.confirm_harvest_picks(&ids);
13508                            log = Some(format!("Route: + {added} harvest stop(s)"));
13509                        }
13510                    } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
13511                        if picked.contains(&n.id) {
13512                            picked.remove(&n.id);
13513                        } else {
13514                            picked.insert(n.id.clone());
13515                        }
13516                    }
13517                }
13518                if let Some(msg) = log {
13519                    self.state.push_log(msg);
13520                }
13521            }
13522            S::WithdrawContainers { .. } => {
13523                let containers = self.re_container_candidates();
13524                if let Some(c) = containers.get(row) {
13525                    let id = c.id.clone();
13526                    self.re_open_withdraw_items(id);
13527                }
13528            }
13529            S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
13530            S::DepositContainers { .. } => {
13531                let containers = self.re_container_candidates();
13532                if let Some(c) = containers.get(row) {
13533                    let id = c.id.clone();
13534                    self.re_open_deposit_filter(id);
13535                }
13536            }
13537            S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
13538            S::SellNpcs { .. } => {
13539                let templates = self.re_template_candidates();
13540                let npcs = self.re_npc_candidates();
13541                if row == 0 {
13542                    if !crate::worker_route_editor::any_trade_npc_buys_route_item(
13543                        &self.state.npcs,
13544                        &templates,
13545                    ) {
13546                        self.state.push_log(
13547                            crate::worker_route_editor::sell_merchant_empty_reason(
13548                                None,
13549                                &self.state.npcs,
13550                                &templates,
13551                            ),
13552                        );
13553                        return;
13554                    }
13555                    self.re_open_sell_item(None);
13556                    return;
13557                }
13558                let Some(n) = npcs.get(row - 1) else {
13559                    return;
13560                };
13561                if !n.buys_route_item {
13562                    self.state
13563                        .push_log(crate::worker_route_editor::sell_merchant_empty_reason(
13564                            Some(n.id.as_str()),
13565                            &self.state.npcs,
13566                            &templates,
13567                        ));
13568                    return;
13569                }
13570                self.re_open_sell_item(Some(n.id.clone()));
13571            }
13572            S::SellItem { .. } => self.re_sell_item_activate(row),
13573            S::MarketListItem { .. } => self.re_market_list_item_activate(row),
13574            S::CraftBlueprint { .. } => {
13575                let bps = self.re_blueprint_ids();
13576                if let Some(bp) = bps.get(row) {
13577                    let stop = WorkerRouteStop::CraftAt {
13578                        device: "hand".into(),
13579                        blueprint: bp.clone(),
13580                        qty: None,
13581                    };
13582                    self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
13583                }
13584            }
13585            S::WaitEntry { ticks } => {
13586                let stop = WorkerRouteStop::Wait { wait_ticks: ticks };
13587                self.re_confirm_stop(stop, format!("wait {ticks}t"));
13588            }
13589            S::BedPicker { .. } => {
13590                let beds = self.re_bed_candidates();
13591                if let Some((id, name)) = beds.get(row) {
13592                    let (id, name) = (id.clone(), name.clone());
13593                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13594                        ed.lodging_container_id = Some(id.clone());
13595                        ed.sheet = S::Stops;
13596                    }
13597                    self.state
13598                        .push_log(format!("Route: rest bed set to {name}"));
13599                }
13600            }
13601            S::FarmPlotPicker { action, .. } => {
13602                let plots = self.re_farm_plot_candidates();
13603                let Some(plot) = plots.get(row).cloned() else {
13604                    return;
13605                };
13606                match action {
13607                    crate::worker_route_editor::FarmPlotAction::Cultivate => {
13608                        let label = plot_route_label(&plot);
13609                        self.re_confirm_stop(
13610                            WorkerRouteStop::CultivatePlot {
13611                                plot_id: plot.plot_id,
13612                            },
13613                            format!("cultivate {label}"),
13614                        );
13615                    }
13616                    crate::worker_route_editor::FarmPlotAction::Harvest => {
13617                        let label = plot_route_label(&plot);
13618                        self.re_confirm_stop(
13619                            WorkerRouteStop::HarvestPlot {
13620                                plot_id: plot.plot_id,
13621                            },
13622                            format!("harvest {label}"),
13623                        );
13624                    }
13625                    crate::worker_route_editor::FarmPlotAction::Plant => {
13626                        let seeds = self.re_farm_seed_candidates();
13627                        if seeds.is_empty() {
13628                            self.state.push_log(
13629                                "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
13630                            );
13631                            return;
13632                        }
13633                        self.re_open_sheet(S::FarmPlantSeed {
13634                            plot_id: plot.plot_id,
13635                            seeds,
13636                            index: 0,
13637                        });
13638                    }
13639                }
13640            }
13641            S::FarmPlantSeed { plot_id, seeds, .. } => {
13642                if let Some(seed) = seeds.get(row).cloned() {
13643                    self.re_confirm_stop(
13644                        WorkerRouteStop::PlantPlot {
13645                            plot_id,
13646                            seed_template: seed.clone(),
13647                        },
13648                        format!("plant {seed}"),
13649                    );
13650                }
13651            }
13652            S::WaypointMapPick => {}
13653        }
13654    }
13655
13656    fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
13657        use crate::worker_route_editor::RouteEditorSheet as S;
13658        if self.re_farm_plot_candidates().is_empty() {
13659            self.state
13660                .push_log("Route: no farmable plots visible — claim land or get farm access first");
13661            return;
13662        }
13663        self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
13664    }
13665
13666    fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
13667        self.state
13668            .property_plots
13669            .iter()
13670            .filter(|p| p.is_mine || p.may_farm)
13671            .cloned()
13672            .collect()
13673    }
13674
13675    /// Seed templates for a plant-plot route stop — does **not** require seeds on the
13676    /// player. Unions inventory, owned storage, withdraw lines already in the draft,
13677    /// and catalog `seed_for` templates so routes can withdraw-then-plant.
13678    fn re_farm_seed_candidates(&self) -> Vec<String> {
13679        let mut set = std::collections::BTreeSet::new();
13680        let looks_like_seed =
13681            |id: &str, catalog: &std::collections::HashMap<String, ItemCatalogEntryView>| {
13682                catalog.get(id).is_some_and(|e| e.is_farm_seed()) || id.ends_with("_seed")
13683            };
13684        for (id, _, _) in self.state.farm_seed_entries() {
13685            set.insert(id);
13686        }
13687        for c in &self.state.placed_containers {
13688            let mine = match (self.state.character_id, c.owner_character_id) {
13689                (Some(a), Some(b)) => a == b,
13690                _ => false,
13691            };
13692            if !mine {
13693                continue;
13694            }
13695            for s in &c.contents {
13696                if s.quantity > 0
13697                    && (s.props.contains_key("seed_for")
13698                        || looks_like_seed(&s.template_id, &self.state.item_catalog))
13699                {
13700                    set.insert(s.template_id.clone());
13701                }
13702            }
13703        }
13704        if let Some(ed) = self.state.worker_route_editor.as_ref() {
13705            for stop in &ed.stops {
13706                if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } =
13707                    stop
13708                {
13709                    for it in items {
13710                        if looks_like_seed(&it.template, &self.state.item_catalog) {
13711                            set.insert(it.template.clone());
13712                        }
13713                    }
13714                }
13715                if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
13716                    seed_template,
13717                    ..
13718                } = stop
13719                {
13720                    if !seed_template.is_empty() {
13721                        set.insert(seed_template.clone());
13722                    }
13723                }
13724            }
13725        }
13726        for (id, entry) in &self.state.item_catalog {
13727            if entry.is_farm_seed() {
13728                set.insert(id.clone());
13729            }
13730        }
13731        set.into_iter().collect()
13732    }
13733
13734    // ---- map clicks (sheet-scoped accelerators) ------------------------
13735
13736    /// Map click while the route editor is open. When a picker sheet is open
13737    /// the click feeds *that* sheet (chest clicks choose the withdraw/deposit
13738    /// container, NPC clicks the merchant, node clicks the harvest target);
13739    /// otherwise the click quick-adds the nearest target (context-aware).
13740    pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
13741        use crate::worker_route_editor as wre;
13742        use wre::RouteEditorSheet as S;
13743        if self.state.worker_route_editor.is_none() {
13744            return;
13745        }
13746        let sheet = self
13747            .state
13748            .worker_route_editor
13749            .as_ref()
13750            .map(|ed| ed.sheet.clone())
13751            .unwrap_or(S::Stops);
13752        match sheet {
13753            S::WaypointMapPick => {
13754                let (_, _, z) = self.state.player_position_with_z();
13755                let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
13756                self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13757                // Stay in map-pick mode when adding (not editing) for fast pathing.
13758                let editing = self
13759                    .state
13760                    .worker_route_editor
13761                    .as_ref()
13762                    .is_some_and(|ed| ed.editing_index.is_some());
13763                if !editing {
13764                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13765                        ed.sheet = S::WaypointMapPick;
13766                    }
13767                }
13768            }
13769            S::HarvestPicker { .. } => {
13770                if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
13771                    let mut log: Option<String> = None;
13772                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13773                        let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
13774                            return;
13775                        };
13776                        let selected = if picked.contains(&node.id) {
13777                            picked.remove(&node.id);
13778                            false
13779                        } else {
13780                            picked.insert(node.id.clone());
13781                            true
13782                        };
13783                        log = Some(format!(
13784                            "Route: {} {}",
13785                            if selected { "selected" } else { "deselected" },
13786                            resource_node_route_label(node)
13787                        ));
13788                    }
13789                    if let Some(msg) = log {
13790                        self.state.push_log(msg);
13791                    }
13792                }
13793            }
13794            S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
13795                // Chest click picks (or switches) the withdraw source.
13796                let inside = self.state.effective_inside_building();
13797                if let Some(cid) = wre::pick_storage_container_at(
13798                    &self.state.placed_containers,
13799                    self.state.character_id,
13800                    x,
13801                    y,
13802                    inside.as_deref(),
13803                ) {
13804                    self.re_open_withdraw_items(cid);
13805                }
13806            }
13807            S::DepositContainers { .. } | S::DepositFilter { .. } => {
13808                let inside = self.state.effective_inside_building();
13809                if let Some(cid) = wre::pick_storage_container_at(
13810                    &self.state.placed_containers,
13811                    self.state.character_id,
13812                    x,
13813                    y,
13814                    inside.as_deref(),
13815                ) {
13816                    self.re_open_deposit_filter(cid);
13817                }
13818            }
13819            S::SellNpcs { .. } => {
13820                if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13821                    self.re_open_sell_item(Some(npc_id));
13822                }
13823            }
13824            S::SellItem { .. } => {
13825                if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13826                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13827                        if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
13828                            *slot = Some(npc_id.clone());
13829                        }
13830                    }
13831                    self.state
13832                        .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
13833                }
13834            }
13835            // Stop list / other sheets: context-aware quick add.
13836            _ => self.worker_route_editor_quick_add_click(x, y),
13837        }
13838    }
13839
13840    /// Quick-add map click with no picker open: nearest of bed/chest/merchant/
13841    /// node wins; duplicates select the existing stop; a click on a merchant
13842    /// while a sell stop is selected pins it.
13843    fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
13844        use crate::worker_route_editor as wre;
13845        let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
13846            let dx = ax - bx;
13847            let dy = ay - by;
13848            (dx * dx + dy * dy).sqrt()
13849        };
13850
13851        // 1. Retarget the selected stop when the click names its target:
13852        //    sell stop → pin the merchant; withdraw stop → set the source chest.
13853        let selected_stop_kind = self
13854            .state
13855            .worker_route_editor
13856            .as_ref()
13857            .and_then(|ed| ed.stops.get(ed.selected_stop_index))
13858            .map(|s| match s {
13859                wre::WorkerRouteStop::TradeWith { .. } => 1,
13860                wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
13861                _ => 0,
13862            })
13863            .unwrap_or(0);
13864        if selected_stop_kind == 1 {
13865            if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13866                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13867                    ed.set_selected_trade_npc(npc_id.clone());
13868                }
13869                self.state
13870                    .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
13871                return;
13872            }
13873        }
13874        if selected_stop_kind == 2 {
13875            let inside = self.state.effective_inside_building();
13876            if let Some(cid) = wre::pick_storage_container_at(
13877                &self.state.placed_containers,
13878                self.state.character_id,
13879                x,
13880                y,
13881                inside.as_deref(),
13882            ) {
13883                let name = self
13884                    .state
13885                    .placed_containers
13886                    .iter()
13887                    .find(|c| c.id == cid)
13888                    .map(|c| c.display_name.clone())
13889                    .unwrap_or_else(|| "container".into());
13890                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13891                    ed.set_selected_withdraw_container(cid.clone());
13892                }
13893                self.state
13894                    .push_log(format!("Route: withdraw source → {name}"));
13895                return;
13896            }
13897        }
13898
13899        // 2. Nearest candidate of any kind wins. Category order is the
13900        //    tie-break for exact overlaps (bed over chest over merchant over node).
13901        enum Target {
13902            Bed(String),
13903            Container(String),
13904            Npc(String, String),
13905            Node(String, String),
13906        }
13907        let mut best: Option<(f32, u8, Target)> = None;
13908        let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
13909            let better = match best {
13910                None => true,
13911                Some((bd, brank, _)) => {
13912                    d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank)
13913                }
13914            };
13915            if better {
13916                *best = Some((d, rank, t));
13917            }
13918        };
13919        let inside = self.state.effective_inside_building();
13920        if let Some(bed_id) = wre::pick_lodging_container_at(
13921            &self.state.placed_containers,
13922            self.state.character_id,
13923            x,
13924            y,
13925            inside.as_deref(),
13926        ) {
13927            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
13928                // A bed that's already the rest bed doubles as a storage chest —
13929                // a second click on it means "deposit here", not "set bed again".
13930                let already_bed =
13931                    self.state.worker_route_editor.as_ref().is_some_and(|ed| {
13932                        ed.lodging_container_id.as_deref() == Some(bed_id.as_str())
13933                    });
13934                if already_bed {
13935                    consider(
13936                        dist(x, y, c.x, c.y),
13937                        1,
13938                        Target::Container(bed_id),
13939                        &mut best,
13940                    );
13941                } else {
13942                    consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
13943                }
13944            }
13945        }
13946        if let Some(cid) = wre::pick_storage_container_at(
13947            &self.state.placed_containers,
13948            self.state.character_id,
13949            x,
13950            y,
13951            inside.as_deref(),
13952        ) {
13953            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
13954                consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
13955            }
13956        }
13957        if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13958            if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
13959                consider(
13960                    dist(x, y, n.x, n.y),
13961                    2,
13962                    Target::Npc(npc_id, label),
13963                    &mut best,
13964                );
13965            }
13966        }
13967        if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
13968            let d = dist(x, y, node.x, node.y);
13969            let label = resource_node_route_label(node);
13970            consider(d, 3, Target::Node(node.id.clone(), label), &mut best);
13971        }
13972
13973        match best.map(|(_, _, t)| t) {
13974            Some(Target::Bed(bed_id)) => {
13975                let name = self
13976                    .state
13977                    .placed_containers
13978                    .iter()
13979                    .find(|c| c.id == bed_id)
13980                    .map(|c| c.display_name.clone())
13981                    .unwrap_or_else(|| "camp bed".into());
13982                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13983                    ed.lodging_container_id = Some(bed_id.clone());
13984                }
13985                self.state
13986                    .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
13987            }
13988            Some(Target::Container(cid)) => {
13989                let name = self
13990                    .state
13991                    .placed_containers
13992                    .iter()
13993                    .find(|c| c.id == cid)
13994                    .map(|c| c.display_name.clone())
13995                    .unwrap_or_else(|| "container".into());
13996                let added = self
13997                    .state
13998                    .worker_route_editor
13999                    .as_mut()
14000                    .is_some_and(|ed| ed.append_deposit_at(&cid));
14001                if added {
14002                    self.state
14003                        .push_log(format!("Route: + deposit at {name} ({cid})"));
14004                } else {
14005                    self.state.push_log(format!(
14006                        "Route: {name} already in route — selected it (d to remove)"
14007                    ));
14008                }
14009            }
14010            Some(Target::Npc(npc_id, label)) => {
14011                // Quick-add sell: first storage template (the full sell flow
14012                // lives in the a → Sell sheet).
14013                let template = self.re_template_candidates().into_iter().next();
14014                let Some(template) = template else {
14015                    self.state.push_log(
14016                        "Route: no items in your storage to sell — stock a chest first".to_string(),
14017                    );
14018                    return;
14019                };
14020                let added = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
14021                    ed.append_trade_with(template.clone(), Some(npc_id.clone()), true)
14022                });
14023                if added {
14024                    self.state
14025                        .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
14026                } else {
14027                    self.state.push_log(format!(
14028                        "Route: {label} already sells {template} — selected it (d to remove)"
14029                    ));
14030                }
14031            }
14032            Some(Target::Node(id, label)) => {
14033                let added = self
14034                    .state
14035                    .worker_route_editor
14036                    .as_mut()
14037                    .is_some_and(|ed| ed.append_harvest_node(&id));
14038                if added {
14039                    self.state
14040                        .push_log(format!("Route: + harvest node {label}"));
14041                } else {
14042                    self.state.push_log(format!(
14043                        "Route: {label} already in route — selected it (d to remove)"
14044                    ));
14045                }
14046            }
14047            None => {}
14048        }
14049    }
14050
14051    pub fn worker_route_editor_select(&mut self, delta: i32) {
14052        let Some(ed) = self.state.worker_route_editor.as_mut() else {
14053            return;
14054        };
14055        if ed.stops.is_empty() {
14056            return;
14057        }
14058        let n = ed.stops.len() as i32;
14059        let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
14060        ed.selected_stop_index = next;
14061    }
14062
14063    pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
14064        let Some(ed) = self.state.worker_route_editor.as_mut() else {
14065            return;
14066        };
14067        if delta < 0 {
14068            ed.move_selected_up();
14069        } else if delta > 0 {
14070            ed.move_selected_down();
14071        }
14072    }
14073
14074    pub fn worker_route_editor_delete_selected(&mut self) {
14075        let removed = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
14076            let before = ed.stop_count();
14077            ed.remove_selected_stop();
14078            ed.stop_count() < before
14079        });
14080        if removed {
14081            self.state.push_log("Route: removed selected stop");
14082        }
14083    }
14084
14085    /// Clear all stops. Saving afterwards parks the worker in idle mode
14086    /// instead of leaving it in a broken job loop.
14087    pub fn worker_route_editor_clear_stops(&mut self) {
14088        let Some(ed) = self.state.worker_route_editor.as_mut() else {
14089            return;
14090        };
14091        if ed.stops.is_empty() {
14092            self.state
14093                .push_log("Route: already empty — s saves an idle worker".to_string());
14094            return;
14095        }
14096        ed.stops.clear();
14097        ed.selected_stop_index = 0;
14098        self.state.push_log(
14099            "Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string(),
14100        );
14101    }
14102
14103    pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
14104        if self.state.pending_worker_job_ack.is_some() {
14105            anyhow::bail!("route save still pending — wait for server ack");
14106        }
14107        let Some(ed) = self.state.worker_route_editor.clone() else {
14108            anyhow::bail!("route editor not open");
14109        };
14110        // Empty route = stand down: park the worker in idle mode rather than
14111        // erroring out or leaving it marching a ghost loop.
14112        let (job_yaml, idle) = if ed.stops.is_empty() {
14113            (ed.build_idle_job_yaml(), true)
14114        } else {
14115            (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
14116        };
14117        let worker_id = ed.worker_instance_id.clone();
14118        let route_view = if idle { None } else { Some(ed.to_route_view()) };
14119        let mode = if idle {
14120            flatland_protocol::WorkerModeView::Idle
14121        } else {
14122            flatland_protocol::WorkerModeView::JobLoop
14123        };
14124        let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
14125            .state
14126            .hired_workers
14127            .iter()
14128            .find(|w| w.instance_id == worker_id)
14129            .map(|w| {
14130                (
14131                    w.route.clone(),
14132                    w.mode,
14133                    w.step_label.clone(),
14134                    w.last_error.clone(),
14135                )
14136            })
14137            .unwrap_or((
14138                None,
14139                flatland_protocol::WorkerModeView::Idle,
14140                String::new(),
14141                None,
14142            ));
14143        self.seq += 1;
14144        let seq = self.seq;
14145        self.session
14146            .submit_intent(Intent::SetWorkerJob {
14147                entity_id: self.state.entity_id,
14148                worker_instance_id: worker_id.clone(),
14149                job_yaml,
14150                seq,
14151            })
14152            .await?;
14153        self.state.intents_sent += 1;
14154        if let Some(w) = self
14155            .state
14156            .hired_workers
14157            .iter_mut()
14158            .find(|w| w.instance_id == worker_id)
14159        {
14160            w.route = route_view;
14161            w.mode = mode;
14162            w.last_error = None;
14163            if idle {
14164                w.step_label.clear();
14165                w.route_stop_index = None;
14166            }
14167        }
14168        self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
14169            seq,
14170            worker_instance_id: worker_id,
14171            worker_label: ed.worker_label.clone(),
14172            idle,
14173            stop_count: ed.stops.len(),
14174            prev_route,
14175            prev_mode,
14176            prev_step_label,
14177            prev_last_error,
14178        });
14179        self.state.push_log(format!(
14180            "Route: saving for {}… (waiting for server)",
14181            ed.worker_label
14182        ));
14183        // Keep editor open until IntentAck; reject Interaction reverts optimistic state.
14184        Ok(())
14185    }
14186    pub fn quest_menu_move(&mut self, delta: i32) {
14187        let n = self.state.active_quest_entries().len();
14188        if n == 0 {
14189            return;
14190        }
14191        let idx = self.state.quest_menu_index as i32;
14192        self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
14193    }
14194
14195    pub fn quest_menu_page(&mut self, pages: i32) {
14196        let n = self.state.active_quest_entries().len();
14197        self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
14198    }
14199
14200    pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
14201        let Some(offer) = self.state.selected_quest_offer().cloned() else {
14202            anyhow::bail!("no quest offer");
14203        };
14204        self.seq += 1;
14205        let seq = self.seq;
14206        self.session
14207            .submit_intent(Intent::AcceptQuest {
14208                entity_id: self.state.entity_id,
14209                quest_id: offer.quest_id,
14210                seq,
14211            })
14212            .await?;
14213        self.state.intents_sent += 1;
14214        Ok(())
14215    }
14216
14217    pub fn quest_offer_move(&mut self, delta: i32) {
14218        self.state.move_quest_offer_selection(delta);
14219    }
14220
14221    pub fn quest_offer_decline(&mut self) {
14222        self.state.clear_quest_offers();
14223        if !self.state.show_npc_chat
14224            && !self.state.show_shop_menu
14225            && self.state.npc_verb_target.is_some()
14226        {
14227            self.state.show_npc_verb_menu = true;
14228        }
14229    }
14230
14231    pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
14232        if !self.state.show_quest_menu {
14233            return Ok(());
14234        }
14235        let active: Vec<_> = self
14236            .state
14237            .active_quest_entries()
14238            .into_iter()
14239            .cloned()
14240            .collect();
14241        let Some(entry) = active.get(self.state.quest_menu_index) else {
14242            return Ok(());
14243        };
14244        if self.state.quest_withdraw_confirm {
14245            if !entry.can_withdraw {
14246                anyhow::bail!("quest cannot be withdrawn");
14247            }
14248            self.seq += 1;
14249            let seq = self.seq;
14250            self.session
14251                .submit_intent(Intent::WithdrawQuest {
14252                    entity_id: self.state.entity_id,
14253                    quest_id: entry.quest_id.clone(),
14254                    seq,
14255                })
14256                .await?;
14257            self.state.intents_sent += 1;
14258            self.state.quest_withdraw_confirm = false;
14259            return Ok(());
14260        }
14261        self.seq += 1;
14262        let seq = self.seq;
14263        self.session
14264            .submit_intent(Intent::TrackQuest {
14265                entity_id: self.state.entity_id,
14266                quest_id: entry.quest_id.clone(),
14267                seq,
14268            })
14269            .await?;
14270        self.state.intents_sent += 1;
14271        Ok(())
14272    }
14273
14274    pub fn quest_request_withdraw(&mut self) {
14275        if self.state.show_quest_menu {
14276            self.state.quest_withdraw_confirm = true;
14277        }
14278    }
14279
14280    pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
14281        if !self.state.is_alive() {
14282            anyhow::bail!("you are dead");
14283        }
14284        let Some(catalog) = self.state.shop_catalog.clone() else {
14285            anyhow::bail!("no shop open");
14286        };
14287        self.seq += 1;
14288        let seq = self.seq;
14289        match self.state.shop_tab {
14290            ShopTab::Buy => {
14291                let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
14292                    anyhow::bail!("nothing selected");
14293                };
14294                if offer.already_owned {
14295                    anyhow::bail!("already owned");
14296                }
14297                self.session
14298                    .submit_intent(Intent::ShopBuy {
14299                        entity_id: self.state.entity_id,
14300                        npc_id: catalog.npc_id.clone(),
14301                        offer_id: offer.offer_id.clone(),
14302                        quantity: self.state.shop_quantity,
14303                        seq,
14304                    })
14305                    .await?;
14306            }
14307            ShopTab::Sell => {
14308                let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
14309                    anyhow::bail!("nothing to sell");
14310                };
14311                if line.quantity == 0 {
14312                    anyhow::bail!("you have no {}", line.label);
14313                }
14314                let quantity = self.state.shop_quantity.min(line.quantity).max(1);
14315                self.session
14316                    .submit_intent(Intent::ShopSell {
14317                        entity_id: self.state.entity_id,
14318                        npc_id: catalog.npc_id.clone(),
14319                        template_id: line.template_id.clone(),
14320                        quantity,
14321                        seq,
14322                    })
14323                    .await?;
14324            }
14325        }
14326        self.state.intents_sent += 1;
14327        Ok(())
14328    }
14329
14330    pub fn craft_menu_move(&mut self, delta: i32) {
14331        let n = self.state.craft_filtered_indices().len();
14332        if n == 0 {
14333            return;
14334        }
14335        let idx = self.state.craft_menu_index as i32;
14336        let next = (idx + delta).rem_euclid(n as i32);
14337        self.state.craft_menu_index = next as usize;
14338        self.state.clamp_craft_batch_quantity();
14339    }
14340
14341    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
14342        self.state.craft_batch_adjust_quantity(delta);
14343    }
14344
14345    pub fn craft_batch_set_max(&mut self) {
14346        self.state.craft_batch_set_max();
14347    }
14348
14349    pub fn craft_batch_set_min(&mut self) {
14350        self.state.craft_batch_set_min();
14351    }
14352
14353    pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
14354        let Some(blueprint) = self.state.craft_selected_blueprint().cloned() else {
14355            anyhow::bail!("no blueprints in this tab");
14356        };
14357        if !self.state.can_craft_blueprint(&blueprint) {
14358            let hint = self
14359                .state
14360                .craft_missing_hint(&blueprint)
14361                .unwrap_or_else(|| "missing materials".into());
14362            anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
14363        }
14364        let count = self.state.craft_batch_quantity;
14365        let max = self.state.max_craft_batches(&blueprint);
14366        if max == 0 {
14367            anyhow::bail!("cannot craft {}", blueprint.label);
14368        }
14369        let batches = count.min(max);
14370        self.craft(&blueprint.id, Some(batches)).await?;
14371        // Keep the craft menu open so progress / blockers stay visible.
14372        Ok(())
14373    }
14374
14375    pub async fn move_by(
14376        &mut self,
14377        forward: f32,
14378        strafe: f32,
14379        vertical: f32,
14380        sprint: bool,
14381        sneak: bool,
14382    ) -> anyhow::Result<()> {
14383        if !self.state.is_alive() {
14384            anyhow::bail!("you are dead");
14385        }
14386        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
14387            self.last_move_forward = forward;
14388            self.last_move_strafe = strafe;
14389        }
14390        self.seq += 1;
14391        self.session
14392            .submit_intent(Intent::Move {
14393                entity_id: self.state.entity_id,
14394                forward,
14395                strafe,
14396                vertical,
14397                sprint: sprint && !sneak,
14398                sneak,
14399                seq: self.seq,
14400            })
14401            .await?;
14402        self.state.intents_sent += 1;
14403        Ok(())
14404    }
14405
14406    pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
14407        if !self.state.connected {
14408            crate::harvest_trace!("harvest_nearest rejected: not connected");
14409            anyhow::bail!("not connected");
14410        }
14411        if !self.state.is_alive() {
14412            crate::harvest_trace!("harvest_nearest rejected: player dead");
14413            anyhow::bail!("you are dead");
14414        }
14415        if self.state.harvest_in_progress {
14416            if self.state.harvest_state_stale() {
14417                self.state.clear_harvest_state();
14418            } else {
14419                anyhow::bail!("already harvesting");
14420            }
14421        }
14422        let (px, py) = self
14423            .state
14424            .player
14425            .as_ref()
14426            .map(|p| (p.transform.position.x, p.transform.position.y))
14427            .unwrap_or((0.0, 0.0));
14428
14429        let available = self
14430            .state
14431            .resource_nodes
14432            .iter()
14433            .filter(|n| !n.harvest_off)
14434            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14435            .count();
14436        let node_id = self
14437            .state
14438            .resource_nodes
14439            .iter()
14440            .filter(|n| !n.harvest_off)
14441            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14442            .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
14443            .min_by(|a, b| {
14444                let da = distance(px, py, a.x, a.y);
14445                let db = distance(px, py, b.x, b.y);
14446                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
14447            })
14448            .map(|n| n.id.clone());
14449
14450        let Some(node_id) = node_id else {
14451            let has_loot = self
14452                .state
14453                .ground_drops
14454                .iter()
14455                .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14456            if has_loot {
14457                return self.pickup_nearest().await;
14458            }
14459            anyhow::bail!(
14460                "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
14461            );
14462        };
14463
14464        self.seq += 1;
14465        let seq = self.seq;
14466        crate::harvest_trace!(
14467            entity_id = self.state.entity_id,
14468            node_id = %node_id,
14469            seq,
14470            px,
14471            py,
14472            available_nodes = available,
14473            "submitting harvest intent"
14474        );
14475        self.session
14476            .submit_intent(Intent::Harvest {
14477                entity_id: self.state.entity_id,
14478                node_id,
14479                seq,
14480            })
14481            .await?;
14482        self.state.intents_sent += 1;
14483        self.state.harvest_in_progress = true;
14484        self.state.harvest_started_at = Some(Instant::now());
14485        self.state.push_log("Harvesting…");
14486        crate::harvest_trace!(
14487            entity_id = self.state.entity_id,
14488            seq,
14489            "harvest intent queued to session"
14490        );
14491        Ok(())
14492    }
14493
14494    pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
14495        if !self.state.is_alive() {
14496            anyhow::bail!("you are dead");
14497        }
14498        let blueprint_id = self
14499            .state
14500            .blueprints
14501            .iter()
14502            .find(|bp| self.state.can_craft_blueprint(bp))
14503            .map(|bp| bp.id.clone())
14504            .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
14505        self.craft(&blueprint_id, None).await
14506    }
14507
14508    pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
14509        if !self.state.is_alive() {
14510            anyhow::bail!("you are dead");
14511        }
14512        self.seq += 1;
14513        self.session
14514            .submit_intent(Intent::Craft {
14515                entity_id: self.state.entity_id,
14516                blueprint_id: blueprint_id.to_string(),
14517                count,
14518                seq: self.seq,
14519            })
14520            .await?;
14521        self.state.intents_sent += 1;
14522        let (label, batches) = self
14523            .state
14524            .blueprints
14525            .iter()
14526            .find(|b| b.id == blueprint_id)
14527            .map(|b| {
14528                let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
14529                (b.label.as_str(), n)
14530            })
14531            .unwrap_or((blueprint_id, count.unwrap_or(1)));
14532        self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
14533        self.state.craft_channel_blueprint_id = Some(blueprint_id.to_string());
14534        Ok(())
14535    }
14536
14537    pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
14538        if !self.state.is_alive() {
14539            anyhow::bail!("you are dead");
14540        }
14541        let target_id = match self.state.nearest_interact_target() {
14542            Some(id) => id,
14543            None => {
14544                anyhow::bail!("nothing to interact with nearby");
14545            }
14546        };
14547        if self.state.npcs.iter().any(|n| n.id == target_id) {
14548            self.state.show_npc_verb_menu = true;
14549            self.state.npc_verb_target = Some(target_id);
14550            self.state.npc_verb_index = 0;
14551            self.state.npc_verb_notice = None;
14552            return Ok(());
14553        }
14554        if self
14555            .state
14556            .hired_workers
14557            .iter()
14558            .any(|w| w.instance_id == target_id)
14559        {
14560            return self.open_workers_menu_for(&target_id).await;
14561        }
14562        if let Ok(peer_id) = target_id.parse::<EntityId>() {
14563            if self
14564                .state
14565                .hired_workers
14566                .iter()
14567                .any(|w| w.entity_id == peer_id)
14568            {
14569                if let Some(w) = self
14570                    .state
14571                    .hired_workers
14572                    .iter()
14573                    .find(|w| w.entity_id == peer_id)
14574                {
14575                    let id = w.instance_id.clone();
14576                    return self.open_workers_menu_for(&id).await;
14577                }
14578            }
14579            if let Some(entity) = self
14580                .state
14581                .entities
14582                .iter()
14583                .find(|e| e.id == peer_id && e.id != self.state.entity_id)
14584            {
14585                self.state.player_verbs.open_for(peer_id, &entity.label);
14586                return Ok(());
14587            }
14588        }
14589        self.seq += 1;
14590        self.session
14591            .submit_intent(Intent::Interact {
14592                entity_id: self.state.entity_id,
14593                target_id: target_id.clone(),
14594                seq: self.seq,
14595            })
14596            .await?;
14597        self.state.intents_sent += 1;
14598        Ok(())
14599    }
14600
14601    /// Context-sensitive world use: loot → interact/door → chest → plot farm → harvest.
14602    ///
14603    /// After loot-first, follows [`crate::UseWorldKind::cascade_stage`] so HUD probe
14604    /// and `f` never diverge (doors beat lodging/chest pickup indoors).
14605    pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
14606        if !self.state.is_alive() {
14607            anyhow::bail!("you are dead");
14608        }
14609        let (px, py) = self.state.player_position();
14610        let has_loot = self
14611            .state
14612            .ground_drops
14613            .iter()
14614            .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14615        if has_loot {
14616            return self.pickup_nearest().await;
14617        }
14618
14619        // Align with probe cascade: interact (doors/NPCs/boards) → chest → harvest.
14620        if let Some(primary) = self.state.probe_use_world().primary {
14621            match primary.kind.cascade_stage() {
14622                0 => return self.interact_nearest().await,
14623                2 => return self.pickup_nearest_container().await,
14624                3 => return self.harvest_nearest().await,
14625                _ => {}
14626            }
14627        }
14628
14629        if let Some(plot) = self.state.my_plot_under_player().cloned() {
14630            // Double-tap f within 1.2s sells; otherwise farm the plot.
14631            const SELL_WINDOW: Duration = Duration::from_millis(1200);
14632            let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
14633                && self
14634                    .state
14635                    .sell_plot_armed_at
14636                    .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
14637            if sell_armed {
14638                return self.confirm_sell_plot_to_crown(plot.plot_id).await;
14639            }
14640            self.state.sell_plot_confirm = None;
14641            self.state.sell_plot_armed_at = None;
14642
14643            // Prefer farm actions on your own land unless an NPC/player/door/board
14644            // is in interact range (those still win).
14645            let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
14646                self.state.npcs.iter().any(|n| n.id == id)
14647                    || self.state.hired_workers.iter().any(|w| w.instance_id == id)
14648                    || self.state.doors.iter().any(|d| d.id == id)
14649                    || self.state.interactables.iter().any(|i| {
14650                        i.id == id
14651                            && matches!(i.kind.as_str(), "quest_board" | "well" | "exit" | "enter")
14652                    })
14653                    || id.parse::<EntityId>().is_ok_and(|eid| {
14654                        self.state
14655                            .entities
14656                            .iter()
14657                            .any(|e| e.id == eid && e.id != self.state.entity_id)
14658                    })
14659            });
14660            if !blocking_interact {
14661                // On your plot, `f` is harvest only — use `c` / `p` for till and plant.
14662                match self.harvest_nearest().await {
14663                    Ok(()) => return Ok(()),
14664                    Err(err) => {
14665                        let msg = err.to_string();
14666                        if !(msg.contains("no harvestable")
14667                            || msg.contains("press p")
14668                            || msg.contains("press f")
14669                            || msg.contains("nothing"))
14670                        {
14671                            return Err(err);
14672                        }
14673                    }
14674                }
14675                return Ok(());
14676            }
14677        }
14678        if self.state.nearest_interact_target().is_some() {
14679            return self.interact_nearest().await;
14680        }
14681        // Prefer a clear "move closer" when a board is visible but out of reach,
14682        // instead of silently falling through to harvest.
14683        if let Some((label, dist)) = self.state.nearest_quest_board() {
14684            if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
14685                anyhow::bail!(
14686                    "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
14687                );
14688            }
14689        }
14690
14691        match self.harvest_nearest().await {
14692            Ok(()) => Ok(()),
14693            Err(err) => {
14694                let msg = err.to_string();
14695                if msg.contains("no harvestable")
14696                    || msg.contains("press p")
14697                    || msg.contains("press f")
14698                {
14699                    anyhow::bail!(
14700                        "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
14701                    );
14702                }
14703                Err(err)
14704            }
14705        }
14706    }
14707
14708    /// Enter claim-mode footprint editor on unclaimed crown land (`k`).
14709    pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
14710        if !self.state.is_alive() {
14711            anyhow::bail!("you are dead");
14712        }
14713        if self.state.claim_mode.is_some() {
14714            anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
14715        }
14716        let zone = self
14717            .state
14718            .free_property_zone_under_player()
14719            .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
14720        let zone_id = zone.id.clone();
14721        let label = zone
14722            .label
14723            .as_deref()
14724            .filter(|s| !s.trim().is_empty())
14725            .unwrap_or(zone.id.as_str())
14726            .to_string();
14727        self.enter_claim_mode(&zone_id);
14728        self.state.push_log(format!(
14729            "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
14730        ));
14731        Ok(())
14732    }
14733
14734    /// Enter claim mode over a property zone (default 4×4 or min size).
14735    pub fn enter_claim_mode(&mut self, zone_id: &str) {
14736        let Some(zone) = self
14737            .state
14738            .property_zones
14739            .iter()
14740            .find(|z| z.id == zone_id)
14741            .cloned()
14742        else {
14743            self.state.push_log("unknown property zone");
14744            return;
14745        };
14746        self.state.sell_plot_confirm = None;
14747        self.state.sell_plot_armed_at = None;
14748        let min_area = self
14749            .state
14750            .property_plot_settings
14751            .as_ref()
14752            .map(|s| s.min_plot_area_m2)
14753            .unwrap_or(4.0)
14754            .max(1.0);
14755        let min_side = min_area.sqrt().ceil().max(1.0) as u32;
14756        let side = 4u32.max(min_side);
14757        let (px, py) = self.state.player_position();
14758        let anchor_x = px.floor();
14759        let anchor_y = py.floor();
14760        self.state.claim_mode = Some(ClaimModeState {
14761            zone_id: zone.id.clone(),
14762            width_m: side,
14763            height_m: side,
14764            anchor_x,
14765            anchor_y,
14766        });
14767        let label = zone
14768            .label
14769            .as_deref()
14770            .filter(|s| !s.trim().is_empty())
14771            .unwrap_or(zone.id.as_str());
14772        self.state.push_log(format!(
14773            "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
14774        ));
14775    }
14776
14777    pub fn cancel_claim_mode(&mut self) {
14778        if self.state.claim_mode.take().is_some() {
14779            self.state.push_log("Claim cancelled");
14780        }
14781    }
14782
14783    /// Enter relocate mode for a placed container (1×1 ghost).
14784    pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
14785        if !self.state.is_alive() {
14786            anyhow::bail!("you are dead");
14787        }
14788        if self.state.relocate_mode.is_some() {
14789            anyhow::bail!("already relocating — Enter confirm, Esc cancel");
14790        }
14791        if self.state.claim_mode.is_some() {
14792            anyhow::bail!("finish or cancel claim mode first");
14793        }
14794        let chest = self
14795            .state
14796            .placed_containers
14797            .iter()
14798            .find(|c| c.id == container_id)
14799            .cloned()
14800            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
14801        let (px, py) = self.state.player_position();
14802        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
14803            anyhow::bail!("too far from {}", chest.display_name);
14804        }
14805        if chest.locked && !chest.accessible {
14806            anyhow::bail!(
14807                "need the matching key for {} before moving it",
14808                chest.display_name
14809            );
14810        }
14811        let label = if chest.display_name.trim().is_empty() {
14812            chest.template_id.clone()
14813        } else {
14814            chest.display_name.clone()
14815        };
14816        self.state.relocate_mode = Some(RelocateModeState {
14817            container_id: chest.id.clone(),
14818            label: label.clone(),
14819            cursor_x: chest.x.floor() + 0.5,
14820            cursor_y: chest.y.floor() + 0.5,
14821        });
14822        self.state.push_log(format!(
14823            "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
14824        ));
14825        Ok(())
14826    }
14827
14828    /// World `Shift+m`: relocate nearest owned/accessible placed container.
14829    pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
14830        let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
14831            anyhow::bail!("no chest nearby to relocate");
14832        };
14833        if chest.locked && !chest.accessible {
14834            anyhow::bail!(
14835                "need the matching key for {} before moving it",
14836                chest.display_name
14837            );
14838        }
14839        // Prefer own chests: if owner is set and we're not the owner, skip unless accessible unlocked?
14840        // Server enforces ownership; client starts on nearest accessible chest in range.
14841        self.begin_relocate_container(&chest.id)
14842    }
14843
14844    pub fn cancel_relocate_mode(&mut self) {
14845        if self.state.relocate_mode.take().is_some() {
14846            self.state.push_log("Relocate cancelled");
14847        }
14848    }
14849
14850    pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
14851        let Some(mode) = self.state.relocate_mode.as_mut() else {
14852            return;
14853        };
14854        let max_x = self.state.world_width_m.max(1.0);
14855        let max_y = self.state.world_height_m.max(1.0);
14856        let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
14857        let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
14858        mode.cursor_x = nx.floor() + 0.5;
14859        mode.cursor_y = ny.floor() + 0.5;
14860    }
14861
14862    pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
14863        let Some(mode) = self.state.relocate_mode.as_mut() else {
14864            return;
14865        };
14866        let max_x = self.state.world_width_m.max(1.0);
14867        let max_y = self.state.world_height_m.max(1.0);
14868        mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
14869        mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
14870    }
14871
14872    pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
14873        if !self.state.is_alive() {
14874            anyhow::bail!("you are dead");
14875        }
14876        let Some(mode) = self.state.relocate_mode.clone() else {
14877            anyhow::bail!("not relocating");
14878        };
14879        let (px, py) = self.state.player_position();
14880        let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
14881        if dist > 8.0 {
14882            anyhow::bail!("destination too far (max 8 m)");
14883        }
14884        self.seq += 1;
14885        self.session
14886            .submit_intent(Intent::MovePlacedContainer {
14887                entity_id: self.state.entity_id,
14888                container_id: mode.container_id.clone(),
14889                x: mode.cursor_x,
14890                y: mode.cursor_y,
14891                seq: self.seq,
14892            })
14893            .await?;
14894        self.state.intents_sent += 1;
14895        self.state.relocate_mode = None;
14896        self.state.push_log(format!("Moving {}…", mode.label));
14897        Ok(())
14898    }
14899
14900    pub fn claim_set_preset(&mut self, w: u32, h: u32) {
14901        let Some(mode) = self.state.claim_mode.as_mut() else {
14902            return;
14903        };
14904        mode.width_m = w.max(1);
14905        mode.height_m = h.max(1);
14906    }
14907
14908    pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
14909        let Some(mode) = self.state.claim_mode.as_mut() else {
14910            return;
14911        };
14912        let w = (mode.width_m as i32 + dw).max(1) as u32;
14913        let h = (mode.height_m as i32 + dh).max(1) as u32;
14914        mode.width_m = w;
14915        mode.height_m = h;
14916    }
14917
14918    /// Nudge the claim footprint SW corner one meter cell (WASD / arrows).
14919    pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
14920        let Some(mode) = self.state.claim_mode.as_mut() else {
14921            return;
14922        };
14923        let max_x = self.state.world_width_m.max(1.0);
14924        let max_y = self.state.world_height_m.max(1.0);
14925        let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
14926        let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
14927        mode.anchor_x = nx.floor();
14928        mode.anchor_y = ny.floor();
14929    }
14930
14931    pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
14932        if !self.state.is_alive() {
14933            anyhow::bail!("you are dead");
14934        }
14935        let Some(mode) = self.state.claim_mode.clone() else {
14936            anyhow::bail!("not in claim mode");
14937        };
14938        let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
14939            self.state.claim_quote()
14940        else {
14941            anyhow::bail!("cannot quote claim");
14942        };
14943        if !valid {
14944            anyhow::bail!(reason);
14945        }
14946        if !can_afford {
14947            anyhow::bail!(
14948                "not enough copper (need {})",
14949                crate::currency::format_copper(purchase)
14950            );
14951        }
14952        let (x0, y0, x1, y1) = self
14953            .state
14954            .claim_footprint_rect()
14955            .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
14956        let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
14957        self.seq += 1;
14958        self.session
14959            .submit_intent(Intent::BuyPlot {
14960                entity_id: self.state.entity_id,
14961                zone_id: mode.zone_id,
14962                x0,
14963                y0,
14964                x1,
14965                y1,
14966                seq: self.seq,
14967            })
14968            .await?;
14969        self.state.intents_sent += 1;
14970        self.state.claim_mode = None;
14971        self.state.push_log(format!(
14972            "Buying plot for {}",
14973            crate::currency::format_copper(purchase)
14974        ));
14975        Ok(())
14976    }
14977
14978    pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
14979        if !self.state.is_alive() {
14980            anyhow::bail!("you are dead");
14981        }
14982        let zone_id = self
14983            .state
14984            .claim_mode
14985            .as_ref()
14986            .map(|m| m.zone_id.clone())
14987            .or_else(|| {
14988                self.state
14989                    .free_property_zone_under_player()
14990                    .map(|z| z.id.clone())
14991            })
14992            .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
14993        self.seq += 1;
14994        self.session
14995            .submit_intent(Intent::BuyPlotAllFree {
14996                entity_id: self.state.entity_id,
14997                zone_id,
14998                seq: self.seq,
14999            })
15000            .await?;
15001        self.state.intents_sent += 1;
15002        self.state.claim_mode = None;
15003        self.state.push_log("Claiming largest free plot…");
15004        Ok(())
15005    }
15006
15007    pub async fn confirm_sell_plot_to_crown(&mut self, plot_id: uuid::Uuid) -> anyhow::Result<()> {
15008        if !self.state.is_alive() {
15009            anyhow::bail!("you are dead");
15010        }
15011        self.seq += 1;
15012        self.session
15013            .submit_intent(Intent::SellPlotToCrown {
15014                entity_id: self.state.entity_id,
15015                plot_id,
15016                seq: self.seq,
15017            })
15018            .await?;
15019        self.state.intents_sent += 1;
15020        self.state.sell_plot_confirm = None;
15021        self.state.sell_plot_armed_at = None;
15022        self.state.push_log("Selling plot to the crown…");
15023        Ok(())
15024    }
15025
15026    pub async fn set_plot_farm_public(
15027        &mut self,
15028        plot_id: uuid::Uuid,
15029        public: bool,
15030        public_tax_discount_bps: u32,
15031    ) -> anyhow::Result<()> {
15032        self.seq += 1;
15033        self.session
15034            .submit_intent(Intent::SetPlotFarmPublic {
15035                entity_id: self.state.entity_id,
15036                plot_id,
15037                public,
15038                public_tax_discount_bps,
15039                seq: self.seq,
15040            })
15041            .await?;
15042        self.state.intents_sent += 1;
15043        Ok(())
15044    }
15045
15046    pub async fn plot_farm_allow_upsert(
15047        &mut self,
15048        plot_id: uuid::Uuid,
15049        character_id: Option<uuid::Uuid>,
15050        character_name: String,
15051        tax_discount_bps: u32,
15052    ) -> anyhow::Result<()> {
15053        self.seq += 1;
15054        self.session
15055            .submit_intent(Intent::PlotFarmAllowUpsert {
15056                entity_id: self.state.entity_id,
15057                plot_id,
15058                character_id,
15059                character_name,
15060                tax_discount_bps,
15061                seq: self.seq,
15062            })
15063            .await?;
15064        self.state.intents_sent += 1;
15065        Ok(())
15066    }
15067
15068    pub async fn plot_farm_allow_remove(
15069        &mut self,
15070        plot_id: uuid::Uuid,
15071        character_id: uuid::Uuid,
15072    ) -> anyhow::Result<()> {
15073        self.seq += 1;
15074        self.session
15075            .submit_intent(Intent::PlotFarmAllowRemove {
15076                entity_id: self.state.entity_id,
15077                plot_id,
15078                character_id,
15079                seq: self.seq,
15080            })
15081            .await?;
15082        self.state.intents_sent += 1;
15083        Ok(())
15084    }
15085
15086    pub fn open_farm_access_panel(&mut self) {
15087        let Some(plot) = self.state.my_plot_under_player() else {
15088            self.state
15089                .push_log("Stand on your deed plot to manage farm access");
15090            return;
15091        };
15092        self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
15093        self.state.farm_access_index = 0;
15094        self.state.show_farm_access = true;
15095    }
15096
15097    pub fn close_farm_access_panel(&mut self) {
15098        self.state.show_farm_access = false;
15099        self.state.farm_access_name_draft.clear();
15100        self.state.farm_access_index = 0;
15101    }
15102
15103    pub fn farm_access_move(&mut self, delta: i32) {
15104        let n = self.farm_access_row_count().max(1);
15105        let idx = self.state.farm_access_index as i32 + delta;
15106        self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
15107    }
15108
15109    pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
15110        let Some(plot) = self.state.my_plot_under_player() else {
15111            return vec![FarmAccessRow::PublicToggle];
15112        };
15113        let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
15114        for g in &plot.farm_allow {
15115            rows.push(FarmAccessRow::AllowRemove {
15116                character_id: g.character_id,
15117                label: if g.character_label.trim().is_empty() {
15118                    g.character_id.to_string()[..8].to_string()
15119                } else {
15120                    g.character_label.clone()
15121                },
15122                tax_discount_bps: g.tax_discount_bps,
15123            });
15124        }
15125        for e in &self.state.entities {
15126            if e.id == self.state.entity_id || e.label.trim().is_empty() {
15127                continue;
15128            }
15129            if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
15130                continue;
15131            }
15132            if self
15133                .state
15134                .npcs
15135                .iter()
15136                .any(|n| n.id == e.label || n.label == e.label)
15137            {
15138                continue;
15139            }
15140            if plot
15141                .farm_allow
15142                .iter()
15143                .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
15144            {
15145                continue;
15146            }
15147            rows.push(FarmAccessRow::NearbyAdd {
15148                name: e.label.clone(),
15149            });
15150        }
15151        rows
15152    }
15153
15154    pub fn farm_access_row_count(&self) -> usize {
15155        self.farm_access_rows().len().max(1)
15156    }
15157
15158    pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
15159        let Some(plot) = self.state.my_plot_under_player().cloned() else {
15160            self.close_farm_access_panel();
15161            return Ok(());
15162        };
15163        let rows = self.farm_access_rows();
15164        let Some(row) = rows.get(self.state.farm_access_index) else {
15165            return Ok(());
15166        };
15167        match row {
15168            FarmAccessRow::PublicToggle => {
15169                self.set_plot_farm_public(
15170                    plot.plot_id,
15171                    !plot.farm_public,
15172                    plot.public_tax_discount_bps,
15173                )
15174                .await
15175            }
15176            FarmAccessRow::PublicDiscount => Ok(()),
15177            FarmAccessRow::AllowRemove { character_id, .. } => {
15178                self.plot_farm_allow_remove(plot.plot_id, *character_id)
15179                    .await
15180            }
15181            FarmAccessRow::NearbyAdd { name } => {
15182                let disc = self
15183                    .state
15184                    .farm_access_discount_bps
15185                    .max(plot.public_tax_discount_bps);
15186                self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
15187                    .await
15188            }
15189        }
15190    }
15191
15192    pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
15193        let Some(plot) = self.state.my_plot_under_player().cloned() else {
15194            return Ok(());
15195        };
15196        let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
15197        self.state.farm_access_discount_bps = next;
15198        self.state.farm_access_index = 1;
15199        self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
15200            .await
15201    }
15202
15203    /// Till the cell under you on a farmable plot (`c`).
15204    pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
15205        if self.state.farmable_plot_under_player().is_none() {
15206            anyhow::bail!("stand on a farmable plot to cultivate");
15207        }
15208        let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
15209            let (px, py) = self.state.player_position();
15210            if self
15211                .state
15212                .terrain_at(px, py)
15213                .is_some_and(|k| k == TerrainKindView::Tilled)
15214            {
15215                anyhow::bail!("already tilled — stand on bare soil and press c");
15216            }
15217            anyhow::bail!("cannot till this cell — move onto soil on your plot");
15218        };
15219        self.cultivate_at(tx, ty).await
15220    }
15221
15222    /// Plant seeds on empty tilled soil under you (`p`).
15223    pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
15224        if self.state.farmable_plot_under_player().is_none() {
15225            anyhow::bail!("stand on a farmable plot to plant");
15226        }
15227        if !self.state.underfoot_free_tilled_plant_slot() {
15228            anyhow::bail!("stand on empty tilled soil and press p");
15229        }
15230        let seeds = self.state.farm_seed_entries();
15231        if seeds.is_empty() {
15232            anyhow::bail!("no seeds in inventory — buy seeds from Eli");
15233        }
15234        if seeds.len() == 1 {
15235            return self.plant_seeds(seeds[0].0.clone(), 1).await;
15236        }
15237        self.open_plant_menu();
15238        Ok(())
15239    }
15240
15241    /// Open the wall/roof build picker on the owned plot underfoot (`B`).
15242    pub fn open_plot_build_menu(&mut self) -> anyhow::Result<()> {
15243        let Some(plot) = self.state.my_plot_under_player() else {
15244            anyhow::bail!("stand on your plot to build");
15245        };
15246        if plot.building_id.is_some() {
15247            anyhow::bail!("this plot already has a building");
15248        }
15249        let building_now = self
15250            .state
15251            .timed_channel
15252            .as_ref()
15253            .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
15254        if !building_now && self.state.building_materials.is_empty() {
15255            anyhow::bail!("no building materials loaded — wait a moment and try again");
15256        }
15257        self.state.show_plot_build_menu = true;
15258        self.state.show_craft_menu = false;
15259        self.state.show_shop_menu = false;
15260        self.state.shop_catalog = None;
15261        self.state.show_stats = false;
15262        self.state.show_inventory_menu = false;
15263        self.state.plot_build_focus_wall = true;
15264        let walls = self.state.plot_build_wall_options().len();
15265        let roofs = self.state.plot_build_roof_options().len();
15266        if walls > 0 {
15267            self.state.plot_build_wall_index = self.state.plot_build_wall_index.min(walls - 1);
15268        } else {
15269            self.state.plot_build_wall_index = 0;
15270        }
15271        if roofs > 0 {
15272            self.state.plot_build_roof_index = self.state.plot_build_roof_index.min(roofs - 1);
15273        } else {
15274            self.state.plot_build_roof_index = 0;
15275        }
15276        Ok(())
15277    }
15278
15279    pub fn close_plot_build_menu(&mut self) {
15280        self.state.show_plot_build_menu = false;
15281    }
15282
15283    pub fn plot_build_menu_move(&mut self, delta: i32) {
15284        let walls = self.state.plot_build_wall_options();
15285        let roofs = self.state.plot_build_roof_options();
15286        if self.state.plot_build_focus_wall {
15287            if walls.is_empty() {
15288                return;
15289            }
15290            let n = walls.len() as i32;
15291            let cur = self.state.plot_build_wall_index as i32;
15292            self.state.plot_build_wall_index = ((cur + delta).rem_euclid(n)) as usize;
15293        } else {
15294            if roofs.is_empty() {
15295                return;
15296            }
15297            let n = roofs.len() as i32;
15298            let cur = self.state.plot_build_roof_index as i32;
15299            self.state.plot_build_roof_index = ((cur + delta).rem_euclid(n)) as usize;
15300        }
15301    }
15302
15303    pub fn plot_build_menu_toggle_focus(&mut self) {
15304        self.state.plot_build_focus_wall = !self.state.plot_build_focus_wall;
15305    }
15306
15307    /// Confirm selection from the B menu.
15308    pub async fn plot_build_menu_confirm(&mut self) -> anyhow::Result<()> {
15309        let wall = self
15310            .state
15311            .plot_build_selected_wall()
15312            .ok_or_else(|| anyhow::anyhow!("pick a wall material"))?
15313            .id
15314            .clone();
15315        let roof = self
15316            .state
15317            .plot_build_selected_roof()
15318            .ok_or_else(|| anyhow::anyhow!("pick a roof material"))?
15319            .id
15320            .clone();
15321        // Keep the B menu open so it switches to the in-progress panel.
15322        self.start_plot_build(&wall, &roof).await
15323    }
15324
15325    /// Cancel an in-progress plot build (materials returned).
15326    pub async fn plot_build_menu_cancel_build(&mut self) -> anyhow::Result<()> {
15327        self.seq += 1;
15328        self.session
15329            .submit_intent(Intent::CancelPlotBuild {
15330                entity_id: self.state.entity_id,
15331                seq: self.seq,
15332            })
15333            .await?;
15334        self.state.intents_sent += 1;
15335        Ok(())
15336    }
15337
15338    /// Start timed shell craft on the plot's solid tilled rectangle.
15339    pub async fn start_plot_build(
15340        &mut self,
15341        wall_material_id: &str,
15342        roof_material_id: &str,
15343    ) -> anyhow::Result<()> {
15344        let Some(plot) = self.state.my_plot_under_player() else {
15345            anyhow::bail!("stand on your plot to build");
15346        };
15347        if plot.building_id.is_some() {
15348            anyhow::bail!("this plot already has a building");
15349        }
15350        let plot_id = plot.plot_id;
15351        self.seq += 1;
15352        self.session
15353            .submit_intent(Intent::StartPlotBuild {
15354                entity_id: self.state.entity_id,
15355                plot_id,
15356                wall_material_id: wall_material_id.to_string(),
15357                roof_material_id: roof_material_id.to_string(),
15358                seq: self.seq,
15359            })
15360            .await?;
15361        self.state.intents_sent += 1;
15362        Ok(())
15363    }
15364
15365    /// Lock/unlock nearest exterior door when you hold its key (`l` / `L`).
15366    pub async fn toggle_nearby_door_lock(&mut self) -> anyhow::Result<()> {
15367        let (px, py) = self.state.player_position();
15368        let mut best: Option<(f32, String, bool)> = None;
15369        for d in &self.state.doors {
15370            if d.lock_id.is_none() {
15371                continue;
15372            }
15373            let dist = (d.x - px).hypot(d.y - py);
15374            if dist > DOOR_INTERACTION_RADIUS_M {
15375                continue;
15376            }
15377            if best.as_ref().is_none_or(|(bd, _, _)| dist < *bd) {
15378                best = Some((dist, d.id.clone(), d.locked));
15379            }
15380        }
15381        let Some((_, door_id, locked_now)) = best else {
15382            anyhow::bail!("no lockable door nearby");
15383        };
15384        let locked = !locked_now;
15385        self.seq += 1;
15386        self.session
15387            .submit_intent(Intent::SetDoorLocked {
15388                entity_id: self.state.entity_id,
15389                door_id,
15390                locked,
15391                seq: self.seq,
15392            })
15393            .await?;
15394        self.state.intents_sent += 1;
15395        Ok(())
15396    }
15397
15398    /// Enter through a nearby open player-building door (`Enter`).
15399    pub async fn enter_nearby_open_door(&mut self) -> anyhow::Result<()> {
15400        if !self.state.is_alive() {
15401            anyhow::bail!("you are dead");
15402        }
15403        if self.state.effective_inside_building().is_some() {
15404            anyhow::bail!("already inside");
15405        }
15406        let (px, py) = self.state.player_position();
15407        let mut best: Option<(f32, String)> = None;
15408        for d in &self.state.doors {
15409            if !d.open || d.locked {
15410                continue;
15411            }
15412            let player_house = self
15413                .state
15414                .buildings
15415                .iter()
15416                .find(|b| b.id == d.building_id)
15417                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15418            if !player_house {
15419                continue;
15420            }
15421            let dist = (d.x - px).hypot(d.y - py);
15422            if dist > DOOR_INTERACTION_RADIUS_M {
15423                continue;
15424            }
15425            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15426                best = Some((dist, d.id.clone()));
15427            }
15428        }
15429        let Some((_, door_id)) = best else {
15430            anyhow::bail!("no open house door nearby — open with f first");
15431        };
15432        self.seq += 1;
15433        self.session
15434            .submit_intent(Intent::EnterBuildingDoor {
15435                entity_id: self.state.entity_id,
15436                door_id,
15437                seq: self.seq,
15438            })
15439            .await?;
15440        self.state.intents_sent += 1;
15441        Ok(())
15442    }
15443
15444    /// Exit through a nearby exterior portal from inside a player house (`Enter`).
15445    /// Works even when the door is closed or locked.
15446    pub async fn exit_nearby_building_door(&mut self) -> anyhow::Result<()> {
15447        if !self.state.is_alive() {
15448            anyhow::bail!("you are dead");
15449        }
15450        let Some(bid) = self.state.effective_inside_building() else {
15451            anyhow::bail!("not inside a building");
15452        };
15453        let (px, py) = self.state.player_position();
15454        let mut best: Option<(f32, String)> = None;
15455        for d in &self.state.doors {
15456            if d.building_id != bid || d.portal.is_none() {
15457                continue;
15458            }
15459            let player_house = self
15460                .state
15461                .buildings
15462                .iter()
15463                .find(|b| b.id == d.building_id)
15464                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15465            if !player_house {
15466                continue;
15467            }
15468            let dist = (d.x - px).hypot(d.y - py);
15469            if dist > 1.5 {
15470                continue;
15471            }
15472            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15473                best = Some((dist, d.id.clone()));
15474            }
15475        }
15476        let Some((_, door_id)) = best else {
15477            anyhow::bail!("stand by the door to exit");
15478        };
15479        self.seq += 1;
15480        self.session
15481            .submit_intent(Intent::ExitBuildingDoor {
15482                entity_id: self.state.entity_id,
15483                door_id,
15484                seq: self.seq,
15485            })
15486            .await?;
15487        self.state.intents_sent += 1;
15488        Ok(())
15489    }
15490
15491    /// Confirm interior layout for your player building (copper charged on server).
15492    pub async fn confirm_interior_edit(
15493        &mut self,
15494        building_id: String,
15495        rooms: Vec<flatland_protocol::InteriorRoomEdit>,
15496        room_doors: Vec<flatland_protocol::InteriorRoomDoorEdit>,
15497    ) -> anyhow::Result<()> {
15498        self.seq += 1;
15499        self.session
15500            .submit_intent(Intent::ConfirmInteriorEdit {
15501                entity_id: self.state.entity_id,
15502                building_id,
15503                rooms,
15504                room_doors,
15505                seq: self.seq,
15506            })
15507            .await?;
15508        self.state.intents_sent += 1;
15509        Ok(())
15510    }
15511
15512    pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
15513        if !self.state.is_alive() {
15514            anyhow::bail!("you are dead");
15515        }
15516        self.seq += 1;
15517        self.session
15518            .submit_intent(Intent::Cultivate {
15519                entity_id: self.state.entity_id,
15520                x,
15521                y,
15522                seq: self.seq,
15523            })
15524            .await?;
15525        self.state.intents_sent += 1;
15526        Ok(())
15527    }
15528
15529    pub async fn plant_seeds(
15530        &mut self,
15531        seed_template_id: String,
15532        quantity: u32,
15533    ) -> anyhow::Result<()> {
15534        if !self.state.is_alive() {
15535            anyhow::bail!("you are dead");
15536        }
15537        self.seq += 1;
15538        self.session
15539            .submit_intent(Intent::PlantSeeds {
15540                entity_id: self.state.entity_id,
15541                seed_template_id: seed_template_id.clone(),
15542                quantity,
15543                seq: self.seq,
15544            })
15545            .await?;
15546        self.state.intents_sent += 1;
15547        self.state
15548            .push_log(format!("Planting {quantity}× {seed_template_id}…"));
15549        Ok(())
15550    }
15551
15552    pub fn open_plant_menu(&mut self) {
15553        if self.state.farm_seed_entries().is_empty() {
15554            self.state.push_log("No seeds in inventory to plant");
15555            return;
15556        }
15557        self.state.show_plant_menu = true;
15558        self.state.plant_menu_index = 0;
15559        self.state.plant_quantity = 1;
15560        self.state.clamp_plant_menu();
15561    }
15562
15563    pub fn close_plant_menu(&mut self) {
15564        self.state.show_plant_menu = false;
15565    }
15566
15567    pub fn plant_menu_move(&mut self, delta: i32) {
15568        let n = self.state.farm_seed_entries().len();
15569        if n == 0 {
15570            return;
15571        }
15572        let idx = self.state.plant_menu_index as i32 + delta;
15573        self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
15574        self.state.clamp_plant_menu();
15575    }
15576
15577    pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
15578        let next = self.state.plant_quantity as i32 + delta;
15579        self.state.plant_quantity = next.max(1) as u32;
15580        self.state.clamp_plant_menu();
15581    }
15582
15583    pub fn plant_menu_set_quantity_max(&mut self) {
15584        if let Some((_, max, _)) = self.state.plant_menu_selection() {
15585            self.state.plant_quantity = max;
15586        }
15587        self.state.clamp_plant_menu();
15588    }
15589
15590    pub fn plant_menu_set_quantity_min(&mut self) {
15591        self.state.plant_quantity = 1;
15592        self.state.clamp_plant_menu();
15593    }
15594
15595    pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
15596        let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
15597            self.close_plant_menu();
15598            anyhow::bail!("no seeds to plant");
15599        };
15600        self.close_plant_menu();
15601        self.plant_seeds(seed, qty).await?;
15602        self.state.push_log(format!("Planted {qty}× {label}"));
15603        Ok(())
15604    }
15605
15606    /// Activate the ability or consumable bound to hotbar slot `1`–`9`.
15607    /// Heals prefer T2/self; other abilities prefer T1. Consumables need no target.
15608    pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
15609        if !self.state.is_alive() {
15610            anyhow::bail!("you are dead");
15611        }
15612        let binding = self
15613            .state
15614            .hotbar_ability(slot)
15615            .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
15616            .to_string();
15617        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
15618            let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
15619            if qty == 0 {
15620                anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
15621            }
15622            return self.use_item(template_id).await;
15623        }
15624        let ability_id = binding;
15625        if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
15626            return self
15627                .cast_ability(&ability_id, Some(self.state.entity_id))
15628                .await;
15629        }
15630        let is_heal = ability_id == "heal_touch"
15631            || self
15632                .state
15633                .ability_meta
15634                .get(&ability_id)
15635                .map(|meta| meta.is_heal)
15636                .unwrap_or(false);
15637        let target = if is_heal {
15638            Some(
15639                self.state
15640                    .target_for_slot(2)
15641                    .unwrap_or(self.state.entity_id),
15642            )
15643        } else {
15644            self.state
15645                .target_for_slot(1)
15646                .or_else(|| self.state.target_for_slot(2))
15647        };
15648        let Some(target_id) = target else {
15649            anyhow::bail!("no target — Tab to select, then press the hotbar key");
15650        };
15651        self.cast_ability(&ability_id, Some(target_id)).await
15652    }
15653
15654    /// Bind or clear a hotbar slot (`1`–`9`) via [`Intent::SetHotbarSlot`].
15655    /// `ability_id` may be a learned ability or `item:<template_id>` for consumables.
15656    pub async fn set_hotbar_slot(
15657        &mut self,
15658        slot: u8,
15659        ability_id: Option<&str>,
15660    ) -> anyhow::Result<()> {
15661        if !self.state.is_alive() {
15662            anyhow::bail!("you are dead");
15663        }
15664        if !(1..=9).contains(&slot) {
15665            anyhow::bail!("hotbar slot must be 1–9");
15666        }
15667        let ability_id = ability_id
15668            .map(str::trim)
15669            .filter(|id| !id.is_empty())
15670            .map(str::to_string);
15671        self.seq += 1;
15672        self.session
15673            .submit_intent(Intent::SetHotbarSlot {
15674                entity_id: self.state.entity_id,
15675                slot,
15676                ability_id: ability_id.clone(),
15677                seq: self.seq,
15678            })
15679            .await?;
15680        self.state.intents_sent += 1;
15681        let idx = (slot - 1) as usize;
15682        if self.state.hotbar.len() < 9 {
15683            self.state.hotbar.resize(9, None);
15684        }
15685        if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
15686            *slot_mut = ability_id.clone();
15687        }
15688        match ability_id {
15689            Some(id) => {
15690                let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
15691                    format!("use {tid}")
15692                } else {
15693                    id
15694                };
15695                self.state.push_log(format!("Hotbar {slot} → {label}"))
15696            }
15697            None => self.state.push_log(format!("Hotbar {slot} cleared")),
15698        }
15699        Ok(())
15700    }
15701
15702    pub fn npc_verb_options(&self) -> Vec<NpcVerbChoice> {
15703        self.state.npc_verb_options()
15704    }
15705
15706    pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
15707        let Some(npc_id) = self.state.npc_verb_target.clone() else {
15708            return Ok(());
15709        };
15710        let options = self.npc_verb_options();
15711        let choice = options
15712            .get(self.state.npc_verb_index)
15713            .cloned()
15714            .unwrap_or_else(GameState::talk_choice);
15715        match choice.action {
15716            NpcVerbAction::QuestGive { quest_id } => {
15717                self.submit_npc_quest_turn_in(&npc_id, Some(&quest_id))
15718                    .await?;
15719                self.state.show_npc_verb_menu = false;
15720            }
15721            NpcVerbAction::Talk => {
15722                self.open_npc_talk(&npc_id, None).await?;
15723            }
15724            NpcVerbAction::QuestTalk { quest_id } => {
15725                self.open_npc_talk(&npc_id, Some(&quest_id)).await?;
15726            }
15727            NpcVerbAction::Trade
15728            | NpcVerbAction::Bank
15729            | NpcVerbAction::Storage
15730            | NpcVerbAction::Market => {
15731                self.seq += 1;
15732                self.session
15733                    .submit_intent(Intent::Interact {
15734                        entity_id: self.state.entity_id,
15735                        target_id: npc_id,
15736                        seq: self.seq,
15737                    })
15738                    .await?;
15739                self.state.intents_sent += 1;
15740            }
15741        }
15742        Ok(())
15743    }
15744
15745    async fn open_npc_talk(&mut self, npc_id: &str, quest_id: Option<&str>) -> anyhow::Result<()> {
15746        self.seq += 1;
15747        self.session
15748            .submit_intent(Intent::NpcTalkOpen {
15749                entity_id: self.state.entity_id,
15750                npc_id: npc_id.to_string(),
15751                quest_id: quest_id.map(str::to_string),
15752                seq: self.seq,
15753            })
15754            .await?;
15755        self.state.intents_sent += 1;
15756        Ok(())
15757    }
15758
15759    async fn submit_npc_quest_turn_in(
15760        &mut self,
15761        npc_id: &str,
15762        quest_id: Option<&str>,
15763    ) -> anyhow::Result<()> {
15764        let catalog_ref = self.state.npc_quest_catalog_ref(npc_id);
15765        let pending: Vec<(String, u32, String)> = self
15766            .state
15767            .quest_log
15768            .iter()
15769            .filter(|q| {
15770                q.status == flatland_protocol::QuestStatusView::Active
15771                    && quest_id.is_none_or(|id| q.quest_id == id)
15772            })
15773            .flat_map(|q| q.objectives.iter())
15774            .filter(|o| {
15775                !o.done
15776                    && o.kind == "give_item"
15777                    && o.npc_ref.as_deref() == Some(catalog_ref.as_str())
15778            })
15779            .filter_map(|o| {
15780                let template = o.item_template.clone()?;
15781                let remaining = o.required.saturating_sub(o.current);
15782                if remaining == 0 {
15783                    return None;
15784                }
15785                Some((template, remaining, o.label.clone()))
15786            })
15787            .collect();
15788        if pending.is_empty() {
15789            self.state.push_log("Nothing to turn in here.");
15790            return Ok(());
15791        }
15792        let mut sent = 0u32;
15793        for (template, remaining, label) in pending {
15794            let held = self.state.count_inventory_template(&template);
15795            let qty = remaining.min(held);
15796            if qty == 0 {
15797                self.state.push_log(format!("Need {label}"));
15798                continue;
15799            }
15800            self.seq += 1;
15801            self.session
15802                .submit_intent(Intent::QuestGiveItem {
15803                    entity_id: self.state.entity_id,
15804                    npc_id: npc_id.to_string(),
15805                    template_id: template,
15806                    quantity: qty,
15807                    seq: self.seq,
15808                })
15809                .await?;
15810            self.state.intents_sent += 1;
15811            sent += 1;
15812        }
15813        if sent > 0 {
15814            self.state.push_log("Turning in quest items.");
15815        }
15816        Ok(())
15817    }
15818
15819    pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
15820        let Some(chat) = self.state.npc_chat.clone() else {
15821            return Ok(());
15822        };
15823        let message = chat.input.trim().to_string();
15824        if message.is_empty() || chat.pending {
15825            return Ok(());
15826        }
15827        if let Some(c) = self.state.npc_chat.as_mut() {
15828            c.lines.push(format!("You: {message}"));
15829            c.input.clear();
15830            c.pending = true;
15831        }
15832        self.seq += 1;
15833        self.session
15834            .submit_intent(Intent::NpcTalkSay {
15835                entity_id: self.state.entity_id,
15836                npc_id: chat.npc_id,
15837                message,
15838                seq: self.seq,
15839            })
15840            .await?;
15841        self.state.intents_sent += 1;
15842        Ok(())
15843    }
15844
15845    pub async fn npc_talk_topic(&mut self, index: usize) -> anyhow::Result<()> {
15846        let topic = self
15847            .state
15848            .npc_chat
15849            .as_ref()
15850            .and_then(|c| c.suggested_topics.get(index))
15851            .cloned();
15852        let Some(topic) = topic else {
15853            return Ok(());
15854        };
15855        if let Some(c) = self.state.npc_chat.as_mut() {
15856            if c.pending {
15857                return Ok(());
15858            }
15859            c.input = topic;
15860        }
15861        self.npc_talk_send().await
15862    }
15863
15864    pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
15865        let return_to_verbs = self.state.npc_verb_target.is_some();
15866        let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
15867            self.state.show_npc_chat = false;
15868            if return_to_verbs {
15869                self.state.show_npc_verb_menu = true;
15870            }
15871            return Ok(());
15872        };
15873        self.seq += 1;
15874        self.session
15875            .submit_intent(Intent::NpcTalkClose {
15876                entity_id: self.state.entity_id,
15877                npc_id,
15878                seq: self.seq,
15879            })
15880            .await?;
15881        self.state.intents_sent += 1;
15882        self.state.show_npc_chat = false;
15883        self.state.npc_chat = None;
15884        if return_to_verbs {
15885            self.state.show_npc_verb_menu = true;
15886            self.state.npc_verb_notice = None;
15887        }
15888        Ok(())
15889    }
15890
15891    /// Esc/back inside Talk, Trade, quest-offer, or the verb menu — pop one layer, not the whole session.
15892    pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
15893        if self.state.show_quest_offer
15894            && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
15895        {
15896            self.quest_offer_decline();
15897            return Ok(());
15898        }
15899        if self.state.show_npc_chat {
15900            return self.npc_talk_close().await;
15901        }
15902        if self.state.show_shop_menu {
15903            return self.back_from_shop_menu().await;
15904        }
15905        if self.state.bank_panel.is_some() {
15906            if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
15907                self.bank_transfer_back();
15908                return Ok(());
15909            }
15910            return self.close_bank_panel().await;
15911        }
15912        if self.state.storage_panel.is_some() {
15913            if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
15914                self.storage_ui_back();
15915                return Ok(());
15916            }
15917            return self.close_storage_panel().await;
15918        }
15919        if self.state.market_panel.is_some() {
15920            if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
15921                self.market_ui_back();
15922                return Ok(());
15923            }
15924            if self.state.market_buy_confirm.is_some() {
15925                self.state.market_buy_confirm = None;
15926                return Ok(());
15927            }
15928            return self.close_market_panel().await;
15929        }
15930        if self.state.show_npc_verb_menu {
15931            self.state.show_npc_verb_menu = false;
15932            self.state.npc_verb_target = None;
15933        }
15934        Ok(())
15935    }
15936
15937    pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
15938        self.seq += 1;
15939        self.session
15940            .submit_intent(Intent::TestDamage {
15941                entity_id: self.state.entity_id,
15942                amount,
15943                seq: self.seq,
15944            })
15945            .await?;
15946        self.state.intents_sent += 1;
15947        Ok(())
15948    }
15949
15950    pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
15951        self.cycle_combat_target_slot(1, reverse).await
15952    }
15953
15954    pub async fn cycle_combat_target_slot(
15955        &mut self,
15956        slot_index: u8,
15957        reverse: bool,
15958    ) -> anyhow::Result<()> {
15959        if !self.state.is_alive() {
15960            anyhow::bail!("you are dead");
15961        }
15962        let candidates = self.state.candidates_for_slot(slot_index);
15963        if candidates.is_empty() {
15964            anyhow::bail!("no targets nearby");
15965        }
15966        let current = self.state.target_for_slot(slot_index);
15967        let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
15968        let next_idx = match idx {
15969            None => 0,
15970            Some(i) if reverse => {
15971                if i == 0 {
15972                    candidates.len() - 1
15973                } else {
15974                    i - 1
15975                }
15976            }
15977            Some(i) => (i + 1) % candidates.len(),
15978        };
15979        if idx == Some(next_idx) && candidates.len() == 1 {
15980            self.clear_combat_target_slot(slot_index).await?;
15981            return Ok(());
15982        }
15983        let (target_id, label) = candidates[next_idx].clone();
15984        self.set_combat_target_slot(slot_index, target_id, &label)
15985            .await
15986    }
15987
15988    pub async fn set_combat_target_slot(
15989        &mut self,
15990        slot_index: u8,
15991        target_id: EntityId,
15992        label: &str,
15993    ) -> anyhow::Result<()> {
15994        if !self.state.is_alive() {
15995            anyhow::bail!("you are dead");
15996        }
15997        self.seq += 1;
15998        self.session
15999            .submit_intent(Intent::SetTargetSlot {
16000                entity_id: self.state.entity_id,
16001                slot_index,
16002                target_id,
16003                seq: self.seq,
16004            })
16005            .await?;
16006        self.state.intents_sent += 1;
16007        if slot_index == 1 {
16008            self.state.combat_target = Some(target_id);
16009            self.state.combat_target_label = Some(label.to_string());
16010        }
16011        self.state
16012            .push_log(format!("Slot {slot_index} target: {label}"));
16013        Ok(())
16014    }
16015
16016    pub async fn set_combat_target(
16017        &mut self,
16018        target_id: EntityId,
16019        label: &str,
16020    ) -> anyhow::Result<()> {
16021        self.set_combat_target_slot(1, target_id, label).await
16022    }
16023
16024    pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
16025        if slot_index == 1 && self.state.combat_target.is_none() {
16026            return Ok(());
16027        }
16028        self.seq += 1;
16029        self.session
16030            .submit_intent(Intent::ClearTargetSlot {
16031                entity_id: self.state.entity_id,
16032                slot_index,
16033                seq: self.seq,
16034            })
16035            .await?;
16036        if slot_index == 1 {
16037            self.state.combat_target = None;
16038            self.state.combat_target_label = None;
16039        }
16040        self.state.intents_sent += 1;
16041        self.state
16042            .push_log(format!("Slot {slot_index} target cleared"));
16043        Ok(())
16044    }
16045
16046    pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
16047        self.clear_combat_target_slot(1).await
16048    }
16049
16050    pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
16051        if !self.state.is_alive() {
16052            anyhow::bail!("you are dead");
16053        }
16054        self.seq += 1;
16055        self.session
16056            .submit_intent(Intent::AdvanceRotation {
16057                entity_id: self.state.entity_id,
16058                slot_index,
16059                seq: self.seq,
16060            })
16061            .await?;
16062        self.state.intents_sent += 1;
16063        Ok(())
16064    }
16065
16066    pub async fn assign_slot_preset(
16067        &mut self,
16068        slot_index: u8,
16069        preset_id: &str,
16070    ) -> anyhow::Result<()> {
16071        if !self.state.is_alive() {
16072            anyhow::bail!("you are dead");
16073        }
16074        self.seq += 1;
16075        self.session
16076            .submit_intent(Intent::AssignSlotPreset {
16077                entity_id: self.state.entity_id,
16078                slot_index,
16079                preset_id: preset_id.to_string(),
16080                seq: self.seq,
16081            })
16082            .await?;
16083        self.state.intents_sent += 1;
16084        if let Some(slot) = self
16085            .state
16086            .combat_slots
16087            .iter_mut()
16088            .find(|s| s.slot_index == slot_index)
16089        {
16090            slot.preset_id = Some(preset_id.to_string());
16091            if let Some(preset) = self
16092                .state
16093                .rotation_presets
16094                .iter()
16095                .find(|p| p.id == preset_id)
16096            {
16097                slot.preset_label = Some(preset.label.clone());
16098                slot.rotation = preset.abilities.clone();
16099                slot.rotation_index = 0;
16100            }
16101        }
16102        self.state
16103            .push_log(format!("T{slot_index} loadout → {preset_id}"));
16104        Ok(())
16105    }
16106
16107    pub async fn cast_ability(
16108        &mut self,
16109        ability_id: &str,
16110        target_id: Option<EntityId>,
16111    ) -> anyhow::Result<()> {
16112        if !self.state.is_alive() {
16113            anyhow::bail!("you are dead");
16114        }
16115        let allows_ground = self.state.ability_allows_ground(ability_id);
16116        let requires_ground = self.state.ability_requires_ground(ability_id);
16117        if requires_ground && self.state.ground_target.is_none() {
16118            anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
16119        }
16120        let (resolved_target_id, target_point) = if allows_ground {
16121            if let Some((x, y, z)) = self.state.ground_target {
16122                (
16123                    target_id.unwrap_or(self.state.entity_id),
16124                    Some(flatland_protocol::AimPoint { x, y, z }),
16125                )
16126            } else {
16127                (
16128                    target_id
16129                        .or_else(|| self.state.target_for_slot(2))
16130                        .or_else(|| self.state.target_for_slot(1))
16131                        .unwrap_or(self.state.entity_id),
16132                    None,
16133                )
16134            }
16135        } else {
16136            (
16137                target_id
16138                    .or_else(|| self.state.target_for_slot(2))
16139                    .or_else(|| self.state.target_for_slot(1))
16140                    .unwrap_or(self.state.entity_id),
16141                None,
16142            )
16143        };
16144        self.seq += 1;
16145        self.session
16146            .submit_intent(Intent::Cast {
16147                entity_id: self.state.entity_id,
16148                ability_id: ability_id.to_string(),
16149                target_id: resolved_target_id,
16150                target_point,
16151                seq: self.seq,
16152            })
16153            .await?;
16154        self.state.intents_sent += 1;
16155        match target_point {
16156            Some(point) => self.state.push_log(format!(
16157                "Cast {ability_id} → ({:.1}, {:.1})",
16158                point.x, point.y
16159            )),
16160            None => self
16161                .state
16162                .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
16163        }
16164        Ok(())
16165    }
16166
16167    pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
16168        self.seq += 1;
16169        self.session
16170            .submit_intent(Intent::UpsertRotationPreset {
16171                entity_id: self.state.entity_id,
16172                preset: preset.clone(),
16173                seq: self.seq,
16174            })
16175            .await?;
16176        self.state.intents_sent += 1;
16177        if let Some(existing) = self
16178            .state
16179            .rotation_presets
16180            .iter_mut()
16181            .find(|p| p.id == preset.id)
16182        {
16183            *existing = preset.clone();
16184        } else {
16185            self.state.rotation_presets.push(preset.clone());
16186        }
16187        for slot in &mut self.state.combat_slots {
16188            if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
16189                slot.preset_label = Some(preset.label.clone());
16190                slot.rotation = preset.abilities.clone();
16191            }
16192        }
16193        self.state
16194            .push_log(format!("Saved rotation: {}", preset.label));
16195        Ok(())
16196    }
16197
16198    pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
16199        self.seq += 1;
16200        self.session
16201            .submit_intent(Intent::DeleteRotationPreset {
16202                entity_id: self.state.entity_id,
16203                preset_id: preset_id.to_string(),
16204                seq: self.seq,
16205            })
16206            .await?;
16207        self.state.intents_sent += 1;
16208        self.state.rotation_presets.retain(|p| p.id != preset_id);
16209        for slot in &mut self.state.combat_slots {
16210            if slot.preset_id.as_deref() == Some(preset_id) {
16211                slot.preset_id = None;
16212                slot.preset_label = None;
16213                slot.rotation.clear();
16214                slot.rotation_index = 0;
16215            }
16216        }
16217        self.state
16218            .push_log(format!("Deleted rotation: {preset_id}"));
16219        Ok(())
16220    }
16221
16222    pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
16223        if !self.state.is_alive() {
16224            anyhow::bail!("you are dead");
16225        }
16226        let enabled = !self
16227            .state
16228            .combat_slots
16229            .iter()
16230            .find(|s| s.slot_index == slot_index)
16231            .map(|s| s.auto_enabled)
16232            .unwrap_or(false);
16233        self.seq += 1;
16234        self.session
16235            .submit_intent(Intent::SetAutoAttack {
16236                entity_id: self.state.entity_id,
16237                slot_index,
16238                enabled,
16239                seq: self.seq,
16240            })
16241            .await?;
16242        if slot_index == 1 {
16243            self.state.auto_attack = enabled;
16244        }
16245        self.state.intents_sent += 1;
16246        self.state.push_log(format!(
16247            "T{slot_index} auto {}",
16248            if enabled { "ON" } else { "OFF" }
16249        ));
16250        Ok(())
16251    }
16252
16253    pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
16254        if !self.state.connected {
16255            anyhow::bail!("not connected");
16256        }
16257        if !self.state.is_alive() {
16258            anyhow::bail!("you are dead");
16259        }
16260        let (px, py) = self.state.player_position();
16261        if self
16262            .state
16263            .ground_drops
16264            .iter()
16265            .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
16266        {
16267            anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
16268        }
16269        self.seq += 1;
16270        self.session
16271            .submit_intent(Intent::Pickup {
16272                entity_id: self.state.entity_id,
16273                drop_id: None,
16274                seq: self.seq,
16275            })
16276            .await?;
16277        self.state.intents_sent += 1;
16278        self.state.push_audio(crate::social::AudioCue::LootPickup);
16279        Ok(())
16280    }
16281
16282    pub async fn dodge(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16283        if !self.state.is_alive() {
16284            anyhow::bail!("you are dead");
16285        }
16286        // Only currently held WASD counts — sticky last_move would skip smart dodge after walking.
16287        self.seq += 1;
16288        self.session
16289            .submit_intent(Intent::Dodge {
16290                entity_id: self.state.entity_id,
16291                forward,
16292                strafe,
16293                seq: self.seq,
16294            })
16295            .await?;
16296        self.state.intents_sent += 1;
16297        self.state.push_log("Dodge!");
16298        self.state.push_audio(crate::social::AudioCue::CombatDodge);
16299        Ok(())
16300    }
16301
16302    pub async fn lunge(&mut self) -> anyhow::Result<()> {
16303        if !self.state.is_alive() {
16304            anyhow::bail!("you are dead");
16305        }
16306        let (forward, strafe) = self.last_move_axes();
16307        self.seq += 1;
16308        self.session
16309            .submit_intent(Intent::Lunge {
16310                entity_id: self.state.entity_id,
16311                forward,
16312                strafe,
16313                seq: self.seq,
16314            })
16315            .await?;
16316        self.state.intents_sent += 1;
16317        self.state.push_log("Lunge!");
16318        Ok(())
16319    }
16320
16321    pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16322        if !self.state.is_alive() {
16323            anyhow::bail!("you are dead");
16324        }
16325        self.seq += 1;
16326        self.session
16327            .submit_intent(Intent::DirectionalJump {
16328                entity_id: self.state.entity_id,
16329                forward,
16330                strafe,
16331                seq: self.seq,
16332            })
16333            .await?;
16334        self.state.intents_sent += 1;
16335        self.state.push_log("Jump!");
16336        Ok(())
16337    }
16338
16339    /// Remembered WASD axes for lunge when not currently moving.
16340    pub fn last_move_axes(&self) -> (f32, f32) {
16341        (self.last_move_forward, self.last_move_strafe)
16342    }
16343
16344    pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
16345        if !self.state.is_alive() {
16346            anyhow::bail!("you are dead");
16347        }
16348        self.seq += 1;
16349        self.session
16350            .submit_intent(Intent::Block {
16351                entity_id: self.state.entity_id,
16352                enabled,
16353                seq: self.seq,
16354            })
16355            .await?;
16356        self.state.intents_sent += 1;
16357        if enabled {
16358            self.state.push_log("Blocking");
16359            self.state.push_audio(crate::social::AudioCue::CombatBlock);
16360        }
16361        Ok(())
16362    }
16363
16364    pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
16365        if !self.state.is_alive() {
16366            anyhow::bail!("you are dead");
16367        }
16368        self.seq += 1;
16369        self.session
16370            .submit_intent(Intent::EquipMainhand {
16371                entity_id: self.state.entity_id,
16372                template_id,
16373                instance_id: None,
16374                seq: self.seq,
16375            })
16376            .await?;
16377        self.state.intents_sent += 1;
16378        Ok(())
16379    }
16380
16381    /// Equip paperdoll: activate selected slot (unequip filled, or equip first matching candidate).
16382    pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
16383        let idx = self.state.equip_menu_index;
16384        let slots = equip_paperdoll_rows(&self.state);
16385        let Some(row) = slots.get(idx) else {
16386            return Ok(());
16387        };
16388        match row {
16389            EquipPaperdollRow::Body { slot, filled } => {
16390                if *filled {
16391                    self.equip_worn(*slot, None).await
16392                } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
16393                    self.equip_worn(*slot, Some(inst)).await
16394                } else {
16395                    self.state
16396                        .push_log(format!("No item for {}", body_slot_label(*slot)));
16397                    Ok(())
16398                }
16399            }
16400            EquipPaperdollRow::Mainhand { filled } => {
16401                if *filled {
16402                    self.unequip_mainhand().await
16403                } else if let Some(tid) = first_inventory_weapon(&self.state) {
16404                    self.equip_mainhand(Some(tid)).await
16405                } else {
16406                    self.state.push_log("No weapon in inventory".to_string());
16407                    Ok(())
16408                }
16409            }
16410            EquipPaperdollRow::Offhand { filled, locked } => {
16411                if *locked {
16412                    self.state
16413                        .push_log("Offhand locked — two-handed weapon equipped".to_string());
16414                    Ok(())
16415                } else if *filled {
16416                    self.unequip_offhand().await
16417                } else if let Some(tid) = first_inventory_offhand(&self.state) {
16418                    self.equip_offhand(Some(tid)).await
16419                } else {
16420                    self.state
16421                        .push_log("No offhand item in inventory".to_string());
16422                    Ok(())
16423                }
16424            }
16425        }
16426    }
16427
16428    pub async fn say(
16429        &mut self,
16430        channel: flatland_protocol::ChatChannel,
16431        text: &str,
16432    ) -> anyhow::Result<()> {
16433        self.say_to(channel, text, None).await
16434    }
16435
16436    pub async fn say_to(
16437        &mut self,
16438        channel: flatland_protocol::ChatChannel,
16439        text: &str,
16440        to_entity: Option<EntityId>,
16441    ) -> anyhow::Result<()> {
16442        self.seq += 1;
16443        self.session
16444            .submit_intent(Intent::Say {
16445                entity_id: self.state.entity_id,
16446                channel,
16447                text: text.to_string(),
16448                to_entity,
16449                seq: self.seq,
16450            })
16451            .await?;
16452        self.state.intents_sent += 1;
16453        Ok(())
16454    }
16455
16456    pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
16457        let Some(peer) = self.state.player_verbs.target_entity else {
16458            return Ok(());
16459        };
16460        let label = self.state.player_verbs.target_label.clone();
16461        let choice = crate::social::PlayerVerbState::options()
16462            .get(self.state.player_verbs.index)
16463            .copied()
16464            .unwrap_or("Whisper");
16465        self.state.player_verbs.close();
16466        match choice {
16467            "Trade" => {
16468                // Only request — peer must accept (Y in chat). Do not auto-respond;
16469                // TradeRespond requires a pending inbound request from the peer.
16470                self.seq += 1;
16471                self.session
16472                    .submit_intent(Intent::TradeRequest {
16473                        entity_id: self.state.entity_id,
16474                        peer_entity_id: peer,
16475                        seq: self.seq,
16476                    })
16477                    .await?;
16478                self.state.intents_sent += 1;
16479                self.state.social_chat.push_system(format!(
16480                    "Trade request sent to {label} — waiting for accept"
16481                ));
16482            }
16483            "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
16484            _ => self.state.social_chat.focus_nearby(),
16485        }
16486        Ok(())
16487    }
16488
16489    pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
16490        let Some(pending) = self.state.social_chat.pending_trade.take() else {
16491            return Ok(());
16492        };
16493        self.seq += 1;
16494        self.session
16495            .submit_intent(Intent::TradeRespond {
16496                entity_id: self.state.entity_id,
16497                peer_entity_id: pending.from_entity,
16498                accept,
16499                seq: self.seq,
16500            })
16501            .await?;
16502        self.state.intents_sent += 1;
16503        if accept {
16504            self.state
16505                .social_chat
16506                .push_system(format!("Accepted trade with {}", pending.from_name));
16507        } else {
16508            self.state
16509                .social_chat
16510                .push_system(format!("Declined trade with {}", pending.from_name));
16511        }
16512        Ok(())
16513    }
16514
16515    pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
16516        let text = self.state.social_chat.buffer.trim().to_string();
16517        if text.is_empty() {
16518            return Ok(());
16519        }
16520        self.state.social_chat.buffer.clear();
16521        if crate::social::is_chat_slash_line(&text) {
16522            match crate::social::parse_chat_slash(&text) {
16523                Some(cmd) => return self.apply_chat_slash(cmd).await,
16524                None => {
16525                    self.state.social_chat.push_system(format!(
16526                        "Unknown command — {}",
16527                        crate::social::chat_slash_help_text()
16528                    ));
16529                    return Ok(());
16530                }
16531            }
16532        }
16533        let thread = self.state.social_chat.thread;
16534        let channel = thread.channel();
16535        let to = thread.to_entity();
16536        if let Some(peer) = to {
16537            let label = self.state.social_chat.peer_label.clone();
16538            self.state
16539                .social_chat
16540                .remember_whisper_peer(peer, &label, channel);
16541        }
16542        self.say_to(channel, &text, to).await
16543    }
16544
16545    async fn apply_chat_slash(
16546        &mut self,
16547        cmd: crate::social::ChatSlashCommand,
16548    ) -> anyhow::Result<()> {
16549        use crate::social::{chat_slash_help_text, ChatSlashCommand};
16550        match cmd {
16551            ChatSlashCommand::Help => {
16552                self.state
16553                    .social_chat
16554                    .push_system(chat_slash_help_text().to_string());
16555                Ok(())
16556            }
16557            ChatSlashCommand::Nearby { message } => {
16558                self.state.social_chat.focus_nearby();
16559                self.state
16560                    .social_chat
16561                    .push_system("Nearby speech — everyone close can hear");
16562                if let Some(msg) = message {
16563                    self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
16564                        .await
16565                } else {
16566                    Ok(())
16567                }
16568            }
16569            ChatSlashCommand::Reply { message } => {
16570                let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16571                    self.state
16572                        .social_chat
16573                        .push_system("No one to reply to — wait for a whisper, or /whisper Name");
16574                    return Ok(());
16575                };
16576                let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
16577                self.state
16578                    .social_chat
16579                    .set_whisper_thread(peer.entity_id, &peer.label, stone);
16580                self.state.social_chat.push_system(format!(
16581                    "Replying to {} — type and Enter · /nearby",
16582                    peer.label
16583                ));
16584                if let Some(msg) = message {
16585                    self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
16586                } else {
16587                    Ok(())
16588                }
16589            }
16590            ChatSlashCommand::Whisper { name, message } => {
16591                let (peer_id, label, stone) = if let Some(name) = name {
16592                    match self.resolve_whisper_target(&name) {
16593                        Ok(t) => t,
16594                        Err(err) => {
16595                            self.state.social_chat.push_system(err);
16596                            return Ok(());
16597                        }
16598                    }
16599                } else {
16600                    let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16601                        self.state.social_chat.push_system(
16602                            "Usage: /whisper Name [message] · or /reply after someone whispers you",
16603                        );
16604                        return Ok(());
16605                    };
16606                    (
16607                        peer.entity_id,
16608                        peer.label,
16609                        peer.channel == flatland_protocol::ChatChannel::WhisperStone,
16610                    )
16611                };
16612                self.state
16613                    .social_chat
16614                    .set_whisper_thread(peer_id, &label, stone);
16615                let channel = if stone {
16616                    flatland_protocol::ChatChannel::WhisperStone
16617                } else {
16618                    flatland_protocol::ChatChannel::Whisper
16619                };
16620                if let Some(msg) = message {
16621                    self.state
16622                        .social_chat
16623                        .push_system(format!("Whisper → {label}"));
16624                    self.say_to(channel, &msg, Some(peer_id)).await
16625                } else {
16626                    self.state.social_chat.push_system(format!(
16627                        "Whispering {label} — type and Enter · Esc / /nearby cancels"
16628                    ));
16629                    Ok(())
16630                }
16631            }
16632        }
16633    }
16634
16635    /// Resolve `/whisper Name` against AOI players (label match).
16636    fn resolve_whisper_target(&self, name: &str) -> Result<(EntityId, String, bool), String> {
16637        let needle = name.trim().to_ascii_lowercase();
16638        if needle.is_empty() {
16639            return Err("Usage: /whisper Name [message]".into());
16640        }
16641        let mut candidates: Vec<(EntityId, String)> = self
16642            .state
16643            .entities
16644            .iter()
16645            .filter(|e| e.id != self.state.entity_id)
16646            .filter(|e| !e.label.trim().is_empty())
16647            .filter(|e| e.vitals.is_some())
16648            .filter(|e| !self.state.npcs.iter().any(|n| n.id == e.id.to_string()))
16649            .filter(|e| !self.state.hired_workers.iter().any(|w| w.entity_id == e.id))
16650            .map(|e| (e.id, e.label.clone()))
16651            .collect();
16652
16653        // Also allow matching the last whisper peer by name even if they left AOI briefly.
16654        if let Some(last) = &self.state.social_chat.last_whisper_peer {
16655            if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
16656                candidates.push((last.entity_id, last.label.clone()));
16657            }
16658        }
16659
16660        let exact: Vec<_> = candidates
16661            .iter()
16662            .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
16663            .cloned()
16664            .collect();
16665        let pool = if exact.len() == 1 {
16666            exact
16667        } else if exact.len() > 1 {
16668            return Err(format!(
16669                "Several players named '{name}' nearby — move closer and try again"
16670            ));
16671        } else {
16672            let starts: Vec<_> = candidates
16673                .iter()
16674                .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
16675                .cloned()
16676                .collect();
16677            if starts.len() == 1 {
16678                starts
16679            } else if starts.len() > 1 {
16680                let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
16681                return Err(format!(
16682                    "Ambiguous name '{name}' — matches: {}",
16683                    names.join(", ")
16684                ));
16685            } else {
16686                let contains: Vec<_> = candidates
16687                    .iter()
16688                    .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
16689                    .cloned()
16690                    .collect();
16691                if contains.len() == 1 {
16692                    contains
16693                } else if contains.is_empty() {
16694                    return Err(format!(
16695                        "No player matching '{name}' in range — get closer or check the spelling"
16696                    ));
16697                } else {
16698                    let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
16699                    return Err(format!(
16700                        "Ambiguous name '{name}' — matches: {}",
16701                        names.join(", ")
16702                    ));
16703                }
16704            }
16705        };
16706
16707        let (id, label) = pool.into_iter().next().unwrap();
16708        let stone = self
16709            .state
16710            .social_chat
16711            .last_whisper_peer
16712            .as_ref()
16713            .is_some_and(|p| {
16714                p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone
16715            });
16716        Ok((id, label, stone))
16717    }
16718
16719    pub async fn trade_present_selected(
16720        &mut self,
16721        item_instance_id: uuid::Uuid,
16722    ) -> anyhow::Result<()> {
16723        self.trade_present_quantity(item_instance_id, None).await
16724    }
16725
16726    pub async fn trade_present_quantity(
16727        &mut self,
16728        item_instance_id: uuid::Uuid,
16729        quantity: Option<u32>,
16730    ) -> anyhow::Result<()> {
16731        self.seq += 1;
16732        self.session
16733            .submit_intent(Intent::TradePresent {
16734                entity_id: self.state.entity_id,
16735                item_instance_id,
16736                quantity,
16737                seq: self.seq,
16738            })
16739            .await?;
16740        self.state.intents_sent += 1;
16741        self.state.trade_ui.qty_entry = None;
16742        self.state.trade_ui.picking_inventory = false;
16743        Ok(())
16744    }
16745
16746    /// Confirm the trade quantity prompt (or present whole stack when qty==1).
16747    pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
16748        if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
16749            let qty = self.state.trade_ui.present_quantity();
16750            return self
16751                .trade_present_quantity(entry.item_instance_id, qty)
16752                .await;
16753        }
16754        if !self.state.trade_ui.picking_inventory {
16755            return Ok(());
16756        }
16757        let stacks = self.state.trade_presentable_stacks();
16758        let Some(stack) = stacks.get(self.state.trade_ui.inventory_index).copied() else {
16759            return Ok(());
16760        };
16761        let Some(id) = stack.item_instance_id else {
16762            return Ok(());
16763        };
16764        let label = stack
16765            .display_name
16766            .clone()
16767            .unwrap_or_else(|| stack.template_id.clone());
16768        if stack.quantity <= 1 {
16769            self.trade_present_quantity(id, Some(1)).await
16770        } else {
16771            self.state
16772                .trade_ui
16773                .begin_qty_entry(id, label, stack.quantity);
16774            Ok(())
16775        }
16776    }
16777
16778    pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
16779        self.seq += 1;
16780        self.session
16781            .submit_intent(Intent::TradeSetReady {
16782                entity_id: self.state.entity_id,
16783                ready,
16784                seq: self.seq,
16785            })
16786            .await?;
16787        self.state.intents_sent += 1;
16788        Ok(())
16789    }
16790
16791    pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
16792        self.seq += 1;
16793        self.session
16794            .submit_intent(Intent::TradeCancel {
16795                entity_id: self.state.entity_id,
16796                seq: self.seq,
16797            })
16798            .await?;
16799        self.state.intents_sent += 1;
16800        self.state.trade_ui.close();
16801        Ok(())
16802    }
16803
16804    pub async fn destroy_whisper_stone(
16805        &mut self,
16806        item_instance_id: uuid::Uuid,
16807    ) -> anyhow::Result<()> {
16808        self.seq += 1;
16809        self.session
16810            .submit_intent(Intent::DestroyWhisperStone {
16811                entity_id: self.state.entity_id,
16812                item_instance_id,
16813                seq: self.seq,
16814            })
16815            .await?;
16816        self.state.intents_sent += 1;
16817        Ok(())
16818    }
16819
16820    pub async fn stop(&mut self) -> anyhow::Result<()> {
16821        self.seq += 1;
16822        self.session
16823            .submit_intent(Intent::Stop {
16824                entity_id: self.state.entity_id,
16825                seq: self.seq,
16826            })
16827            .await?;
16828        self.state.intents_sent += 1;
16829        Ok(())
16830    }
16831
16832    pub fn disconnect(&self) {
16833        self.session.disconnect();
16834    }
16835}
16836
16837fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
16838    let dx = ax - bx;
16839    let dy = ay - by;
16840    (dx * dx + dy * dy).sqrt()
16841}
16842
16843#[cfg(test)]
16844mod tests {
16845    use std::collections::BTreeMap;
16846
16847    use super::*;
16848    use flatland_protocol::{
16849        BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
16850    };
16851
16852    fn sample_state() -> GameState {
16853        let mut state = GameState {
16854            session_id: 1,
16855            entity_id: 1,
16856            character_id: None,
16857            tick: 0,
16858            chunk_rev: 0,
16859            content_rev: 0,
16860            publish_rev: 0,
16861            entities: vec![EntityState {
16862                id: 1,
16863                label: "You".into(),
16864                transform: Transform {
16865                    position: WorldCoord::surface(128.0, 128.0),
16866                    yaw: 0.0,
16867                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
16868                },
16869                vitals: None,
16870                attributes: None,
16871                skills: None,
16872                inside_building: None,
16873                tile_id: None,
16874                paperdoll_ref: None,
16875                draw_scale: 1.0,
16876                presentation_state: None,
16877                sprite_mode: None,
16878                progression_xp: None,
16879                combat_cues: vec![],
16880                statuses: vec![],
16881            }],
16882            player: None,
16883            resource_nodes: vec![ResourceNodeView {
16884                id: "oak-1".into(),
16885                label: "Oak".into(),
16886                x: 130.0,
16887                y: 128.0,
16888                z: 0.0,
16889                item_template: "oak_log".into(),
16890                state: ResourceNodeState::Available,
16891                blocking: true,
16892                blocking_radius_m: 0.8,
16893                harvest_off: false,
16894                tile_id: None,
16895                yaw: 0.0,
16896                pitch: 0.0,
16897                roll: 0.0,
16898                draw_scale: 1.0,
16899                sprite_mode: None,
16900                growth_progress: None,
16901                presentation_state: None,
16902                channel_start_tick: None,
16903                channel_end_tick: None,
16904                harvest_drop_templates: vec![],
16905            }],
16906            harvest_route_nodes: vec![],
16907            ground_drops: vec![],
16908            placed_containers: vec![],
16909            buildings: vec![BuildingView {
16910                id: "broker-hut".into(),
16911                label: "Broker".into(),
16912                x: 148.0,
16913                y: 118.0,
16914                width_m: 8.0,
16915                depth_m: 6.0,
16916                interior_blueprint: Some("broker_hut".into()),
16917                tags: vec![],
16918                market_boundary_zone_ids: vec![],
16919                market_max_volume: None,
16920                wall_set: None,
16921                roof_set: None,
16922            }],
16923            doors: vec![flatland_protocol::DoorView {
16924                id: "door-1".into(),
16925                building_id: "broker-hut".into(),
16926                x: 148.0,
16927                y: 118.0,
16928                open: false,
16929                portal: Some("front".into()),
16930                locked: false,
16931                accessible: true,
16932                lock_id: None,
16933            }],
16934            interior_map: None,
16935            npcs: vec![],
16936            blueprints: vec![],
16937            building_materials: vec![],
16938            world_x0: 0.0,
16939            world_y0: 0.0,
16940            world_width_m: 256.0,
16941            world_height_m: 256.0,
16942            terrain_zones: Vec::new(),
16943            z_platforms: Vec::new(),
16944            z_transitions: Vec::new(),
16945            z_bands_outdoor_backup: None,
16946            world_clock: flatland_protocol::WorldClock::default(),
16947            inventory: std::collections::HashMap::new(),
16948            inventory_hints: std::collections::HashMap::new(),
16949            item_catalog: std::collections::HashMap::new(),
16950            logs: VecDeque::new(),
16951            intents_sent: 0,
16952            ticks_received: 0,
16953            connected: true,
16954            disconnect_reason: None,
16955            show_stats: false,
16956            hud_log_hidden: false,
16957            show_equip_menu: false,
16958            equip_menu_index: 0,
16959            show_craft_menu: false,
16960            show_plot_build_menu: false,
16961            plot_build_focus_wall: true,
16962            plot_build_wall_index: 0,
16963            plot_build_roof_index: 0,
16964            craft_menu_index: 0,
16965            craft_batch_quantity: 1,
16966            craft_tab: CraftTab::Ready,
16967            craft_filter: String::new(),
16968            craft_filter_focused: false,
16969            craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
16970            show_shop_menu: false,
16971            shop_catalog: None,
16972            bank_panel: None,
16973            bank_menu_index: 0,
16974            bank_ui_mode: BankUiMode::Menu,
16975            storage_panel: None,
16976            market_panel: None,
16977            market_menu_index: 0,
16978            market_filter: String::new(),
16979            market_filter_focused: false,
16980            market_category_filter: None,
16981            market_buy_confirm: None,
16982            market_ui_mode: MarketUiMode::Browse,
16983            storage_menu_index: 0,
16984            storage_ui_mode: StorageUiMode::Menu,
16985            shop_tab: ShopTab::default(),
16986            shop_menu_index: 0,
16987            shop_quantity: 1,
16988            shop_trade_log: VecDeque::new(),
16989            show_npc_verb_menu: false,
16990            npc_verb_target: None,
16991            npc_verb_index: 0,
16992            npc_verb_notice: None,
16993            player_verbs: crate::social::PlayerVerbState::default(),
16994            social_chat: crate::social::SocialChatState::default(),
16995            trade_ui: crate::social::TradeUiState::default(),
16996            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
16997            show_npc_chat: false,
16998            npc_chat: None,
16999            show_inventory_menu: false,
17000            inventory_menu_index: 0,
17001            inventory_tab: InventoryTab::OnPerson,
17002            inventory_filter: String::new(),
17003            inventory_filter_focused: false,
17004            show_move_picker: false,
17005            show_rename_prompt: false,
17006            rename_plot_id: None,
17007            highlighted_plot_id: None,
17008            show_worker_rename: false,
17009            rename_buffer: String::new(),
17010            move_picker_index: 0,
17011            move_picker: None,
17012            show_grant_picker: false,
17013            grant_picker_index: 0,
17014            grant_picker: None,
17015            show_destroy_picker: false,
17016            destroy_confirm_pending: false,
17017            destroy_picker: None,
17018            combat_target: None,
17019            combat_target_label: None,
17020            ground_target: None,
17021            combat_fx: Vec::new(),
17022            ground_hazards: Vec::new(),
17023            property_zones: Vec::new(),
17024            tax_zones: Vec::new(),
17025            growth_zones: Vec::new(),
17026            biome_zones: Vec::new(),
17027            terrain_kind_nav: Vec::new(),
17028            property_plots: Vec::new(),
17029            property_plot_settings: None,
17030            claim_mode: None,
17031            relocate_mode: None,
17032            sell_plot_confirm: None,
17033            sell_plot_armed_at: None,
17034            show_plant_menu: false,
17035            plant_menu_index: 0,
17036            show_farm_access: false,
17037            farm_access_name_draft: String::new(),
17038            farm_access_discount_bps: 0,
17039            farm_access_index: 0,
17040            plant_quantity: 1,
17041            in_combat: false,
17042            auto_attack: true,
17043            combat_has_los: false,
17044            attack_cd_ticks: 0,
17045            gcd_ticks: 0,
17046            weapon_ability_id: "unarmed".into(),
17047            mainhand_template_id: None,
17048            mainhand_label: None,
17049            mainhand_instance_id: None,
17050            offhand_template_id: None,
17051            offhand_label: None,
17052            offhand_instance_id: None,
17053            mainhand_hand_slots: 1,
17054            defense: None,
17055            worn: BTreeMap::new(),
17056            carry_mass: 0.0,
17057            carry_mass_max: 0.0,
17058            encumbrance: flatland_protocol::EncumbranceState::Light,
17059            move_speed_mps: 0.0,
17060            move_speed_mult: 0.0,
17061            inventory_stacks: Vec::new(),
17062            keychain_stacks: Vec::new(),
17063            whisper_pouch_stacks: Vec::new(),
17064            combat_target_detail: None,
17065            statuses: Vec::new(),
17066            cast_progress: None,
17067            timed_channel: None,
17068            plot_build_offer: None,
17069            ability_cooldowns: Vec::new(),
17070            blocking_active: false,
17071            max_target_slots: 1,
17072            combat_slots: Vec::new(),
17073            rotation_presets: Vec::new(),
17074            known_abilities: Vec::new(),
17075            ability_meta: std::collections::HashMap::new(),
17076            ability_mastery: std::collections::HashMap::new(),
17077            hotbar: vec![None; 9],
17078            max_abilities_per_rotation: 0,
17079            show_loadout_menu: false,
17080            show_keychain_menu: false,
17081            keychain_menu_index: 0,
17082            show_rotation_editor: false,
17083            loadout_menu_index: 0,
17084            loadout_hotbar_slot: 1,
17085            loadout_ability_index: 0,
17086            loadout_focus_presets: false,
17087            rotation_editor: RotationEditorState::default(),
17088            harvest_in_progress: false,
17089            harvest_started_at: None,
17090            pending_craft_ack: None,
17091            craft_channel_blueprint_id: None,
17092            pending_worker_job_ack: None,
17093            attending_worker_instance_id: None,
17094            quest_log: Vec::new(),
17095            interactables: Vec::new(),
17096            ledger: None,
17097            career: None,
17098            character_sheet_tab: CharacterSheetTab::Character,
17099            ledger_period: LedgerPeriod::Day,
17100            show_quest_offer: false,
17101            pending_quest_offers: Vec::new(),
17102            quest_offer_index: 0,
17103            show_quest_menu: false,
17104            quest_menu_index: 0,
17105            quest_withdraw_confirm: false,
17106            hired_workers: Vec::new(),
17107            show_workers_menu: false,
17108            workers_menu_index: 0,
17109            worker_dismiss_confirmation: None,
17110            workers_menu_compact: false,
17111            worker_step_display: BTreeMap::new(),
17112            worker_error_display: BTreeMap::new(),
17113            worker_health_ring_until: BTreeMap::new(),
17114            pending_worker_hire_since: None,
17115            show_worker_give_picker: false,
17116            worker_give_picker_index: 0,
17117            worker_give_picker: None,
17118            show_worker_give_target_picker: false,
17119            worker_give_target_picker_index: 0,
17120            worker_give_target_picker: None,
17121            show_worker_take_picker: false,
17122            worker_take_picker_index: 0,
17123            worker_take_picker: None,
17124            show_worker_teach_picker: false,
17125            worker_teach_picker_index: 0,
17126            worker_teach_picker: None,
17127            worker_route_editor: None,
17128            progression_curve: None,
17129        };
17130        state.player = state.entities.first().cloned();
17131        state
17132    }
17133
17134    #[test]
17135    fn template_display_name_uses_item_catalog_for_uuid_ids() {
17136        let mut state = sample_state();
17137        let id = "7ac81ed5-e3d3-4b7f-a7fb-11d6c34a0995";
17138        assert_eq!(state.template_display_name(id), "Unknown item");
17139        state.item_catalog.insert(
17140            id.into(),
17141            ItemCatalogEntryView {
17142                template_id: id.into(),
17143                display_name: "Emerald".into(),
17144                category: "resource".into(),
17145                seed_for: None,
17146            },
17147        );
17148        assert_eq!(state.template_display_name(id), "Emerald");
17149    }
17150
17151    #[test]
17152    fn whisper_cancels_when_peer_walks_out_of_range() {
17153        let mut state = sample_state();
17154        state.player = state.entities.first().cloned();
17155        let mut peer = state.entities[0].clone();
17156        peer.id = 2;
17157        peer.label = "Ada".into();
17158        peer.transform.position = WorldCoord::surface(129.0, 128.0); // 1m — in range
17159        state.entities.push(peer.clone());
17160        state.social_chat.focus_whisper(2, "Ada");
17161        state.refresh_whisper_range();
17162        assert!(matches!(
17163            state.social_chat.thread,
17164            crate::social::ChatThreadKind::Whisper { peer: 2 }
17165        ));
17166
17167        peer.transform.position = WorldCoord::surface(132.0, 128.0); // 4m — out of range
17168        state.entities[1] = peer;
17169        state.refresh_whisper_range();
17170        assert_eq!(
17171            state.social_chat.thread,
17172            crate::social::ChatThreadKind::Nearby
17173        );
17174        assert!(!state.social_chat.input_focused);
17175    }
17176
17177    #[test]
17178    fn probe_use_world_hired_worker_manage() {
17179        let mut state = sample_state();
17180        state
17181            .hired_workers
17182            .push(flatland_protocol::HiredWorkerView {
17183                instance_id: "worker-1".into(),
17184                entity_id: 42,
17185                def_id: "worker_laborer".into(),
17186                label: "Sam".into(),
17187                x: 129.0,
17188                y: 128.0,
17189                z: 0.0,
17190                mode: flatland_protocol::WorkerModeView::JobLoop,
17191                state: flatland_protocol::WorkerStateView::Working,
17192                step_label: "cultivate".into(),
17193                vitals: flatland_protocol::WorkerVitalsSummary {
17194                    health_pct: 100.0,
17195                    stamina_pct: 100.0,
17196                    mana_pct: 100.0,
17197                    hunger_pct: 100.0,
17198                    thirst_pct: 100.0,
17199                },
17200                carry_pct: 0.0,
17201                last_error: None,
17202                wage_copper_per_interval: 1,
17203                effective_wage_copper: 1,
17204                wage_meters_walked: 0.0,
17205                lodging_container_id: None,
17206                route: None,
17207                route_stop_index: None,
17208                known_blueprint_ids: Vec::new(),
17209                level: 1,
17210                worker_xp: 0.0,
17211                inventory: Vec::new(),
17212                equipment: flatland_protocol::WorkerEquipmentView::default(),
17213                issue_hint: None,
17214            });
17215        let probe = state.probe_use_world();
17216        let primary = probe.primary.expect("primary");
17217        assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17218        assert_eq!(primary.id, "worker-1");
17219        assert!(primary.hint_line().contains("Manage"));
17220        assert!(primary.hint_line().contains("Sam"));
17221        assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17222    }
17223
17224    fn sample_hired_worker(x: f32, y: f32) -> flatland_protocol::HiredWorkerView {
17225        flatland_protocol::HiredWorkerView {
17226            instance_id: "worker-1".into(),
17227            entity_id: 42,
17228            def_id: "worker_laborer".into(),
17229            label: "Sam".into(),
17230            x,
17231            y,
17232            z: 0.0,
17233            mode: flatland_protocol::WorkerModeView::JobLoop,
17234            state: flatland_protocol::WorkerStateView::Working,
17235            step_label: "follow".into(),
17236            vitals: flatland_protocol::WorkerVitalsSummary {
17237                health_pct: 100.0,
17238                stamina_pct: 100.0,
17239                mana_pct: 100.0,
17240                hunger_pct: 100.0,
17241                thirst_pct: 100.0,
17242            },
17243            carry_pct: 0.0,
17244            last_error: None,
17245            wage_copper_per_interval: 1,
17246            effective_wage_copper: 1,
17247            wage_meters_walked: 0.0,
17248            lodging_container_id: None,
17249            route: None,
17250            route_stop_index: None,
17251            known_blueprint_ids: Vec::new(),
17252            level: 1,
17253            worker_xp: 0.0,
17254            inventory: Vec::new(),
17255            equipment: flatland_protocol::WorkerEquipmentView::default(),
17256            issue_hint: None,
17257        }
17258    }
17259
17260    #[test]
17261    fn probe_harvest_beats_closer_hired_worker() {
17262        let mut state = sample_state();
17263        state.resource_nodes[0].x = 129.0;
17264        state.resource_nodes[0].y = 128.0;
17265        state.hired_workers.push(sample_hired_worker(128.2, 128.0));
17266        let probe = state.probe_use_world();
17267        let primary = probe.primary.expect("primary");
17268        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
17269        assert_eq!(primary.id, "oak-1");
17270        assert!(state.harvestable_node_in_range());
17271        assert_eq!(
17272            state.nearest_interact_target().as_deref(),
17273            Some("worker-1"),
17274            "harvest is not Interact — worker remains the interact target"
17275        );
17276    }
17277
17278    #[test]
17279    fn probe_door_beats_closer_hired_worker() {
17280        let mut state = sample_state();
17281        state.doors[0].x = 129.2;
17282        state.doors[0].y = 128.0;
17283        state.hired_workers.push(sample_hired_worker(128.3, 128.0));
17284        let probe = state.probe_use_world();
17285        let primary = probe.primary.expect("primary");
17286        assert!(
17287            matches!(
17288                primary.kind,
17289                crate::UseWorldKind::EnterDoor
17290                    | crate::UseWorldKind::OpenDoor
17291                    | crate::UseWorldKind::CloseDoor
17292                    | crate::UseWorldKind::ExitDoor
17293            ),
17294            "door should win over closer worker, got {:?}",
17295            primary.kind
17296        );
17297        assert_eq!(primary.id, "door-1");
17298        assert_eq!(state.nearest_interact_target().as_deref(), Some("door-1"));
17299    }
17300
17301    #[test]
17302    fn probe_indoor_exit_door_beats_lodging_chest_pickup() {
17303        // MadSin-style house: exit door + lodging beds within chest pickup radius.
17304        // `f` / probe must prefer the door (cascade stage 0) over ChestPickup (stage 2).
17305        let mut state = sample_state();
17306        state.entities[0].inside_building = Some("player_house".into());
17307        state.entities[0].transform.position = WorldCoord::surface(5.0, 2.0);
17308        state.player = state.entities.first().cloned();
17309        state.buildings = vec![BuildingView {
17310            id: "player_house".into(),
17311            label: "MadSin's house".into(),
17312            x: 100.0,
17313            y: 100.0,
17314            width_m: 10.0,
17315            depth_m: 8.0,
17316            interior_blueprint: Some("player_house".into()),
17317            tags: vec!["player_built".into()],
17318            market_boundary_zone_ids: vec![],
17319            market_max_volume: None,
17320            wall_set: None,
17321            roof_set: None,
17322        }];
17323        state.doors = vec![flatland_protocol::DoorView {
17324            id: "house_exit".into(),
17325            building_id: "player_house".into(),
17326            x: 5.0,
17327            y: 1.0,
17328            open: true,
17329            portal: Some("front".into()),
17330            locked: false,
17331            accessible: true,
17332            lock_id: None,
17333        }];
17334        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17335            id: "lodging_bed".into(),
17336            template_id: "camp_bed".into(),
17337            display_name: "Camp bed".into(),
17338            x: 5.5,
17339            y: 2.4,
17340            z: 0.0,
17341            locked: false,
17342            accessible: true,
17343            owner_character_id: None,
17344            contents: vec![],
17345            lock_id: None,
17346            capacity_volume: None,
17347            item_instance_id: Some(uuid::Uuid::from_u128(99)),
17348            tile_id: None,
17349            worker_lodging_capacity: Some(1),
17350            blocking: false,
17351            blocking_radius_m: 0.0,
17352            building_id: Some("player_house".into()),
17353        }];
17354        // Worker assigned to the bed but standing far away — occupancy only.
17355        let mut worker = sample_hired_worker(40.0, 40.0);
17356        worker.lodging_container_id = Some("lodging_bed".into());
17357        state.hired_workers.push(worker);
17358
17359        let probe = state.probe_use_world();
17360        let primary = probe.primary.expect("primary");
17361        assert!(
17362            matches!(
17363                primary.kind,
17364                crate::UseWorldKind::ExitDoor
17365                    | crate::UseWorldKind::OpenDoor
17366                    | crate::UseWorldKind::CloseDoor
17367                    | crate::UseWorldKind::EnterDoor
17368            ),
17369            "indoor exit must beat lodging ChestPickup, got {:?}",
17370            primary.kind
17371        );
17372        assert_eq!(primary.id, "house_exit");
17373        assert_eq!(primary.kind.cascade_stage(), 0);
17374        assert!(
17375            probe
17376                .candidates
17377                .iter()
17378                .any(|c| c.kind == crate::UseWorldKind::ChestPickup && c.in_range),
17379            "lodging bed should still be an in-range chest candidate"
17380        );
17381        assert_eq!(
17382            state.nearest_interact_target().as_deref(),
17383            Some("house_exit"),
17384            "use_nearest interact path should target the door"
17385        );
17386        assert!(state.lodging_is_occupied("lodging_bed"));
17387    }
17388
17389    #[test]
17390    fn probe_worker_when_no_resource_or_door_in_range() {
17391        let mut state = sample_state();
17392        // oak stays at 130,128 (2m, out of harvest); door stays at hut (~22m)
17393        state.hired_workers.push(sample_hired_worker(129.0, 128.0));
17394        let probe = state.probe_use_world();
17395        let primary = probe.primary.expect("primary");
17396        assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17397        assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17398        assert!(!state.harvestable_node_in_range());
17399    }
17400
17401    #[test]
17402    fn market_clerk_verb_options_include_market() {
17403        let mut state = sample_state();
17404        state.npcs.push(flatland_protocol::NpcView {
17405            id: "mira_market".into(),
17406            label: "Mira".into(),
17407            role: "market_clerk".into(),
17408            x: 129.0,
17409            y: 128.0,
17410            building_id: Some("town_market".into()),
17411            entity_id: None,
17412            life_state: None,
17413            hp_pct: None,
17414            can_trade: false,
17415            buy_templates: vec![],
17416            tile_id: None,
17417            behavior_state: None,
17418            presentation_state: None,
17419            sprite_mode: None,
17420            paperdoll_ref: None,
17421            draw_scale: 1.0,
17422            yaw: None,
17423            perception_fov_deg: None,
17424            perception_sight_m: None,
17425            perception_hear_m: None,
17426            quest_verbs: Vec::new(),
17427        });
17428        state.npc_verb_target = Some("mira_market".into());
17429        assert_eq!(
17430            state
17431                .npc_verb_options()
17432                .iter()
17433                .map(|v| v.label.as_str())
17434                .collect::<Vec<_>>(),
17435            vec!["Market", "Talk"]
17436        );
17437    }
17438
17439    #[test]
17440    fn butcher_verb_options_include_turn_in_for_give_item() {
17441        let mut state = sample_state();
17442        state.npcs.push(flatland_protocol::NpcView {
17443            id: "town_butcher_1".into(),
17444            label: "Brutus".into(),
17445            role: "butcher".into(),
17446            x: 129.0,
17447            y: 128.0,
17448            building_id: None,
17449            entity_id: None,
17450            life_state: None,
17451            hp_pct: None,
17452            can_trade: true,
17453            buy_templates: vec!["raw_venison".into()],
17454            tile_id: None,
17455            behavior_state: None,
17456            presentation_state: None,
17457            sprite_mode: None,
17458            paperdoll_ref: None,
17459            draw_scale: 1.0,
17460            yaw: None,
17461            perception_fov_deg: None,
17462            perception_sight_m: None,
17463            perception_hear_m: None,
17464            quest_verbs: Vec::new(),
17465        });
17466        state.quest_log.push(flatland_protocol::QuestLogEntry {
17467            quest_id: "deer_threat".into(),
17468            title: "Deer threat".into(),
17469            description: String::new(),
17470            status: flatland_protocol::QuestStatusView::Active,
17471            current_step_id: Some("deliver".into()),
17472            current_step_title: "Deliver venison".into(),
17473            current_step_index: 0,
17474            objectives: vec![flatland_protocol::QuestObjectiveProgress {
17475                label: "Give 3 Raw venison to Brutus".into(),
17476                current: 0,
17477                required: 3,
17478                done: false,
17479                kind: "give_item".into(),
17480                npc_ref: Some("town_butcher_1".into()),
17481                item_template: Some("raw_venison".into()),
17482                blueprint_id: None,
17483                building_id: None,
17484            }],
17485            current_step_reward: flatland_protocol::QuestRewardView::default(),
17486            completion_reward: flatland_protocol::QuestRewardView::default(),
17487            steps: Vec::new(),
17488            is_tracked: true,
17489            can_withdraw: true,
17490        });
17491        state.npc_verb_target = Some("town_butcher_1".into());
17492        assert_eq!(
17493            state
17494                .npc_verb_options()
17495                .iter()
17496                .map(|v| v.label.as_str())
17497                .collect::<Vec<_>>(),
17498            vec!["Turn in: Deer threat", "Talk", "Trade"]
17499        );
17500    }
17501
17502    #[test]
17503    fn ada_verb_options_include_quest_offer() {
17504        let mut state = sample_state();
17505        state.npcs.push(flatland_protocol::NpcView {
17506            id: "ada_broker".into(),
17507            label: "Ada".into(),
17508            role: "broker".into(),
17509            x: 129.0,
17510            y: 128.0,
17511            building_id: None,
17512            entity_id: None,
17513            life_state: None,
17514            hp_pct: None,
17515            can_trade: true,
17516            buy_templates: vec![],
17517            tile_id: None,
17518            behavior_state: None,
17519            presentation_state: None,
17520            sprite_mode: None,
17521            paperdoll_ref: Some("ada_broker".into()),
17522            draw_scale: 1.0,
17523            yaw: None,
17524            perception_fov_deg: None,
17525            perception_sight_m: None,
17526            perception_hear_m: None,
17527            quest_verbs: vec![flatland_protocol::NpcQuestVerb {
17528                quest_id: "ada_goblin_hunt".into(),
17529                label: "Ask about goblins".into(),
17530                kind: flatland_protocol::NpcQuestVerb::KIND_OFFER.into(),
17531            }],
17532        });
17533        state.npc_verb_target = Some("ada_broker".into());
17534        assert_eq!(
17535            state
17536                .npc_verb_options()
17537                .iter()
17538                .map(|v| v.label.as_str())
17539                .collect::<Vec<_>>(),
17540            vec!["Ask about goblins", "Talk", "Trade"]
17541        );
17542    }
17543
17544    #[test]
17545    fn market_list_excludes_currency_stacks() {
17546        let mut state = sample_state();
17547        state.inventory_stacks = vec![
17548            flatland_protocol::ItemStack {
17549                template_id: "copper_coin".into(),
17550                quantity: 50,
17551                item_instance_id: Some(uuid::Uuid::from_u128(10)),
17552                display_name: Some("Copper Coin".into()),
17553                ..Default::default()
17554            },
17555            flatland_protocol::ItemStack {
17556                template_id: "oak_log".into(),
17557                quantity: 2,
17558                item_instance_id: Some(uuid::Uuid::from_u128(11)),
17559                display_name: Some("Oak Log".into()),
17560                ..Default::default()
17561            },
17562            flatland_protocol::ItemStack {
17563                template_id: "whisper_stone".into(),
17564                quantity: 1,
17565                item_instance_id: Some(uuid::Uuid::from_u128(12)),
17566                display_name: Some("Whisper Stone".into()),
17567                category: Some("quest".into()),
17568                listable: Some(false),
17569                ..Default::default()
17570            },
17571        ];
17572        let opts = state.market_list_item_options(&MarketListSourceKind::Person);
17573        assert_eq!(opts.len(), 1);
17574        assert!(opts[0].label.contains("Oak"));
17575    }
17576
17577    #[test]
17578    fn market_browse_filters_by_category_and_search() {
17579        let mut state = sample_state();
17580        state.market_panel = Some(flatland_protocol::MarketPanel {
17581            npc_id: "mira_market".into(),
17582            npc_label: "Mira".into(),
17583            building_id: "town_market".into(),
17584            building_label: "Town Market".into(),
17585            used_volume: 0.0,
17586            max_volume: 100.0,
17587            listings: vec![
17588                flatland_protocol::MarketListingView {
17589                    listing_id: uuid::Uuid::from_u128(1),
17590                    seller_character_id: uuid::Uuid::from_u128(2),
17591                    seller_label: "Ada".into(),
17592                    hall_building_id: "town_market".into(),
17593                    hall_label: "Town Market".into(),
17594                    template_id: "oak_log".into(),
17595                    display_name: "Oak Log".into(),
17596                    category: "resource".into(),
17597                    quantity: 3,
17598                    unit_price_copper: 10,
17599                    line_total_copper: 30,
17600                    npc_price: false,
17601                    npc_dump_unit_copper: None,
17602                    mine: false,
17603                },
17604                flatland_protocol::MarketListingView {
17605                    listing_id: uuid::Uuid::from_u128(3),
17606                    seller_character_id: uuid::Uuid::from_u128(2),
17607                    seller_label: "Ada".into(),
17608                    hall_building_id: "town_market".into(),
17609                    hall_label: "Town Market".into(),
17610                    template_id: "short_sword".into(),
17611                    display_name: "Short Sword".into(),
17612                    category: "weapon".into(),
17613                    quantity: 1,
17614                    unit_price_copper: 100,
17615                    line_total_copper: 100,
17616                    npc_price: false,
17617                    npc_dump_unit_copper: None,
17618                    mine: false,
17619                },
17620            ],
17621            tax_bps: 0,
17622            tax_flat_copper: 0,
17623            list_vaults: vec![],
17624        });
17625        assert_eq!(state.market_filtered_listing_indices().len(), 2);
17626        state.market_category_filter = Some("Weapons");
17627        let weapons = state.market_filtered_listing_indices();
17628        assert_eq!(weapons.len(), 1);
17629        assert_eq!(
17630            state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
17631            "Short Sword"
17632        );
17633        state.market_category_filter = None;
17634        state.market_filter = "oak".into();
17635        let oak = state.market_filtered_listing_indices();
17636        assert_eq!(oak.len(), 1);
17637        assert_eq!(
17638            state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
17639            "Oak Log"
17640        );
17641    }
17642
17643    #[test]
17644    fn market_list_source_includes_person_and_vaults() {
17645        let mut state = sample_state();
17646        let item_id = uuid::Uuid::from_u128(1);
17647        state.inventory_stacks = vec![flatland_protocol::ItemStack {
17648            template_id: "oak_log".into(),
17649            quantity: 2,
17650            item_instance_id: Some(item_id),
17651            display_name: Some("Oak Log".into()),
17652            ..Default::default()
17653        }];
17654        state.market_panel = Some(flatland_protocol::MarketPanel {
17655            npc_id: "mira_market".into(),
17656            npc_label: "Mira".into(),
17657            building_id: "town_market".into(),
17658            building_label: "Town Market".into(),
17659            used_volume: 0.0,
17660            max_volume: 100.0,
17661            listings: vec![],
17662            tax_bps: 0,
17663            tax_flat_copper: 0,
17664            list_vaults: vec![flatland_protocol::MarketListVault {
17665                building_id: "town_storage".into(),
17666                building_label: "Town Storage".into(),
17667                contents: vec![flatland_protocol::ItemStack {
17668                    template_id: "lumber".into(),
17669                    quantity: 1,
17670                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
17671                    display_name: Some("Lumber".into()),
17672                    ..Default::default()
17673                }],
17674            }],
17675        });
17676        let sources = state.market_list_source_options();
17677        assert_eq!(sources.len(), 2);
17678        assert!(matches!(sources[0].0, MarketListSourceKind::Person));
17679        assert!(matches!(
17680            sources[1].0,
17681            MarketListSourceKind::TownStorage { .. }
17682        ));
17683        assert!(sources[1].1.contains("Town Storage"));
17684    }
17685
17686    #[test]
17687    fn npc_market_dump_estimate_from_town_storage_vault() {
17688        let mut state = sample_state();
17689        state.market_panel = Some(flatland_protocol::MarketPanel {
17690            npc_id: "mira_market".into(),
17691            npc_label: "Mira".into(),
17692            building_id: "town_market".into(),
17693            building_label: "Town Market".into(),
17694            used_volume: 0.0,
17695            max_volume: 100.0,
17696            listings: vec![],
17697            tax_bps: 0,
17698            tax_flat_copper: 0,
17699            list_vaults: vec![flatland_protocol::MarketListVault {
17700                building_id: "town_storage".into(),
17701                building_label: "Town Storage".into(),
17702                contents: vec![flatland_protocol::ItemStack {
17703                    template_id: "lumber".into(),
17704                    quantity: 3,
17705                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
17706                    display_name: Some("Lumber".into()),
17707                    base_value_copper: Some(20),
17708                    ..Default::default()
17709                }],
17710            }],
17711        });
17712        assert_eq!(
17713            state.npc_market_dump_unit_estimate("lumber"),
17714            Some(9),
17715            "vault stack base_value should enable NPC price estimate"
17716        );
17717    }
17718
17719    #[test]
17720    fn probe_use_world_npc_beats_nearby_loot() {
17721        let mut state = sample_state();
17722        state.npcs.push(flatland_protocol::NpcView {
17723            id: "ada".into(),
17724            label: "Ada".into(),
17725            role: "broker".into(),
17726            x: 129.0,
17727            y: 128.0,
17728            building_id: None,
17729            entity_id: None,
17730            life_state: None,
17731            hp_pct: None,
17732            can_trade: true,
17733            buy_templates: vec!["lumber".into()],
17734            tile_id: None,
17735            behavior_state: None,
17736            presentation_state: None,
17737            sprite_mode: None,
17738            paperdoll_ref: None,
17739            draw_scale: 1.0,
17740            yaw: None,
17741            perception_fov_deg: None,
17742            perception_sight_m: None,
17743            perception_hear_m: None,
17744            quest_verbs: Vec::new(),
17745        });
17746        state.ground_drops.push(flatland_protocol::GroundDropView {
17747            id: "d1".into(),
17748            template_id: "lumber".into(),
17749            quantity: 1,
17750            x: 128.5,
17751            y: 128.0,
17752            z: 0.0,
17753            tile_id: None,
17754            display_name: None,
17755            yaw: 0.0,
17756            pitch: 0.0,
17757            roll: 0.0,
17758            draw_scale: 1.0,
17759            item_instance_id: None,
17760            props: Default::default(),
17761            status_bindings: Vec::new(),
17762        });
17763        let probe = state.probe_use_world();
17764        let primary = probe.primary.expect("primary");
17765        assert_eq!(primary.kind, crate::UseWorldKind::Npc);
17766        assert_eq!(primary.id, "ada");
17767    }
17768
17769    #[test]
17770    fn probe_use_world_harvest_when_in_range() {
17771        let state = sample_state(); // oak at 130,128 — player 128,128 → dist 2 > 1.5
17772        let probe = state.probe_use_world();
17773        assert!(
17774            probe.primary.is_none(),
17775            "oak is 2m away, out of harvest range"
17776        );
17777        assert!(probe
17778            .candidates
17779            .iter()
17780            .any(|c| c.kind == crate::UseWorldKind::Harvest));
17781
17782        let mut state = sample_state();
17783        state.resource_nodes[0].x = 129.0;
17784        let probe = state.probe_use_world();
17785        let primary = probe.primary.expect("primary");
17786        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
17787    }
17788
17789    #[test]
17790    fn probe_use_world_door_uses_building_label() {
17791        let mut state = sample_state();
17792        state.doors[0].x = 129.0;
17793        state.doors[0].y = 128.0;
17794        let probe = state.probe_use_world();
17795        let primary = probe.primary.expect("primary");
17796        assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
17797        assert_eq!(primary.label, "Broker");
17798        assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
17799    }
17800
17801    #[test]
17802    fn empty_entity_tick_preserves_welcome_snapshot() {
17803        let mut state = sample_state();
17804        state.inventory.insert("carrot".into(), 3);
17805        let delta = TickDelta {
17806            tick: 1,
17807            entities: vec![],
17808            resource_nodes: vec![],
17809            ground_drops: vec![],
17810            placed_containers: vec![],
17811            buildings: vec![],
17812            doors: vec![],
17813            interior_map: None,
17814            npcs: vec![],
17815            inventory: vec![],
17816            blueprints: vec![],
17817            building_materials: vec![],
17818            world_clock: flatland_protocol::WorldClock::default(),
17819            combat: None,
17820            quest_log: vec![],
17821            hired_workers: Vec::new(),
17822            interactables: vec![],
17823            ledger: None,
17824            career: None,
17825            combat_fx: Vec::new(),
17826            ground_hazards: Vec::new(),
17827            property_plots: Vec::new(),
17828            terrain_overlays: Vec::new(),
17829        };
17830
17831        state.apply_tick_fields(&delta, 1);
17832
17833        assert_eq!(state.entities.len(), 1);
17834        assert!(state.player.is_some());
17835        assert_eq!(state.inventory.get("carrot"), Some(&3));
17836        assert_eq!(state.resource_nodes.len(), 1);
17837    }
17838
17839    #[test]
17840    fn tick_preserves_world_layers_when_delta_omits_them() {
17841        let mut state = sample_state();
17842        let delta = TickDelta {
17843            tick: 1,
17844            entities: state.entities.clone(),
17845            resource_nodes: vec![],
17846            ground_drops: vec![],
17847            placed_containers: vec![],
17848            buildings: vec![],
17849            doors: vec![],
17850            interior_map: None,
17851            npcs: vec![],
17852            inventory: vec![],
17853            blueprints: vec![],
17854            building_materials: vec![],
17855            world_clock: flatland_protocol::WorldClock::default(),
17856            combat: None,
17857            quest_log: vec![],
17858            hired_workers: Vec::new(),
17859            interactables: vec![],
17860            ledger: None,
17861            career: None,
17862            combat_fx: Vec::new(),
17863            ground_hazards: Vec::new(),
17864            property_plots: Vec::new(),
17865            terrain_overlays: Vec::new(),
17866        };
17867
17868        state.apply_tick_fields(&delta, 1);
17869
17870        assert_eq!(state.resource_nodes.len(), 1);
17871        assert_eq!(state.buildings.len(), 1);
17872        assert_eq!(state.doors.len(), 1);
17873    }
17874
17875    #[test]
17876    fn tick_updates_resource_nodes_when_server_sends_them() {
17877        let mut state = sample_state();
17878        let delta = TickDelta {
17879            tick: 1,
17880            entities: state.entities.clone(),
17881            resource_nodes: vec![ResourceNodeView {
17882                id: "oak-1".into(),
17883                label: "Oak".into(),
17884                x: 130.0,
17885                y: 128.0,
17886                z: 0.0,
17887                item_template: "oak_log".into(),
17888                state: ResourceNodeState::Cooldown,
17889                blocking: true,
17890                blocking_radius_m: 0.8,
17891                harvest_off: false,
17892                tile_id: None,
17893                yaw: 0.0,
17894                pitch: 0.0,
17895                roll: 0.0,
17896                draw_scale: 1.0,
17897                sprite_mode: None,
17898                growth_progress: None,
17899                presentation_state: None,
17900                channel_start_tick: None,
17901                channel_end_tick: None,
17902                harvest_drop_templates: vec![],
17903            }],
17904            buildings: vec![],
17905            doors: vec![],
17906            interior_map: None,
17907            npcs: vec![],
17908            inventory: vec![],
17909            blueprints: vec![],
17910            building_materials: vec![],
17911            world_clock: flatland_protocol::WorldClock::default(),
17912            ground_drops: vec![],
17913            placed_containers: vec![],
17914            combat: None,
17915            quest_log: vec![],
17916            hired_workers: Vec::new(),
17917            interactables: vec![],
17918            ledger: None,
17919            career: None,
17920            combat_fx: Vec::new(),
17921            ground_hazards: Vec::new(),
17922            property_plots: Vec::new(),
17923            terrain_overlays: Vec::new(),
17924        };
17925
17926        state.apply_tick_fields(&delta, 1);
17927
17928        assert!(matches!(
17929            state.resource_nodes[0].state,
17930            ResourceNodeState::Cooldown
17931        ));
17932    }
17933
17934    #[test]
17935    fn harvest_picker_keeps_welcome_nodes_after_aoi_tick() {
17936        let mut state = sample_state();
17937        let nearby = state.resource_nodes[0].clone();
17938        let mut far = nearby.clone();
17939        far.id = "far-oak".into();
17940        far.label = "Far Oak".into();
17941        far.x = 200.0;
17942        far.y = 200.0;
17943        state.replace_harvest_route_nodes(&[nearby.clone(), far.clone()]);
17944
17945        let delta = TickDelta {
17946            tick: 1,
17947            entities: state.entities.clone(),
17948            resource_nodes: vec![nearby],
17949            ground_drops: vec![],
17950            placed_containers: vec![],
17951            buildings: vec![],
17952            doors: vec![],
17953            interior_map: None,
17954            npcs: vec![],
17955            inventory: vec![],
17956            blueprints: vec![],
17957            building_materials: vec![],
17958            world_clock: flatland_protocol::WorldClock::default(),
17959            combat: None,
17960            quest_log: vec![],
17961            hired_workers: Vec::new(),
17962            interactables: vec![],
17963            ledger: None,
17964            career: None,
17965            combat_fx: Vec::new(),
17966            ground_hazards: Vec::new(),
17967            property_plots: Vec::new(),
17968            terrain_overlays: Vec::new(),
17969        };
17970        state.apply_tick_fields(&delta, 1);
17971
17972        assert_eq!(state.resource_nodes.len(), 1);
17973        let ids: Vec<_> = state
17974            .route_editor_node_candidates()
17975            .into_iter()
17976            .map(|n| n.id)
17977            .collect();
17978        assert!(ids.contains(&"oak-1".to_string()), "got {ids:?}");
17979        assert!(ids.contains(&"far-oak".to_string()), "got {ids:?}");
17980    }
17981
17982    #[test]
17983    fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
17984        let mut state = GameState {
17985            session_id: 1,
17986            entity_id: 1,
17987            character_id: None,
17988            tick: 0,
17989            chunk_rev: 0,
17990            content_rev: 0,
17991            publish_rev: 0,
17992            entities: vec![EntityState {
17993                id: 1,
17994                label: "You".into(),
17995                transform: Transform {
17996                    position: WorldCoord::surface(4.5, 2.0),
17997                    yaw: 0.0,
17998                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
17999                },
18000                vitals: None,
18001                attributes: None,
18002                skills: None,
18003                inside_building: Some("broker_hut".into()),
18004                tile_id: None,
18005                paperdoll_ref: None,
18006                draw_scale: 1.0,
18007                presentation_state: None,
18008                sprite_mode: None,
18009                progression_xp: None,
18010                combat_cues: vec![],
18011                statuses: vec![],
18012            }],
18013            player: None,
18014            resource_nodes: vec![],
18015            harvest_route_nodes: vec![],
18016            ground_drops: vec![],
18017            placed_containers: vec![],
18018            buildings: vec![BuildingView {
18019                id: "broker_hut".into(),
18020                label: "Broker".into(),
18021                x: 158.0,
18022                y: 124.0,
18023                width_m: 8.0,
18024                depth_m: 6.0,
18025                interior_blueprint: Some("broker_hut".into()),
18026                tags: vec![],
18027                market_boundary_zone_ids: vec![],
18028                market_max_volume: None,
18029                wall_set: None,
18030                roof_set: None,
18031            }],
18032            doors: vec![flatland_protocol::DoorView {
18033                id: "broker_hut_exit".into(),
18034                building_id: "broker_hut".into(),
18035                x: 4.3,
18036                y: 0.9,
18037                open: true,
18038                portal: Some("front".into()),
18039                locked: false,
18040                accessible: true,
18041                lock_id: None,
18042            }],
18043            interior_map: None,
18044            npcs: vec![flatland_protocol::NpcView {
18045                id: "ada_broker".into(),
18046                label: "Ada".into(),
18047                x: 4.5,
18048                y: 2.0,
18049                building_id: Some("broker_hut".into()),
18050                role: "broker".into(),
18051                entity_id: None,
18052                life_state: None,
18053                hp_pct: None,
18054                can_trade: true,
18055                buy_templates: vec!["lumber".into()],
18056                tile_id: None,
18057                behavior_state: None,
18058                presentation_state: None,
18059                sprite_mode: None,
18060                paperdoll_ref: None,
18061                draw_scale: 1.0,
18062                yaw: None,
18063                perception_fov_deg: None,
18064                perception_sight_m: None,
18065                perception_hear_m: None,
18066                quest_verbs: Vec::new(),
18067            }],
18068            blueprints: vec![],
18069            building_materials: vec![],
18070            world_x0: 0.0,
18071            world_y0: 0.0,
18072            world_width_m: 256.0,
18073            world_height_m: 256.0,
18074            terrain_zones: Vec::new(),
18075            z_platforms: Vec::new(),
18076            z_transitions: Vec::new(),
18077            z_bands_outdoor_backup: None,
18078            world_clock: flatland_protocol::WorldClock::default(),
18079            inventory: std::collections::HashMap::new(),
18080            inventory_hints: std::collections::HashMap::new(),
18081            item_catalog: std::collections::HashMap::new(),
18082            logs: VecDeque::new(),
18083            intents_sent: 0,
18084            ticks_received: 0,
18085            connected: true,
18086            disconnect_reason: None,
18087            show_stats: false,
18088            hud_log_hidden: false,
18089            show_equip_menu: false,
18090            equip_menu_index: 0,
18091            show_craft_menu: false,
18092            show_plot_build_menu: false,
18093            plot_build_focus_wall: true,
18094            plot_build_wall_index: 0,
18095            plot_build_roof_index: 0,
18096            craft_menu_index: 0,
18097            craft_batch_quantity: 1,
18098            craft_tab: CraftTab::Ready,
18099            craft_filter: String::new(),
18100            craft_filter_focused: false,
18101            craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
18102            show_shop_menu: false,
18103            shop_catalog: None,
18104            bank_panel: None,
18105            bank_menu_index: 0,
18106            bank_ui_mode: BankUiMode::Menu,
18107            storage_panel: None,
18108            market_panel: None,
18109            market_menu_index: 0,
18110            market_filter: String::new(),
18111            market_filter_focused: false,
18112            market_category_filter: None,
18113            market_buy_confirm: None,
18114            market_ui_mode: MarketUiMode::Browse,
18115            storage_menu_index: 0,
18116            storage_ui_mode: StorageUiMode::Menu,
18117            shop_tab: ShopTab::default(),
18118            shop_menu_index: 0,
18119            shop_quantity: 1,
18120            shop_trade_log: VecDeque::new(),
18121            show_npc_verb_menu: false,
18122            npc_verb_target: None,
18123            npc_verb_index: 0,
18124            npc_verb_notice: None,
18125            player_verbs: crate::social::PlayerVerbState::default(),
18126            social_chat: crate::social::SocialChatState::default(),
18127            trade_ui: crate::social::TradeUiState::default(),
18128            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
18129            show_npc_chat: false,
18130            npc_chat: None,
18131            show_inventory_menu: false,
18132            inventory_menu_index: 0,
18133            inventory_tab: InventoryTab::OnPerson,
18134            inventory_filter: String::new(),
18135            inventory_filter_focused: false,
18136            show_move_picker: false,
18137            show_rename_prompt: false,
18138            rename_plot_id: None,
18139            highlighted_plot_id: None,
18140            show_worker_rename: false,
18141            rename_buffer: String::new(),
18142            move_picker_index: 0,
18143            move_picker: None,
18144            show_grant_picker: false,
18145            grant_picker_index: 0,
18146            grant_picker: None,
18147            show_destroy_picker: false,
18148            destroy_confirm_pending: false,
18149            destroy_picker: None,
18150            combat_target: None,
18151            combat_target_label: None,
18152            ground_target: None,
18153            combat_fx: Vec::new(),
18154            ground_hazards: Vec::new(),
18155            property_zones: Vec::new(),
18156            tax_zones: Vec::new(),
18157            growth_zones: Vec::new(),
18158            biome_zones: Vec::new(),
18159            terrain_kind_nav: Vec::new(),
18160            property_plots: Vec::new(),
18161            property_plot_settings: None,
18162            claim_mode: None,
18163            relocate_mode: None,
18164            sell_plot_confirm: None,
18165            sell_plot_armed_at: None,
18166            show_plant_menu: false,
18167            plant_menu_index: 0,
18168            show_farm_access: false,
18169            farm_access_name_draft: String::new(),
18170            farm_access_discount_bps: 0,
18171            farm_access_index: 0,
18172            plant_quantity: 1,
18173            in_combat: false,
18174            auto_attack: true,
18175            combat_has_los: false,
18176            attack_cd_ticks: 0,
18177            gcd_ticks: 0,
18178            weapon_ability_id: "unarmed".into(),
18179            mainhand_template_id: None,
18180            mainhand_label: None,
18181            mainhand_instance_id: None,
18182            offhand_template_id: None,
18183            offhand_label: None,
18184            offhand_instance_id: None,
18185            mainhand_hand_slots: 1,
18186            defense: None,
18187            worn: BTreeMap::new(),
18188            carry_mass: 0.0,
18189            carry_mass_max: 0.0,
18190            encumbrance: flatland_protocol::EncumbranceState::Light,
18191            move_speed_mps: 0.0,
18192            move_speed_mult: 0.0,
18193            inventory_stacks: Vec::new(),
18194            keychain_stacks: Vec::new(),
18195            whisper_pouch_stacks: Vec::new(),
18196            combat_target_detail: None,
18197            statuses: Vec::new(),
18198            cast_progress: None,
18199            timed_channel: None,
18200            plot_build_offer: None,
18201            ability_cooldowns: Vec::new(),
18202            blocking_active: false,
18203            max_target_slots: 1,
18204            combat_slots: Vec::new(),
18205            rotation_presets: Vec::new(),
18206            known_abilities: Vec::new(),
18207            ability_meta: std::collections::HashMap::new(),
18208            ability_mastery: std::collections::HashMap::new(),
18209            hotbar: vec![None; 9],
18210            max_abilities_per_rotation: 0,
18211            show_loadout_menu: false,
18212            show_keychain_menu: false,
18213            keychain_menu_index: 0,
18214            show_rotation_editor: false,
18215            loadout_menu_index: 0,
18216            loadout_hotbar_slot: 1,
18217            loadout_ability_index: 0,
18218            loadout_focus_presets: false,
18219            rotation_editor: RotationEditorState::default(),
18220            harvest_in_progress: false,
18221            harvest_started_at: None,
18222            pending_craft_ack: None,
18223            craft_channel_blueprint_id: None,
18224            pending_worker_job_ack: None,
18225            attending_worker_instance_id: None,
18226            quest_log: Vec::new(),
18227            interactables: Vec::new(),
18228            ledger: None,
18229            career: None,
18230            character_sheet_tab: CharacterSheetTab::Character,
18231            ledger_period: LedgerPeriod::Day,
18232            show_quest_offer: false,
18233            pending_quest_offers: Vec::new(),
18234            quest_offer_index: 0,
18235            show_quest_menu: false,
18236            quest_menu_index: 0,
18237            quest_withdraw_confirm: false,
18238            hired_workers: Vec::new(),
18239            show_workers_menu: false,
18240            workers_menu_index: 0,
18241            worker_dismiss_confirmation: None,
18242            workers_menu_compact: false,
18243            worker_step_display: BTreeMap::new(),
18244            worker_error_display: BTreeMap::new(),
18245            worker_health_ring_until: BTreeMap::new(),
18246            pending_worker_hire_since: None,
18247            show_worker_give_picker: false,
18248            worker_give_picker_index: 0,
18249            worker_give_picker: None,
18250            show_worker_give_target_picker: false,
18251            worker_give_target_picker_index: 0,
18252            worker_give_target_picker: None,
18253            show_worker_take_picker: false,
18254            worker_take_picker_index: 0,
18255            worker_take_picker: None,
18256            show_worker_teach_picker: false,
18257            worker_teach_picker_index: 0,
18258            worker_teach_picker: None,
18259            worker_route_editor: None,
18260            progression_curve: None,
18261        };
18262        state.player = state.entities.first().cloned();
18263        assert_eq!(
18264            state.nearest_interact_target().as_deref(),
18265            Some("ada_broker")
18266        );
18267    }
18268
18269    #[test]
18270    fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
18271        let mut state = sample_state();
18272        // Player is at (128, 128) per sample_state(). One chest just inside
18273        // CONTAINER_RANGE_M, one clearly beyond it.
18274        state.placed_containers = vec![
18275            flatland_protocol::PlacedContainerView {
18276                id: "near".into(),
18277                template_id: "wooden_chest_small".into(),
18278                display_name: "Wooden Chest".into(),
18279                x: 130.0,
18280                y: 128.0,
18281                z: 0.0,
18282                locked: true,
18283                accessible: true,
18284                owner_character_id: None,
18285                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
18286                lock_id: None,
18287                capacity_volume: None,
18288                item_instance_id: Some(uuid::Uuid::from_u128(1)),
18289                tile_id: None,
18290                worker_lodging_capacity: None,
18291                blocking: false,
18292                blocking_radius_m: 0.0,
18293                building_id: None,
18294            },
18295            flatland_protocol::PlacedContainerView {
18296                id: "far".into(),
18297                template_id: "wooden_chest_small".into(),
18298                display_name: "Distant Chest".into(),
18299                x: 128.0 + CONTAINER_RANGE_M + 5.0,
18300                y: 128.0,
18301                z: 0.0,
18302                locked: false,
18303                accessible: true,
18304                owner_character_id: None,
18305                contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
18306                lock_id: None,
18307                capacity_volume: None,
18308                item_instance_id: Some(uuid::Uuid::from_u128(2)),
18309                tile_id: None,
18310                worker_lodging_capacity: None,
18311                blocking: false,
18312                blocking_radius_m: 0.0,
18313                building_id: None,
18314            },
18315        ];
18316
18317        let nearby = state.nearby_containers();
18318        assert_eq!(
18319            nearby.len(),
18320            1,
18321            "far chest must not appear once out of range"
18322        );
18323        assert_eq!(nearby[0].view.id, "near");
18324        assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
18325        assert!(nearby[0].rows[0].is_chest_shell);
18326
18327        // The same chest, but locked and inaccessible (no key held), must hide
18328        // contents but still show the selectable chest shell row.
18329        state.placed_containers[0].accessible = false;
18330        let nearby = state.nearby_containers();
18331        assert_eq!(nearby.len(), 1);
18332        assert_eq!(nearby[0].rows.len(), 1);
18333        assert!(nearby[0].rows[0].is_chest_shell);
18334    }
18335
18336    #[test]
18337    fn chest_pickup_destinations_offer_person_and_worn_bag() {
18338        let mut state = sample_state();
18339        let back_id = uuid::Uuid::from_u128(42);
18340        state.worn.insert(
18341            BodySlot::Back,
18342            flatland_protocol::ItemStack {
18343                template_id: "travel_backpack".into(),
18344                quantity: 1,
18345                item_instance_id: Some(back_id),
18346                props: Default::default(),
18347                status_bindings: Vec::new(),
18348                contents: Vec::new(),
18349                display_name: Some("Travel Backpack".into()),
18350                category: Some("container".into()),
18351                base_mass: Some(2.5),
18352                base_volume: Some(12.0),
18353                capacity_volume: Some(80.0),
18354                stackable: Some(false),
18355                world_placeable: Some(false),
18356                worker_lodging_capacity: None,
18357                equip_slot: None,
18358                armor_physical: None,
18359                resists: vec![],
18360                hand_slots: None,
18361                listable: None,
18362                ..Default::default()
18363            },
18364        );
18365        let opts = state.chest_pickup_destinations("chest-1");
18366        assert!(matches!(
18367            opts.first().map(|o| &o.kind),
18368            Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
18369        ));
18370        assert!(opts.iter().any(|o| matches!(
18371            &o.kind,
18372            MoveOptionKind::PickupPlaced {
18373                nest_parent_instance_id: None,
18374                ..
18375            }
18376        )));
18377        assert!(opts.iter().any(|o| matches!(
18378            &o.kind,
18379            MoveOptionKind::PickupPlaced {
18380                nest_parent_instance_id: Some(id),
18381                ..
18382            } if *id == back_id
18383        )));
18384        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
18385    }
18386
18387    #[test]
18388    fn placed_container_public_label_hides_owner_custom_name() {
18389        let owner = uuid::Uuid::from_u128(99);
18390        let mut state = sample_state();
18391        state.character_id = Some(uuid::Uuid::from_u128(1));
18392        state.inventory_hints.insert(
18393            "wooden_chest_medium".into(),
18394            InventoryHint {
18395                display_name: "Medium Wooden Chest".into(),
18396                category: "container".into(),
18397                base_mass: None,
18398                base_volume: None,
18399                capacity_volume: None,
18400                stackable: false,
18401                listable: true,
18402                base_value_copper: None,
18403            },
18404        );
18405        let chest = flatland_protocol::PlacedContainerView {
18406            id: "c1".into(),
18407            template_id: "wooden_chest_medium".into(),
18408            display_name: "Barry's Loot #a3f2".into(),
18409            x: 128.0,
18410            y: 128.0,
18411            z: 0.0,
18412            locked: false,
18413            accessible: true,
18414            owner_character_id: Some(owner),
18415            contents: vec![],
18416            lock_id: None,
18417            capacity_volume: None,
18418            item_instance_id: None,
18419            tile_id: None,
18420            worker_lodging_capacity: None,
18421            blocking: false,
18422            blocking_radius_m: 0.0,
18423            building_id: None,
18424        };
18425        assert_eq!(
18426            state.placed_container_public_label(&chest),
18427            "Medium Wooden Chest"
18428        );
18429        state.character_id = Some(owner);
18430        assert_eq!(
18431            state.placed_container_public_label(&chest),
18432            "Barry's Loot #a3f2"
18433        );
18434    }
18435
18436    #[test]
18437    fn location_context_shows_crop_growth_percent_not_depleted() {
18438        let mut state = sample_state();
18439        state.player = state.entities.first().cloned();
18440        state.resource_nodes[0].label = "Carrot (growing)".into();
18441        state.resource_nodes[0].x = 128.2;
18442        state.resource_nodes[0].y = 128.0;
18443        state.resource_nodes[0].state = ResourceNodeState::Cooldown;
18444        state.resource_nodes[0].growth_progress = Some(0.47);
18445        let lines = state.location_context_lines();
18446        let line = lines
18447            .iter()
18448            .find(|l| l.text.contains("Carrot"))
18449            .map(|l| l.text.as_str())
18450            .unwrap_or("");
18451        assert!(
18452            line.contains("(growing, 47%)"),
18453            "expected growth percent, got: {line}"
18454        );
18455        assert!(
18456            !line.contains("depleted"),
18457            "growing crop should not show depleted: {line}"
18458        );
18459    }
18460
18461    #[test]
18462    fn resource_node_near_action_suffix_prefers_growth() {
18463        let node = ResourceNodeView {
18464            id: "crop".into(),
18465            label: "Wheat".into(),
18466            x: 0.0,
18467            y: 0.0,
18468            z: 0.0,
18469            item_template: "wheat".into(),
18470            state: ResourceNodeState::Cooldown,
18471            blocking: false,
18472            blocking_radius_m: 0.0,
18473            harvest_off: false,
18474            tile_id: None,
18475            yaw: 0.0,
18476            pitch: 0.0,
18477            roll: 0.0,
18478            draw_scale: 1.0,
18479            sprite_mode: None,
18480            growth_progress: Some(0.12),
18481            presentation_state: None,
18482            channel_start_tick: None,
18483            channel_end_tick: None,
18484            harvest_drop_templates: vec![],
18485        };
18486        assert_eq!(resource_node_near_action_suffix(&node), " (growing, 12%)");
18487    }
18488
18489    #[test]
18490    fn location_context_lists_nearby_resource_node() {
18491        let mut state = sample_state();
18492        state.player = state.entities.first().cloned();
18493        state.resource_nodes[0].x = 128.2;
18494        state.resource_nodes[0].y = 128.0;
18495        let lines = state.location_context_lines();
18496        assert!(
18497            lines
18498                .iter()
18499                .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
18500            "expected resource node in context: {:?}",
18501            lines
18502        );
18503    }
18504
18505    #[test]
18506    fn quest_board_usable_within_board_radius() {
18507        let mut state = sample_state();
18508        state.player = state.entities.first().cloned();
18509        state.interactables = vec![flatland_protocol::InteractableView {
18510            id: "board-1".into(),
18511            kind: "quest_board".into(),
18512            label: "Town Quest Board".into(),
18513            x: 130.5,
18514            y: 128.0,
18515            z: 0.0,
18516            board_id: Some("starter_town_board".into()),
18517        }];
18518        // ~2.5m away — outside the old 1.5m interact radius, inside the 3.0m board radius.
18519        assert_eq!(
18520            state.nearest_interact_target().as_deref(),
18521            Some("board-1"),
18522            "quest board should be selectable at ~2.5m"
18523        );
18524        let lines = state.location_context_lines();
18525        assert!(
18526            lines
18527                .iter()
18528                .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
18529            "HUD should advertise f when board is in range: {:?}",
18530            lines
18531        );
18532    }
18533
18534    #[test]
18535    fn quest_board_keeps_multiple_offers() {
18536        let mut state = sample_state();
18537        let offer = |id: &str, title: &str| flatland_protocol::QuestOffer {
18538            quest_id: id.into(),
18539            title: title.into(),
18540            description: format!("{title} desc"),
18541            step_count: 2,
18542        };
18543        state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18544        state.push_quest_offer(offer("daily_20695_1", "Town board: Deer threat"));
18545        state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18546        assert_eq!(state.pending_quest_offers.len(), 2);
18547        assert_eq!(
18548            state.selected_quest_offer().unwrap().quest_id,
18549            "ada_goblin_hunt"
18550        );
18551        state.move_quest_offer_selection(1);
18552        assert_eq!(
18553            state.selected_quest_offer().unwrap().quest_id,
18554            "daily_20695_1"
18555        );
18556        state.remove_quest_offer("daily_20695_1");
18557        assert_eq!(state.pending_quest_offers.len(), 1);
18558        assert!(state.show_quest_offer);
18559        state.remove_quest_offer("ada_goblin_hunt");
18560        assert!(!state.show_quest_offer);
18561        assert!(state.pending_quest_offers.is_empty());
18562    }
18563
18564    #[test]
18565    fn inventory_selectable_rows_excludes_equipped_shells_but_keeps_bag_contents() {
18566        let mut state = sample_state();
18567        state.worn.insert(
18568            BodySlot::Back,
18569            flatland_protocol::ItemStack {
18570                template_id: "travel_backpack".into(),
18571                quantity: 1,
18572                item_instance_id: Some(uuid::Uuid::from_u128(3)),
18573                props: Default::default(),
18574                status_bindings: Vec::new(),
18575                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
18576                display_name: None,
18577                category: None,
18578                base_mass: None,
18579                base_volume: None,
18580                capacity_volume: None,
18581                stackable: None,
18582                world_placeable: None,
18583                worker_lodging_capacity: None,
18584                equip_slot: None,
18585                armor_physical: None,
18586                resists: vec![],
18587                hand_slots: None,
18588                listable: None,
18589                ..Default::default()
18590            },
18591        );
18592        state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
18593        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18594            id: "chest-1".into(),
18595            template_id: "wooden_chest_small".into(),
18596            display_name: "Wooden Chest".into(),
18597            x: 129.0,
18598            y: 128.0,
18599            z: 0.0,
18600            locked: false,
18601            accessible: true,
18602            owner_character_id: None,
18603            contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
18604            lock_id: None,
18605            capacity_volume: None,
18606            item_instance_id: Some(uuid::Uuid::from_u128(4)),
18607            tile_id: None,
18608            worker_lodging_capacity: None,
18609            blocking: false,
18610            blocking_radius_m: 0.0,
18611            building_id: None,
18612        }];
18613
18614        state.inventory_tab = InventoryTab::OnPerson;
18615        let rows = state.inventory_selectable_rows();
18616        let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
18617        assert_eq!(
18618            sections,
18619            vec![
18620                InventorySection::Person, // iron_ore carried in backpack
18621                InventorySection::Person, // lumber
18622            ]
18623        );
18624        assert_eq!(rows[0].stack.template_id, "iron_ore");
18625        assert_eq!(rows[0].depth, 0);
18626        assert!(!rows[0].is_equip_shell);
18627        assert_eq!(rows[1].stack.template_id, "lumber");
18628
18629        let lines = state.inventory_browser_lines();
18630        assert!(lines.iter().any(|l| matches!(
18631            l,
18632            InventoryBrowserLine::Section(s) if s.contains("carried bags")
18633        )));
18634        assert!(lines.iter().any(|l| matches!(
18635            l,
18636            InventoryBrowserLine::Item { text, .. } if text.contains("iron_ore")
18637        )));
18638        assert!(!lines.iter().any(|l| matches!(
18639            l,
18640            InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
18641        )));
18642        assert!(!lines.iter().any(|l| matches!(
18643            l,
18644            InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
18645        )));
18646
18647        state.inventory_tab = InventoryTab::Nearby;
18648        let nearby_rows = state.inventory_selectable_rows();
18649        assert_eq!(nearby_rows.len(), 2);
18650        assert!(nearby_rows[0].is_chest_shell);
18651        assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
18652        let nearby_lines = state.inventory_browser_lines();
18653        assert!(nearby_lines.iter().any(|l| matches!(
18654            l,
18655            InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
18656        )));
18657    }
18658
18659    #[test]
18660    fn give_worker_notice_does_not_put_item_back_in_bag() {
18661        let mut state = sample_state();
18662        let id = uuid::Uuid::from_u128(42);
18663        let mut saw = flatland_protocol::ItemStack::simple("handsaw", 1);
18664        saw.item_instance_id = Some(id);
18665        saw.display_name = Some("Handsaw".into());
18666        state.sync_inventory_from_stacks(&[saw]);
18667        assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 1);
18668
18669        state.remove_carried_instance(id, None);
18670        assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 0);
18671        assert!(state.inventory_stacks.is_empty());
18672
18673        state.apply_interaction_notice(&flatland_protocol::InteractionNotice {
18674            target_id: "worker-1".into(),
18675            message: "Gave 1x Handsaw to Laborer".into(),
18676            coins_delta: 0,
18677            inventory_delta: vec![flatland_protocol::ItemStack::simple("handsaw", 1)],
18678        });
18679        assert_eq!(
18680            state.inventory.get("handsaw").copied().unwrap_or(0),
18681            0,
18682            "Gave notice must not restore the handed stack"
18683        );
18684    }
18685
18686    #[test]
18687    fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
18688        let mut state = sample_state();
18689        let back_id = uuid::Uuid::from_u128(5);
18690        state.worn.insert(
18691            BodySlot::Back,
18692            flatland_protocol::ItemStack {
18693                template_id: "travel_backpack".into(),
18694                quantity: 1,
18695                item_instance_id: Some(back_id),
18696                props: Default::default(),
18697                status_bindings: Vec::new(),
18698                contents: Vec::new(),
18699                display_name: None,
18700                category: Some("container".into()),
18701                base_mass: None,
18702                base_volume: None,
18703                capacity_volume: Some(80.0),
18704                stackable: None,
18705                world_placeable: None,
18706                worker_lodging_capacity: None,
18707                equip_slot: None,
18708                armor_physical: None,
18709                resists: vec![],
18710                hand_slots: None,
18711                listable: None,
18712                ..Default::default()
18713            },
18714        );
18715        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18716            id: "chest-1".into(),
18717            template_id: "wooden_chest_small".into(),
18718            display_name: "Wooden Chest".into(),
18719            x: 129.0,
18720            y: 128.0,
18721            z: 0.0,
18722            locked: false,
18723            accessible: true,
18724            owner_character_id: None,
18725            contents: Vec::new(),
18726            lock_id: None,
18727            capacity_volume: None,
18728            item_instance_id: Some(uuid::Uuid::from_u128(6)),
18729            tile_id: None,
18730            worker_lodging_capacity: None,
18731            blocking: false,
18732            blocking_radius_m: 0.0,
18733            building_id: None,
18734        }];
18735
18736        // Item currently sitting loose on the person (Root): backpack + nearby
18737        // chest should both be offered, plus Drop/Cancel, but not "Root" itself.
18738        let opts = state.move_destinations_for(
18739            &flatland_protocol::InventoryLocation::Root,
18740            None,
18741            None,
18742            "lumber",
18743        );
18744        assert!(!opts.iter().any(|o| matches!(
18745            &o.kind,
18746            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
18747        )));
18748        assert!(opts.iter().any(|o| matches!(
18749            &o.kind,
18750            MoveOptionKind::Move { location, parent_instance_id, .. }
18751                if *location == flatland_protocol::InventoryLocation::Worn {
18752                    slot: BodySlot::Back,
18753                } && *parent_instance_id == Some(back_id)
18754        )));
18755        assert!(opts.iter().any(|o| matches!(
18756            &o.kind,
18757            MoveOptionKind::Move { location, .. }
18758                if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
18759        )));
18760        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
18761        assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
18762        let backpack = opts
18763            .iter()
18764            .find(|o| {
18765                matches!(
18766                    &o.kind,
18767                    MoveOptionKind::Move {
18768                        location: flatland_protocol::InventoryLocation::Worn {
18769                            slot: BodySlot::Back,
18770                        },
18771                        parent_instance_id,
18772                    } if *parent_instance_id == Some(back_id)
18773                )
18774            })
18775            .expect("worn backpack destination");
18776        assert_eq!(backpack.volume, Some((0.0, 80.0)));
18777        assert_eq!(
18778            backpack.volume_usage_label().as_deref(),
18779            Some("vol 0/80 (80 free)")
18780        );
18781
18782        // Item currently inside the worn backpack: the backpack itself must be
18783        // excluded from its own destination list (can't move an item into the
18784        // container it's already in).
18785        let from_backpack = flatland_protocol::InventoryLocation::Worn {
18786            slot: BodySlot::Back,
18787        };
18788        let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
18789        assert!(!opts.iter().any(|o| matches!(
18790            &o.kind,
18791            MoveOptionKind::Move { location, parent_instance_id, .. }
18792                if *location == from_backpack && *parent_instance_id == Some(back_id)
18793        )));
18794        assert!(opts.iter().any(|o| matches!(
18795            &o.kind,
18796            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
18797        )));
18798    }
18799
18800    #[test]
18801    fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
18802        let mut state = sample_state();
18803        // Insert out of display order — BTreeMap iteration must still yield the
18804        // canonical Head/Body/Arms/Legs/Feet/Back/Waist order regardless.
18805        state.worn.insert(
18806            BodySlot::Waist,
18807            flatland_protocol::ItemStack {
18808                template_id: "simple_belt".into(),
18809                quantity: 1,
18810                item_instance_id: Some(uuid::Uuid::from_u128(10)),
18811                props: Default::default(),
18812                status_bindings: Vec::new(),
18813                contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
18814                display_name: None,
18815                category: Some("container".into()),
18816                base_mass: None,
18817                base_volume: None,
18818                capacity_volume: None,
18819                stackable: None,
18820                world_placeable: None,
18821                worker_lodging_capacity: None,
18822                equip_slot: None,
18823                armor_physical: None,
18824                resists: vec![],
18825                hand_slots: None,
18826                listable: None,
18827                ..Default::default()
18828            },
18829        );
18830        state.worn.insert(
18831            BodySlot::Head,
18832            flatland_protocol::ItemStack {
18833                template_id: "cloth_cap".into(),
18834                quantity: 1,
18835                item_instance_id: Some(uuid::Uuid::from_u128(11)),
18836                props: Default::default(),
18837                status_bindings: Vec::new(),
18838                contents: Vec::new(),
18839                display_name: None,
18840                category: Some("armor".into()),
18841                base_mass: None,
18842                base_volume: None,
18843                capacity_volume: None,
18844                stackable: None,
18845                world_placeable: None,
18846                worker_lodging_capacity: None,
18847                equip_slot: None,
18848                armor_physical: None,
18849                resists: vec![],
18850                hand_slots: None,
18851                listable: None,
18852                ..Default::default()
18853            },
18854        );
18855        state.worn.insert(
18856            BodySlot::Back,
18857            flatland_protocol::ItemStack {
18858                template_id: "travel_backpack".into(),
18859                quantity: 1,
18860                item_instance_id: Some(uuid::Uuid::from_u128(12)),
18861                props: Default::default(),
18862                status_bindings: Vec::new(),
18863                contents: Vec::new(),
18864                display_name: None,
18865                category: Some("container".into()),
18866                base_mass: None,
18867                base_volume: None,
18868                capacity_volume: None,
18869                stackable: None,
18870                world_placeable: None,
18871                worker_lodging_capacity: None,
18872                equip_slot: None,
18873                armor_physical: None,
18874                resists: vec![],
18875                hand_slots: None,
18876                listable: None,
18877                ..Default::default()
18878            },
18879        );
18880
18881        let rows = state.worn_rows();
18882        // Head, then Back, then Waist (+ nested pouch) — enum declaration order.
18883        assert_eq!(rows.len(), 4);
18884        assert_eq!(rows[0].stack.template_id, "cloth_cap");
18885        assert!(rows[0].is_equip_shell);
18886        assert_eq!(rows[1].stack.template_id, "travel_backpack");
18887        assert!(rows[1].is_equip_shell);
18888        assert_eq!(rows[2].stack.template_id, "simple_belt");
18889        assert!(rows[2].is_equip_shell);
18890        assert_eq!(rows[3].stack.template_id, "leather_pouch");
18891        assert_eq!(rows[3].depth, 1);
18892        assert!(!rows[3].is_equip_shell);
18893    }
18894
18895    #[test]
18896    fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
18897        let mut state = sample_state();
18898        state.worn.insert(
18899            BodySlot::Waist,
18900            flatland_protocol::ItemStack {
18901                template_id: "simple_belt".into(),
18902                quantity: 1,
18903                item_instance_id: Some(uuid::Uuid::from_u128(20)),
18904                props: Default::default(),
18905                status_bindings: Vec::new(),
18906                contents: Vec::new(),
18907                display_name: Some("Simple Belt".into()),
18908                category: Some("container".into()),
18909                base_mass: None,
18910                base_volume: None,
18911                capacity_volume: None,
18912                stackable: None,
18913                world_placeable: None,
18914                worker_lodging_capacity: None,
18915                equip_slot: None,
18916                armor_physical: None,
18917                resists: vec![],
18918                hand_slots: None,
18919                listable: None,
18920                ..Default::default()
18921            },
18922        );
18923        state.worn.insert(
18924            BodySlot::Head,
18925            flatland_protocol::ItemStack {
18926                template_id: "cloth_cap".into(),
18927                quantity: 1,
18928                item_instance_id: Some(uuid::Uuid::from_u128(21)),
18929                props: Default::default(),
18930                status_bindings: Vec::new(),
18931                contents: Vec::new(),
18932                display_name: Some("Cloth Cap".into()),
18933                category: Some("armor".into()),
18934                base_mass: None,
18935                base_volume: None,
18936                capacity_volume: None,
18937                stackable: None,
18938                world_placeable: None,
18939                worker_lodging_capacity: None,
18940                equip_slot: None,
18941                armor_physical: None,
18942                resists: vec![],
18943                hand_slots: None,
18944                listable: None,
18945                ..Default::default()
18946            },
18947        );
18948
18949        let opts = state.move_destinations_for(
18950            &flatland_protocol::InventoryLocation::Root,
18951            None,
18952            None,
18953            "leather_pouch",
18954        );
18955        assert!(
18956            opts.iter().any(|o| matches!(
18957                &o.kind,
18958                MoveOptionKind::Move { location, .. }
18959                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18960            )),
18961            "belt loop must be offered when moving a pouch"
18962        );
18963        assert!(
18964            !opts.iter().any(|o| matches!(
18965                &o.kind,
18966                MoveOptionKind::Move { location, .. }
18967                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
18968            )),
18969            "armor slots can't hold other items and must not appear as move destinations"
18970        );
18971        let belt_opt = opts
18972            .iter()
18973            .find(|o| matches!(
18974                &o.kind,
18975                MoveOptionKind::Move { location, .. }
18976                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18977            ))
18978            .unwrap();
18979        assert!(belt_opt.label.contains("belt loop"));
18980
18981        let opts = state.move_destinations_for(
18982            &flatland_protocol::InventoryLocation::Root,
18983            None,
18984            None,
18985            "lumber",
18986        );
18987        assert!(
18988            !opts.iter().any(|o| o.label.contains("belt loop")),
18989            "loose materials must not target the belt shell — only nested pouches"
18990        );
18991    }
18992
18993    #[test]
18994    fn move_destinations_for_offers_dimensional_pouch_on_belt() {
18995        let mut state = sample_state();
18996        let belt_id = uuid::Uuid::from_u128(30);
18997        let pouch_id = uuid::Uuid::from_u128(31);
18998        state.worn.insert(
18999            BodySlot::Waist,
19000            flatland_protocol::ItemStack {
19001                template_id: "simple_belt".into(),
19002                quantity: 1,
19003                item_instance_id: Some(belt_id),
19004                props: Default::default(),
19005                status_bindings: Vec::new(),
19006                world_placeable: None,
19007                worker_lodging_capacity: None,
19008                equip_slot: None,
19009                armor_physical: None,
19010                resists: vec![],
19011                hand_slots: None,
19012                contents: vec![flatland_protocol::ItemStack {
19013                    template_id: "dimensional_pouch".into(),
19014                    quantity: 1,
19015                    item_instance_id: Some(pouch_id),
19016                    props: Default::default(),
19017                    status_bindings: Vec::new(),
19018                    contents: Vec::new(),
19019                    display_name: Some("Dimensional Pouch".into()),
19020                    category: Some("container".into()),
19021                    base_mass: None,
19022                    base_volume: None,
19023                    capacity_volume: Some(200.0),
19024                    stackable: None,
19025                    world_placeable: None,
19026                    worker_lodging_capacity: None,
19027                    equip_slot: None,
19028                    armor_physical: None,
19029                    resists: vec![],
19030                    hand_slots: None,
19031                    listable: None,
19032                    ..Default::default()
19033                }],
19034                display_name: Some("Simple Belt".into()),
19035                category: Some("container".into()),
19036                base_mass: None,
19037                base_volume: None,
19038                capacity_volume: None,
19039                stackable: None,
19040                listable: None,
19041                ..Default::default()
19042            },
19043        );
19044
19045        let opts = state.move_destinations_for(
19046            &flatland_protocol::InventoryLocation::Root,
19047            None,
19048            None,
19049            "iron_ore",
19050        );
19051        assert!(
19052            opts.iter().any(|o| matches!(
19053                &o.kind,
19054                MoveOptionKind::Move {
19055                    location,
19056                    parent_instance_id,
19057                    ..
19058                } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
19059                    && *parent_instance_id == Some(pouch_id)
19060            )),
19061            "dimensional pouch clipped on belt must accept loose items"
19062        );
19063        assert!(
19064            opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
19065            "destination label should name the pouch"
19066        );
19067    }
19068
19069    #[test]
19070    fn container_volume_label_on_placed_chest_shell() {
19071        let mut state = sample_state();
19072        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19073            id: "chest-1".into(),
19074            template_id: "wooden_chest_small".into(),
19075            display_name: "Camp Chest".into(),
19076            x: 129.0,
19077            y: 128.0,
19078            z: 0.0,
19079            locked: false,
19080            accessible: true,
19081            owner_character_id: None,
19082            contents: vec![flatland_protocol::ItemStack {
19083                template_id: "iron_ore".into(),
19084                quantity: 2,
19085                item_instance_id: None,
19086                props: Default::default(),
19087                status_bindings: Vec::new(),
19088                contents: Vec::new(),
19089                display_name: None,
19090                category: None,
19091                base_mass: None,
19092                base_volume: Some(2.0),
19093                capacity_volume: None,
19094                stackable: None,
19095                world_placeable: None,
19096                worker_lodging_capacity: None,
19097                equip_slot: None,
19098                armor_physical: None,
19099                resists: vec![],
19100                hand_slots: None,
19101                listable: None,
19102                ..Default::default()
19103            }],
19104            lock_id: None,
19105            capacity_volume: Some(60.0),
19106            item_instance_id: Some(uuid::Uuid::from_u128(4)),
19107            tile_id: None,
19108            worker_lodging_capacity: None,
19109            blocking: false,
19110            blocking_radius_m: 0.0,
19111            building_id: None,
19112        }];
19113        let nearby = state.nearby_containers();
19114        let label = state.container_volume_label(&nearby[0].rows[0]);
19115        assert!(
19116            label.contains("vol 4/60"),
19117            "expected used/cap in label, got {label}"
19118        );
19119        assert!(
19120            label.contains("56 free"),
19121            "expected free space, got {label}"
19122        );
19123    }
19124
19125    #[test]
19126    fn move_destinations_for_include_placed_chest_volume() {
19127        let mut state = sample_state();
19128        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19129            id: "chest-1".into(),
19130            template_id: "wooden_chest_small".into(),
19131            display_name: "Camp Chest".into(),
19132            x: 129.0,
19133            y: 128.0,
19134            z: 0.0,
19135            locked: false,
19136            accessible: true,
19137            owner_character_id: None,
19138            contents: vec![flatland_protocol::ItemStack {
19139                template_id: "iron_ore".into(),
19140                quantity: 2,
19141                item_instance_id: None,
19142                props: Default::default(),
19143                status_bindings: Vec::new(),
19144                contents: Vec::new(),
19145                display_name: None,
19146                category: None,
19147                base_mass: None,
19148                base_volume: Some(2.0),
19149                capacity_volume: None,
19150                stackable: None,
19151                world_placeable: None,
19152                worker_lodging_capacity: None,
19153                equip_slot: None,
19154                armor_physical: None,
19155                resists: vec![],
19156                hand_slots: None,
19157                listable: None,
19158                ..Default::default()
19159            }],
19160            lock_id: None,
19161            capacity_volume: Some(60.0),
19162            item_instance_id: Some(uuid::Uuid::from_u128(4)),
19163            tile_id: None,
19164            worker_lodging_capacity: None,
19165            blocking: false,
19166            blocking_radius_m: 0.0,
19167            building_id: None,
19168        }];
19169        let opts = state.move_destinations_for(
19170            &flatland_protocol::InventoryLocation::Root,
19171            None,
19172            None,
19173            "lumber",
19174        );
19175        let chest = opts
19176            .iter()
19177            .find(|o| {
19178                matches!(
19179                    &o.kind,
19180                    MoveOptionKind::Move {
19181                        location: flatland_protocol::InventoryLocation::Placed { container_id },
19182                        ..
19183                    } if container_id == "chest-1"
19184                )
19185            })
19186            .expect("nearby chest destination");
19187        assert_eq!(chest.volume, Some((4.0, 60.0)));
19188        assert_eq!(
19189            chest.volume_usage_label().as_deref(),
19190            Some("vol 4/60 (56 free)")
19191        );
19192        assert!(opts
19193            .iter()
19194            .filter(|o| matches!(o.kind, MoveOptionKind::Drop | MoveOptionKind::Cancel))
19195            .all(|o| o.volume.is_none()));
19196    }
19197
19198    #[test]
19199    fn chest_pickup_destinations_include_worn_bag_volume() {
19200        let mut state = sample_state();
19201        let back_id = uuid::Uuid::from_u128(5);
19202        state.worn.insert(
19203            BodySlot::Back,
19204            flatland_protocol::ItemStack {
19205                template_id: "travel_backpack".into(),
19206                quantity: 1,
19207                item_instance_id: Some(back_id),
19208                props: Default::default(),
19209                status_bindings: Vec::new(),
19210                contents: Vec::new(),
19211                display_name: Some("Travel Backpack".into()),
19212                category: Some("container".into()),
19213                capacity_volume: Some(80.0),
19214                ..Default::default()
19215            },
19216        );
19217        let opts = state.chest_pickup_destinations("chest-1");
19218        let bag = opts
19219            .iter()
19220            .find(|o| {
19221                matches!(
19222                    &o.kind,
19223                    MoveOptionKind::PickupPlaced {
19224                        nest_parent_instance_id,
19225                        ..
19226                    } if *nest_parent_instance_id == Some(back_id)
19227                )
19228            })
19229            .expect("pickup into worn backpack");
19230        assert_eq!(bag.volume, Some((0.0, 80.0)));
19231        assert!(opts
19232            .iter()
19233            .filter(|o| matches!(
19234                o.kind,
19235                MoveOptionKind::RelocatePlaced { .. } | MoveOptionKind::Cancel
19236            ))
19237            .all(|o| o.volume.is_none()));
19238    }
19239
19240    #[test]
19241    fn key_pair_chest_label_from_placed_lock_id() {
19242        let mut state = sample_state();
19243        let owner = uuid::Uuid::from_u128(77);
19244        state.character_id = Some(owner);
19245        let lock = uuid::Uuid::from_u128(99).to_string();
19246        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19247            id: "chest-1".into(),
19248            template_id: "wooden_chest_small".into(),
19249            display_name: "Barry's Loot #a3f2".into(),
19250            x: 129.0,
19251            y: 128.0,
19252            z: 0.0,
19253            locked: true,
19254            accessible: true,
19255            owner_character_id: Some(owner),
19256            contents: Vec::new(),
19257            lock_id: Some(lock.clone()),
19258            capacity_volume: None,
19259            item_instance_id: Some(uuid::Uuid::from_u128(4)),
19260            tile_id: None,
19261            worker_lodging_capacity: None,
19262            blocking: false,
19263            blocking_radius_m: 0.0,
19264            building_id: None,
19265        }];
19266        let key_id = uuid::Uuid::from_u128(5);
19267        let key = flatland_protocol::ItemStack {
19268            template_id: KEY_TEMPLATE.into(),
19269            quantity: 1,
19270            item_instance_id: Some(key_id),
19271            props: BTreeMap::from([
19272                (PROP_OPENS_LOCK_ID.into(), lock),
19273                (
19274                    PROP_OPENS_CONTAINER_NAME.into(),
19275                    "Barry's Loot #a3f2".into(),
19276                ),
19277            ]),
19278            status_bindings: Vec::new(),
19279            contents: Vec::new(),
19280            display_name: Some("Container Key".into()),
19281            category: Some("key".into()),
19282            base_mass: None,
19283            base_volume: None,
19284            capacity_volume: None,
19285            stackable: None,
19286            world_placeable: None,
19287            worker_lodging_capacity: None,
19288            equip_slot: None,
19289            armor_physical: None,
19290            resists: vec![],
19291            hand_slots: None,
19292            listable: None,
19293            ..Default::default()
19294        };
19295        state.inventory_stacks = vec![key.clone()];
19296        assert_eq!(
19297            state.key_pair_chest_label(&key).as_deref(),
19298            Some("Barry's Loot #a3f2")
19299        );
19300        assert!(state.key_drop_blocked(&key));
19301    }
19302
19303    #[test]
19304    fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
19305        let mut state = sample_state();
19306        let lock = uuid::Uuid::from_u128(101).to_string();
19307        let key = flatland_protocol::ItemStack {
19308            template_id: KEY_TEMPLATE.into(),
19309            quantity: 1,
19310            item_instance_id: Some(uuid::Uuid::from_u128(7)),
19311            props: BTreeMap::from([
19312                (PROP_OPENS_LOCK_ID.into(), lock),
19313                (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
19314            ]),
19315            status_bindings: Vec::new(),
19316            contents: Vec::new(),
19317            display_name: None,
19318            category: Some("key".into()),
19319            base_mass: None,
19320            base_volume: None,
19321            capacity_volume: None,
19322            stackable: None,
19323            world_placeable: None,
19324            worker_lodging_capacity: None,
19325            equip_slot: None,
19326            armor_physical: None,
19327            resists: vec![],
19328            hand_slots: None,
19329            listable: None,
19330            ..Default::default()
19331        };
19332        state.placed_containers.clear();
19333        assert_eq!(
19334            state.key_pair_chest_label(&key).as_deref(),
19335            Some("Camp Stash")
19336        );
19337    }
19338
19339    #[test]
19340    fn key_drop_allowed_when_paired_chest_unlocked() {
19341        let mut state = sample_state();
19342        let lock = uuid::Uuid::from_u128(100).to_string();
19343        let key_id = uuid::Uuid::from_u128(6);
19344        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19345            id: "chest-1".into(),
19346            template_id: "wooden_chest_small".into(),
19347            display_name: "Camp Chest".into(),
19348            x: 129.0,
19349            y: 128.0,
19350            z: 0.0,
19351            locked: false,
19352            accessible: true,
19353            owner_character_id: None,
19354            contents: Vec::new(),
19355            lock_id: Some(lock.clone()),
19356            capacity_volume: None,
19357            item_instance_id: None,
19358            tile_id: None,
19359            worker_lodging_capacity: None,
19360            blocking: false,
19361            blocking_radius_m: 0.0,
19362            building_id: None,
19363        }];
19364        let key = flatland_protocol::ItemStack {
19365            template_id: KEY_TEMPLATE.into(),
19366            quantity: 1,
19367            item_instance_id: Some(key_id),
19368            props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
19369            status_bindings: Vec::new(),
19370            contents: Vec::new(),
19371            display_name: None,
19372            category: Some("key".into()),
19373            base_mass: None,
19374            base_volume: None,
19375            capacity_volume: None,
19376            stackable: None,
19377            world_placeable: None,
19378            worker_lodging_capacity: None,
19379            equip_slot: None,
19380            armor_physical: None,
19381            resists: vec![],
19382            hand_slots: None,
19383            listable: None,
19384            ..Default::default()
19385        };
19386        state.inventory_stacks = vec![key.clone()];
19387        assert!(!state.key_drop_blocked(&key));
19388        let opts = state.move_destinations_for(
19389            &flatland_protocol::InventoryLocation::Root,
19390            None,
19391            Some(key_id),
19392            KEY_TEMPLATE,
19393        );
19394        assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
19395    }
19396
19397    #[test]
19398    fn combat_hud_refreshes_progression_xp_when_entity_stale() {
19399        use flatland_protocol::{CombatHud, ProgressionCurve, ProgressionXp};
19400
19401        let mut state = sample_state();
19402        let curve = ProgressionCurve::default();
19403        let bootstrap =
19404            ProgressionXp::bootstrap_new(curve.baseline_display, curve.xp_base, curve.xp_growth);
19405        let mut fresh = bootstrap.clone();
19406        fresh.strength += 0.08;
19407        if let Some(player) = state.player.as_mut() {
19408            player.progression_xp = Some(bootstrap);
19409        }
19410
19411        let combat = CombatHud {
19412            progression_xp: Some(fresh.clone()),
19413            progression_baseline: curve.baseline_display,
19414            progression_xp_base: curve.xp_base,
19415            progression_xp_growth: curve.xp_growth,
19416            attributes: state.player.as_ref().and_then(|p| p.attributes),
19417            skills: state.player.as_ref().and_then(|p| p.skills.clone()),
19418            ..CombatHud::default()
19419        };
19420        state.apply_combat_hud(&combat);
19421
19422        let xp = state
19423            .player
19424            .as_ref()
19425            .and_then(|p| p.progression_xp.as_ref())
19426            .expect("xp");
19427        assert!((xp.strength - fresh.strength).abs() < 0.001);
19428        assert!(state.progression_curve.is_some());
19429    }
19430
19431    #[test]
19432    fn combat_hud_syncs_known_abilities_and_hotbar() {
19433        use flatland_protocol::CombatHud;
19434
19435        let mut state = sample_state();
19436        let combat = CombatHud {
19437            known_abilities: vec!["unarmed".into(), "fireball".into()],
19438            hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
19439            max_abilities_per_rotation: 4,
19440            ability_id: "short_sword_slash".into(),
19441            ..CombatHud::default()
19442        };
19443        state.apply_combat_hud(&combat);
19444
19445        assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
19446        assert_eq!(state.hotbar_ability(1), Some("fireball"));
19447        assert_eq!(state.hotbar_ability(2), None);
19448        assert_eq!(state.hotbar_ability(3), Some("unarmed"));
19449        assert_eq!(state.max_abilities_per_rotation, 4);
19450        let choices = state.loadout_ability_choices();
19451        assert!(choices.iter().any(|a| a == "short_sword_slash"));
19452        assert!(choices.iter().any(|a| a == "fireball"));
19453    }
19454
19455    #[test]
19456    fn loadout_hotbar_choices_include_inventory_consumables() {
19457        let mut state = sample_state();
19458        state.known_abilities = vec!["unarmed".into()];
19459        state.weapon_ability_id = "unarmed".into();
19460        state.inventory_stacks = vec![flatland_protocol::ItemStack {
19461            template_id: "empty_bottle".into(),
19462            quantity: 1,
19463            item_instance_id: Some(uuid::Uuid::from_u128(9)),
19464            display_name: Some("Glass Bottle of Water".into()),
19465            category: Some("container".into()),
19466            props: [
19467                ("serving".into(), "1".into()),
19468                ("liquid_vessel".into(), "1".into()),
19469                ("serving_holds".into(), "liquid".into()),
19470            ]
19471            .into_iter()
19472            .collect(),
19473            ..Default::default()
19474        }];
19475        state.inventory.insert("empty_bottle".into(), 1);
19476        state.inventory_hints.insert(
19477            "empty_bottle".into(),
19478            InventoryHint {
19479                display_name: "Glass Bottle".into(),
19480                category: "container".into(),
19481                ..Default::default()
19482            },
19483        );
19484
19485        let choices = state.loadout_hotbar_choices();
19486        assert!(choices.iter().any(|c| c.binding == "unarmed"));
19487        let water = choices
19488            .iter()
19489            .find(|c| c.binding == "item:empty_bottle")
19490            .expect("serving bottle binding");
19491        assert_eq!(water.meta.as_deref(), Some("use"));
19492        assert!(water.label.contains("Glass Bottle of Water"));
19493        assert_eq!(state.hotbar_slot_label(1), None, "unbound until set");
19494        state.hotbar = vec![None, None, None, None, Some("item:empty_bottle".into())];
19495        assert_eq!(
19496            state.hotbar_slot_label(5).as_deref(),
19497            Some("Glass Bottle×1")
19498        );
19499    }
19500
19501    #[test]
19502    fn loadout_hotbar_choices_exclude_blueprint_scrolls() {
19503        let mut state = sample_state();
19504        state.known_abilities = vec!["unarmed".into()];
19505        state.weapon_ability_id = "unarmed".into();
19506        state.inventory_stacks = vec![
19507            flatland_protocol::ItemStack {
19508                template_id: "carrot".into(),
19509                quantity: 2,
19510                display_name: Some("Wild Carrot".into()),
19511                category: Some("consumable".into()),
19512                ..Default::default()
19513            },
19514            flatland_protocol::ItemStack {
19515                template_id: "blueprint_dimensional_pouch".into(),
19516                quantity: 1,
19517                display_name: Some("Blueprint — Dimensional Pouch".into()),
19518                category: Some("consumable".into()),
19519                props: [("teaches_blueprint".into(), "craft_dimensional_pouch".into())]
19520                    .into_iter()
19521                    .collect(),
19522                ..Default::default()
19523            },
19524        ];
19525        state.inventory.insert("carrot".into(), 2);
19526        state
19527            .inventory
19528            .insert("blueprint_dimensional_pouch".into(), 1);
19529        state.inventory_hints.insert(
19530            "carrot".into(),
19531            InventoryHint {
19532                display_name: "Wild Carrot".into(),
19533                category: "consumable".into(),
19534                ..Default::default()
19535            },
19536        );
19537        state.inventory_hints.insert(
19538            "blueprint_dimensional_pouch".into(),
19539            InventoryHint {
19540                display_name: "Blueprint — Dimensional Pouch".into(),
19541                category: "consumable".into(),
19542                ..Default::default()
19543            },
19544        );
19545
19546        let choices = state.loadout_hotbar_choices();
19547        assert!(choices.iter().any(|c| c.binding == "item:carrot"));
19548        assert!(
19549            choices
19550                .iter()
19551                .all(|c| c.binding != "item:blueprint_dimensional_pouch"),
19552            "recipe scrolls must not appear on the hotbar picker: {choices:?}"
19553        );
19554    }
19555
19556    #[test]
19557    fn storage_store_options_excludes_hand_equipped() {
19558        let mut state = sample_state();
19559        let sword_id = uuid::Uuid::from_u128(11);
19560        let ore_id = uuid::Uuid::from_u128(22);
19561        state.inventory_stacks = vec![
19562            flatland_protocol::ItemStack {
19563                template_id: "short_sword".into(),
19564                quantity: 1,
19565                item_instance_id: Some(sword_id),
19566                display_name: Some("Short Sword".into()),
19567                category: Some("weapon".into()),
19568                ..Default::default()
19569            },
19570            flatland_protocol::ItemStack {
19571                template_id: "iron_ore".into(),
19572                quantity: 5,
19573                item_instance_id: Some(ore_id),
19574                display_name: Some("Iron Ore".into()),
19575                category: Some("resource".into()),
19576                ..Default::default()
19577            },
19578        ];
19579        state.mainhand_template_id = Some("short_sword".into());
19580        state.mainhand_instance_id = Some(sword_id);
19581
19582        let opts = state.storage_store_options();
19583        assert_eq!(opts.len(), 1);
19584        assert_eq!(opts[0].item_instance_id, ore_id);
19585        assert!(state.hand_equipped_instance_ids().contains(&sword_id));
19586    }
19587
19588    #[test]
19589    fn loose_consumable_move_picker_offers_use_and_storage() {
19590        let mut state = sample_state();
19591        let inst = uuid::Uuid::from_u128(77);
19592        state.inventory_stacks = vec![flatland_protocol::ItemStack {
19593            template_id: "carrot".into(),
19594            quantity: 2,
19595            item_instance_id: Some(inst),
19596            props: Default::default(),
19597            status_bindings: Vec::new(),
19598            contents: Vec::new(),
19599            display_name: Some("Wild Carrot".into()),
19600            category: Some("consumable".into()),
19601            base_mass: None,
19602            base_volume: None,
19603            capacity_volume: None,
19604            stackable: Some(true),
19605            world_placeable: None,
19606            worker_lodging_capacity: None,
19607            equip_slot: None,
19608            armor_physical: None,
19609            resists: vec![],
19610            hand_slots: None,
19611            listable: None,
19612            ..Default::default()
19613        }];
19614        state.inventory_hints.insert(
19615            "carrot".into(),
19616            InventoryHint {
19617                display_name: "Wild Carrot".into(),
19618                category: "consumable".into(),
19619                base_mass: Some(0.15),
19620                base_volume: Some(0.3),
19621                capacity_volume: None,
19622                stackable: true,
19623                listable: true,
19624                base_value_copper: None,
19625            },
19626        );
19627        state.show_inventory_menu = true;
19628        state.inventory_menu_index = 0;
19629
19630        let row = state.inventory_selected_row().expect("carrot row");
19631        let mut options = state.move_destinations_for(
19632            &row.from,
19633            row.from_parent_instance_id,
19634            row.stack.item_instance_id,
19635            &row.stack.template_id,
19636        );
19637        if row.from == flatland_protocol::InventoryLocation::Root
19638            && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
19639        {
19640            options.insert(
19641                0,
19642                MoveOption::action("Use (eat / drink)", MoveOptionKind::Use),
19643            );
19644        }
19645
19646        assert_eq!(
19647            options.first().map(|o| &o.label),
19648            Some(&"Use (eat / drink)".into())
19649        );
19650        assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
19651        assert!(options
19652            .iter()
19653            .any(|o| matches!(o.kind, MoveOptionKind::Drop)));
19654    }
19655
19656    #[test]
19657    fn inventory_category_group_order_is_stable() {
19658        assert_eq!(inventory_category_group("weapon").0, "Weapons");
19659        assert_eq!(inventory_category_group("armor").0, "Armor");
19660        assert_eq!(inventory_category_group("consumable").0, "Consumables");
19661        assert_eq!(inventory_category_group("liquid").0, "Consumables");
19662        assert_eq!(inventory_category_group("resource").0, "Resources");
19663        assert_eq!(inventory_category_group("container").0, "Containers");
19664        assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
19665        assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
19666    }
19667
19668    #[test]
19669    fn page_list_index_clamps_without_wrap() {
19670        assert_eq!(page_list_index(0, -1, 25), 0);
19671        assert_eq!(page_list_index(0, 1, 25), 10);
19672        assert_eq!(page_list_index(12, 1, 25), 22);
19673        assert_eq!(page_list_index(22, 1, 25), 24);
19674        assert_eq!(page_list_index(5, 1, 0), 0);
19675        assert_eq!(page_list_index(3, -1, 8), 0);
19676    }
19677
19678    #[test]
19679    fn inventory_filter_hides_non_matching_person_items() {
19680        let mut state = sample_state();
19681        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
19682        sword.display_name = Some("Iron Sword".into());
19683        sword.category = Some("weapon".into());
19684        let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
19685        herb.display_name = Some("Wild Herb".into());
19686        herb.category = Some("consumable".into());
19687        state.inventory_stacks = vec![sword, herb];
19688        state.inventory_tab = InventoryTab::OnPerson;
19689        state.inventory_filter = "sword".into();
19690
19691        let rows = state.inventory_selectable_rows();
19692        assert_eq!(rows.len(), 1);
19693        assert_eq!(rows[0].stack.template_id, "iron_sword");
19694
19695        let lines = state.inventory_browser_lines();
19696        assert!(lines.iter().any(|l| matches!(
19697            l,
19698            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
19699        )));
19700        assert!(!lines.iter().any(|l| matches!(
19701            l,
19702            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
19703        )));
19704    }
19705
19706    #[test]
19707    fn list_filter_chars_reject_mac_arrow_glyphs() {
19708        assert!(is_list_filter_char('a'));
19709        assert!(is_list_filter_char(' '));
19710        assert!(is_list_filter_char('-'));
19711        assert!(!is_list_filter_char('\u{F700}'));
19712        assert!(!is_list_filter_char('\u{F701}'));
19713        assert!(!is_list_filter_char('\n'));
19714    }
19715
19716    #[test]
19717    fn ready_tab_keeps_in_progress_craft_after_inputs_spent() {
19718        let mut state = sample_state();
19719        state.craft_tab = CraftTab::Ready;
19720        state.blueprints = vec![BlueprintView {
19721            id: "plank".into(),
19722            label: "Plank".into(),
19723            craft_tier: 1,
19724            craft_ticks: 30,
19725            output: "wood_plank".into(),
19726            output_qty: 1,
19727            output_display_name: "Wood Plank".into(),
19728            station: None,
19729            category: None,
19730            inputs: vec![flatland_protocol::BlueprintIngredientView {
19731                template_id: "oak_log".into(),
19732                quantity: 1,
19733                consumed: true,
19734                display_name: "Oak Log".into(),
19735            }],
19736            required_tools: vec![],
19737            skill: None,
19738            failure_chance: 0.0,
19739            worker_train_copper: 0,
19740        }];
19741        // Materials already consumed at craft begin — would drop off Ready without the pin.
19742        state.inventory.clear();
19743        state.craft_channel_blueprint_id = Some("plank".into());
19744        state.timed_channel = Some(flatland_protocol::TimedChannelHud {
19745            label: "Crafting Plank".into(),
19746            channel: flatland_protocol::TimedChannelKind::Craft,
19747            ticks_remaining: 20,
19748            ticks_total: 30,
19749            ..Default::default()
19750        });
19751
19752        let idxs = state.craft_filtered_indices();
19753        assert_eq!(idxs, vec![0]);
19754        assert!(state.craft_blueprint_in_channel("plank"));
19755
19756        // Channel finished → clear pin → Ready empty.
19757        state.timed_channel = None;
19758        state.craft_channel_blueprint_id = None;
19759        assert!(state.craft_filtered_indices().is_empty());
19760    }
19761
19762    #[test]
19763    fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
19764        let mut state = sample_state();
19765        let id_a = uuid::Uuid::from_u128(0xa1);
19766        let id_b = uuid::Uuid::from_u128(0xb2);
19767        let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
19768        sword_a.display_name = Some("Iron Sword".into());
19769        sword_a.category = Some("weapon".into());
19770        sword_a.item_instance_id = Some(id_a);
19771        let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
19772        sword_b.display_name = Some("Iron Sword".into());
19773        sword_b.category = Some("weapon".into());
19774        sword_b.item_instance_id = Some(id_b);
19775        state.inventory_stacks = vec![sword_a, sword_b];
19776        state.inventory_tab = InventoryTab::OnPerson;
19777
19778        let lines = state.inventory_browser_lines();
19779        let items: Vec<_> = lines
19780            .iter()
19781            .filter_map(|l| match l {
19782                InventoryBrowserLine::Item {
19783                    title,
19784                    instance_tooltip,
19785                    ..
19786                } => Some((title.clone(), instance_tooltip.clone())),
19787                _ => None,
19788            })
19789            .collect();
19790        assert_eq!(items.len(), 2);
19791        for (title, tip) in &items {
19792            assert!(
19793                !title.contains('#'),
19794                "title should not show instance suffix: {title}"
19795            );
19796            assert!(
19797                tip.is_some(),
19798                "two identical rows should expose instance on hover"
19799            );
19800        }
19801
19802        state.inventory_stacks.pop();
19803        let lines = state.inventory_browser_lines();
19804        let one = lines.iter().find_map(|l| match l {
19805            InventoryBrowserLine::Item {
19806                title,
19807                instance_tooltip,
19808                ..
19809            } => Some((title.clone(), instance_tooltip.clone())),
19810            _ => None,
19811        });
19812        let (title, tip) = one.expect("one sword row");
19813        assert!(!title.contains('#'));
19814        assert!(tip.is_none(), "single row should not need instance tooltip");
19815    }
19816
19817    #[test]
19818    fn inventory_person_rows_group_by_category() {
19819        let mut state = sample_state();
19820        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
19821        sword.category = Some("weapon".into());
19822        sword.display_name = Some("Iron Sword".into());
19823        let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
19824        ore.category = Some("resource".into());
19825        ore.display_name = Some("Iron Ore".into());
19826        let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
19827        potion.category = Some("consumable".into());
19828        potion.display_name = Some("Health Potion".into());
19829        state.inventory_stacks = vec![ore, potion, sword];
19830        state.inventory_tab = InventoryTab::OnPerson;
19831
19832        let lines = state.inventory_browser_lines();
19833        let labels: Vec<&str> = lines
19834            .iter()
19835            .filter_map(|l| match l {
19836                InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
19837                _ => None,
19838            })
19839            .collect();
19840        assert!(
19841            labels.iter().any(|s| s.contains("Weapons")),
19842            "expected Weapons group: {labels:?}"
19843        );
19844        assert!(labels.iter().any(|s| s.contains("Consumables")));
19845        assert!(labels.iter().any(|s| s.contains("Resources")));
19846
19847        let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
19848        let consumable_pos = labels
19849            .iter()
19850            .position(|s| s.contains("Consumables"))
19851            .unwrap();
19852        let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
19853        assert!(weapon_pos < consumable_pos);
19854        assert!(consumable_pos < resource_pos);
19855    }
19856
19857    #[test]
19858    fn inventory_tab_cycle_resets_selection() {
19859        let mut state = sample_state();
19860        state.inventory_tab = InventoryTab::OnPerson;
19861        state.inventory_menu_index = 3;
19862        state.inventory_tab = state.inventory_tab.cycle(true);
19863        assert_eq!(state.inventory_tab, InventoryTab::Nearby);
19864        // Client method resets index; enum cycle alone does not — verify cycle labels.
19865        assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
19866        assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
19867        assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
19868        assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
19869    }
19870
19871    #[test]
19872    fn parse_bank_copper_amount_blank_and_zero_mean_all() {
19873        assert_eq!(parse_bank_copper_amount(""), Some(0));
19874        assert_eq!(parse_bank_copper_amount("  "), Some(0));
19875        assert_eq!(parse_bank_copper_amount("0"), Some(0));
19876        assert_eq!(parse_bank_copper_amount("250"), Some(250));
19877        assert_eq!(parse_bank_copper_amount("nope"), None);
19878    }
19879
19880    #[test]
19881    fn parse_storage_quantity_blank_and_zero_mean_all() {
19882        assert_eq!(parse_storage_quantity(""), Some(None));
19883        assert_eq!(parse_storage_quantity("  "), Some(None));
19884        assert_eq!(parse_storage_quantity("0"), Some(None));
19885        assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
19886        assert_eq!(parse_storage_quantity("nope"), None);
19887    }
19888
19889    #[test]
19890    fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
19891        assert!(worker_error_is_hud_noise("path stuck — repathing"));
19892        assert!(worker_error_is_hud_noise(
19893            "path stuck — nudged clear, repathing"
19894        ));
19895        assert!(worker_error_is_hud_noise(
19896            "returned to lodging after path failures"
19897        ));
19898        // Real problem: player may need to place lodging / fix assignment.
19899        assert!(!worker_error_is_hud_noise(
19900            "path stuck — no lodging to reset to"
19901        ));
19902        assert!(!worker_error_is_hud_noise("cannot reach Eli — idling"));
19903        assert!(worker_error_is_hud_noise(
19904            "path unreachable (plan failures 0, leg 0)"
19905        ));
19906        assert!(!worker_error_is_hud_noise(
19907            "path unreachable (plan failures 0, leg 24)"
19908        ));
19909        assert!(worker_error_is_transient("storage full; continuing route"));
19910        assert!(!worker_error_is_transient(
19911            "storage full (Food Bank) — free chest space or reassign deposit"
19912        ));
19913        assert!(!worker_error_is_hud_noise(
19914            "storage full (Food Bank) — free chest space or reassign deposit"
19915        ));
19916    }
19917
19918    #[test]
19919    fn leaving_building_restores_outdoor_z_bands() {
19920        use flatland_protocol::{InteriorMapView, ZPlatformView};
19921
19922        let mut state = sample_state();
19923        state.z_platforms.clear();
19924        state.z_transitions.clear();
19925        state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
19926        state.interior_map = Some(InteriorMapView {
19927            building_id: "broker_hut".into(),
19928            blueprint_id: "broker_hut".into(),
19929            background_color: "#000".into(),
19930            default_floor_color: None,
19931            floor_height_m: 3.0,
19932            z_platforms: vec![ZPlatformView {
19933                id: "floor_0".into(),
19934                z: 0.0,
19935                x0: 0.0,
19936                y0: 0.0,
19937                x1: 8.0,
19938                y1: 8.0,
19939            }],
19940            z_transitions: vec![],
19941            rooms: vec![],
19942            room_doors: vec![],
19943        });
19944        state.sync_interior_map_context();
19945        assert_eq!(
19946            state.z_platforms.len(),
19947            1,
19948            "indoors installs interior platforms"
19949        );
19950        assert!(state.z_bands_outdoor_backup.is_some());
19951
19952        state.player.as_mut().unwrap().inside_building = None;
19953        state.sync_interior_map_context();
19954        assert!(
19955            state.z_platforms.is_empty(),
19956            "leaving must restore outdoor bands (empty), not leave interior platforms"
19957        );
19958        assert!(state.z_bands_outdoor_backup.is_none());
19959        assert!(state.interior_map.is_none());
19960    }
19961
19962    #[test]
19963    fn resource_node_route_label_prefers_friendly_label_with_suffix() {
19964        let node = ResourceNodeView {
19965            id: "crop-carrot-1_copy10".into(),
19966            label: "crop-carrot-1_copy10".into(),
19967            x: 0.0,
19968            y: 0.0,
19969            z: 0.0,
19970            item_template: "carrot".into(),
19971            state: ResourceNodeState::Available,
19972            blocking: false,
19973            blocking_radius_m: 0.5,
19974            harvest_off: false,
19975            tile_id: None,
19976            yaw: 0.0,
19977            pitch: 0.0,
19978            roll: 0.0,
19979            draw_scale: 1.0,
19980            sprite_mode: None,
19981            growth_progress: None,
19982            presentation_state: None,
19983            channel_start_tick: None,
19984            channel_end_tick: None,
19985            harvest_drop_templates: vec![],
19986        };
19987        let label = super::resource_node_route_label(&node);
19988        assert!(label.starts_with("Carrot ("), "got {label}");
19989        assert!(label.ends_with(')'), "got {label}");
19990
19991        let mut named = node;
19992        named.label = "Sweet Pad".into();
19993        named.id = "crop-carrot-a3f2b1c0".into();
19994        assert_eq!(super::resource_node_route_label(&named), "Sweet Pad (b1c0)");
19995    }
19996
19997    #[test]
19998    fn plot_public_label_uses_owner_zone_and_label() {
19999        let plot = flatland_protocol::PropertyPlotView {
20000            plot_id: uuid::Uuid::nil(),
20001            property_zone_id: "zone_a".into(),
20002            zone_label: Some("Starter Town East 1".into()),
20003            deed_instance_id: uuid::Uuid::nil(),
20004            x0: 0.0,
20005            y0: 0.0,
20006            x1: 4.0,
20007            y1: 4.0,
20008            upkeep_copper_per_day: 1,
20009            arrears_days: 0,
20010            is_mine: true,
20011            may_farm: true,
20012            purchase_basis_copper: 0,
20013            farm_public: false,
20014            public_tax_discount_bps: 0,
20015            farm_allow: vec![],
20016            owner_character_id: None,
20017            owner_label: Some("Madsin".into()),
20018            building_id: None,
20019            plot_code: "xyz1234a".into(),
20020            label: "Food Pad".into(),
20021        };
20022        assert_eq!(
20023            super::plot_public_label(&plot),
20024            "Madsin — Starter Town East 1 — Food Pad"
20025        );
20026    }
20027
20028    #[test]
20029    fn plot_public_label_uses_size_when_label_and_code_blank() {
20030        let plot = flatland_protocol::PropertyPlotView {
20031            plot_id: uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap(),
20032            property_zone_id: String::new(),
20033            zone_label: None,
20034            deed_instance_id: uuid::Uuid::nil(),
20035            x0: 10.0,
20036            y0: 20.0,
20037            x1: 18.0,
20038            y1: 28.0,
20039            upkeep_copper_per_day: 1,
20040            arrears_days: 0,
20041            is_mine: true,
20042            may_farm: true,
20043            purchase_basis_copper: 0,
20044            farm_public: false,
20045            public_tax_discount_bps: 0,
20046            farm_allow: vec![],
20047            owner_character_id: None,
20048            owner_label: None,
20049            building_id: None,
20050            plot_code: String::new(),
20051            label: String::new(),
20052        };
20053        assert_eq!(super::plot_public_label(&plot), "Homestead — Plot (8×8 m)");
20054        assert!(!super::plot_public_label(&plot).contains("19fe35f"));
20055    }
20056
20057    #[test]
20058    fn plot_stop_label_prefers_view_over_hex() {
20059        let plot_id = uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap();
20060        let plot = flatland_protocol::PropertyPlotView {
20061            plot_id,
20062            property_zone_id: "zone_a".into(),
20063            zone_label: Some("Starter Town East".into()),
20064            deed_instance_id: uuid::Uuid::nil(),
20065            x0: 0.0,
20066            y0: 0.0,
20067            x1: 4.0,
20068            y1: 4.0,
20069            upkeep_copper_per_day: 1,
20070            arrears_days: 0,
20071            is_mine: true,
20072            may_farm: true,
20073            purchase_basis_copper: 0,
20074            farm_public: false,
20075            public_tax_discount_bps: 0,
20076            farm_allow: vec![],
20077            owner_character_id: None,
20078            owner_label: Some("Madsin".into()),
20079            building_id: None,
20080            plot_code: "xyz1234a".into(),
20081            label: "Food Pad".into(),
20082        };
20083        assert_eq!(
20084            super::plot_stop_label(&[plot.clone()], plot_id),
20085            "Madsin — Starter Town East — Food Pad"
20086        );
20087        let missing = uuid::Uuid::parse_str("1a001590-0000-4000-8000-000000000002").unwrap();
20088        assert_eq!(super::plot_stop_label(&[plot], missing), "plot 1a001590");
20089    }
20090}