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}
938
939#[derive(Debug, Clone, PartialEq)]
940pub enum MoveOptionKind {
941    Move {
942        location: flatland_protocol::InventoryLocation,
943        parent_instance_id: Option<uuid::Uuid>,
944    },
945    /// Pick up a placed chest/crate; optionally nest into a worn bag afterward.
946    PickupPlaced {
947        container_id: String,
948        nest_location: flatland_protocol::InventoryLocation,
949        nest_parent_instance_id: Option<uuid::Uuid>,
950    },
951    /// Enter map relocate mode for a placed chest (no pickup).
952    RelocatePlaced {
953        container_id: String,
954    },
955    /// Eat/drink a loose consumable (one unit per use).
956    Use,
957    /// Open grant-target picker for `grants_item_status` consumables.
958    GrantApply,
959    Drop,
960    /// Sell the plot tied to a property deed back to the crown.
961    SellPlotToCrown {
962        plot_id: uuid::Uuid,
963    },
964    Cancel,
965}
966
967/// One navigable row in the deed farm-access panel.
968#[derive(Debug, Clone, PartialEq)]
969pub enum FarmAccessRow {
970    PublicToggle,
971    PublicDiscount,
972    AllowRemove {
973        character_id: uuid::Uuid,
974        label: String,
975        tax_discount_bps: u32,
976    },
977    NearbyAdd {
978        name: String,
979    },
980}
981
982/// Active "apply grant onto…" picker (fortify oil, frost edge scroll, …).
983#[derive(Debug, Clone)]
984pub struct GrantTargetPicker {
985    pub grant_instance_id: uuid::Uuid,
986    pub grant_label: String,
987    pub effect_id: String,
988    pub mode: String,
989    pub options: Vec<GrantTargetOption>,
990    pub filter: String,
991    pub filter_focused: bool,
992}
993
994#[derive(Debug, Clone)]
995pub struct GrantTargetOption {
996    pub label: String,
997    pub target_instance_id: uuid::Uuid,
998}
999
1000/// Active "move to…" destination picker state for the selected inventory item.
1001#[derive(Debug, Clone)]
1002pub struct MovePicker {
1003    pub item_instance_id: uuid::Uuid,
1004    pub from: flatland_protocol::InventoryLocation,
1005    pub item_label: String,
1006    pub template_id: String,
1007    pub stack_quantity: u32,
1008    pub quantity: u32,
1009    pub options: Vec<MoveOption>,
1010    pub filter: String,
1011    pub filter_focused: bool,
1012}
1013
1014/// Active permanent-delete picker for the selected inventory item.
1015#[derive(Debug, Clone)]
1016pub struct DestroyPicker {
1017    pub item_instance_id: uuid::Uuid,
1018    pub from: flatland_protocol::InventoryLocation,
1019    pub item_label: String,
1020    pub stack_quantity: u32,
1021    pub quantity: u32,
1022}
1023
1024/// One giveable stack in the workers-menu give picker.
1025#[derive(Debug, Clone)]
1026pub struct WorkerGiveOption {
1027    pub item_instance_id: uuid::Uuid,
1028    pub label: String,
1029    pub quantity: u32,
1030    pub template_id: String,
1031}
1032
1033/// Give an on-person inventory stack to the selected hired worker.
1034#[derive(Debug, Clone)]
1035pub struct WorkerGivePicker {
1036    pub worker_instance_id: String,
1037    pub worker_label: String,
1038    pub options: Vec<WorkerGiveOption>,
1039}
1040
1041/// Nearby hired worker choice when giving a selected inventory stack (`g`).
1042#[derive(Debug, Clone)]
1043pub struct WorkerGiveTargetOption {
1044    pub instance_id: String,
1045    pub label: String,
1046    pub distance_m: f32,
1047}
1048
1049/// Pick which nearby worker receives the selected inventory item.
1050#[derive(Debug, Clone)]
1051pub struct WorkerGiveTargetPicker {
1052    pub item_instance_id: uuid::Uuid,
1053    pub item_label: String,
1054    pub quantity: Option<u32>,
1055    pub options: Vec<WorkerGiveTargetOption>,
1056}
1057
1058/// Take an item from a hired worker back into the employer's inventory.
1059#[derive(Debug, Clone)]
1060pub struct WorkerTakePicker {
1061    pub worker_instance_id: String,
1062    pub worker_label: String,
1063    pub options: Vec<WorkerGiveOption>,
1064    /// How many of the selected stack to take (1..=stack quantity).
1065    pub quantity: u32,
1066}
1067
1068/// Max distance (m) to hand an item to a hired worker.
1069pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
1070
1071/// One teachable blueprint in the workers-menu teach picker.
1072#[derive(Debug, Clone)]
1073pub struct WorkerTeachOption {
1074    pub blueprint_id: String,
1075    pub label: String,
1076    pub cost_copper: u64,
1077    pub min_level: u32,
1078    pub worker_level: u32,
1079    pub can_afford: bool,
1080    pub level_ok: bool,
1081}
1082
1083/// Teach a known blueprint to the selected hired worker.
1084#[derive(Debug, Clone)]
1085pub struct WorkerTeachPicker {
1086    pub worker_instance_id: String,
1087    pub worker_label: String,
1088    pub worker_level: u32,
1089    pub options: Vec<WorkerTeachOption>,
1090}
1091
1092/// Selected worker awaiting confirmation before permanent dismissal.
1093#[derive(Debug, Clone)]
1094pub struct WorkerDismissConfirmation {
1095    pub worker_instance_id: String,
1096    pub worker_label: String,
1097}
1098
1099/// Sticky workers-menu step line — holds a coarse label so travel/harvest ticks
1100/// do not thrash the UI.
1101#[derive(Debug, Clone, Default)]
1102pub struct StickyWorkerStep {
1103    shown: String,
1104    pending: String,
1105    pending_since: Option<Instant>,
1106}
1107
1108impl StickyWorkerStep {
1109    fn from_label(label: String) -> Self {
1110        Self {
1111            shown: label.clone(),
1112            pending: label,
1113            pending_since: Some(Instant::now()),
1114        }
1115    }
1116
1117    fn observe(&mut self, label: &str, now: Instant) {
1118        let pending_since = self.pending_since.unwrap_or(now);
1119        if label == self.pending {
1120            if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD {
1121                self.shown = self.pending.clone();
1122            }
1123            return;
1124        }
1125        self.pending = label.to_string();
1126        self.pending_since = Some(now);
1127        // Empty → first value, or first observation: show immediately.
1128        if self.shown.is_empty() {
1129            self.shown = self.pending.clone();
1130        }
1131    }
1132}
1133
1134/// Last non-transient worker error — held so route flicker stays readable.
1135/// Path→lodging recoveries are log-only (`worker_error_is_hud_noise`) and not held.
1136#[derive(Debug, Clone, Default)]
1137pub struct StickyWorkerError {
1138    message: String,
1139    last_seen: Option<Instant>,
1140}
1141
1142impl StickyWorkerError {
1143    fn observe(&mut self, err: Option<&str>, now: Instant) {
1144        if let Some(e) = err {
1145            if !worker_error_is_transient(e) && !worker_error_is_hud_noise(e) {
1146                self.message = e.to_string();
1147                self.last_seen = Some(now);
1148            }
1149            return;
1150        }
1151        if let Some(seen) = self.last_seen {
1152            if now.duration_since(seen) > WORKER_ERROR_HOLD {
1153                self.message.clear();
1154                self.last_seen = None;
1155            }
1156        }
1157    }
1158
1159    pub fn shown(&self, now: Instant) -> Option<&str> {
1160        if self.message.is_empty() {
1161            return None;
1162        }
1163        let seen = self.last_seen?;
1164        if now.duration_since(seen) > WORKER_ERROR_HOLD {
1165            return None;
1166        }
1167        Some(self.message.as_str())
1168    }
1169}
1170
1171/// First non-transient worker issue for the status bar (strike, route error, etc.).
1172/// Routine recovery noise (path failures → lodging) stays in the game log only.
1173pub fn worker_attention_line(state: &GameState) -> Option<String> {
1174    use flatland_protocol::WorkerStateView;
1175    let now = Instant::now();
1176    for w in &state.hired_workers {
1177        if matches!(w.state, WorkerStateView::Strike) {
1178            return Some(format!(
1179                "Worker {}: on strike — fund bank, pay wages, or stock lodging chest",
1180                w.label
1181            ));
1182        }
1183        let sticky = state
1184            .worker_error_display
1185            .get(&w.instance_id)
1186            .and_then(|s| s.shown(now))
1187            .filter(|e| !worker_error_is_hud_noise(e));
1188        let live = w
1189            .last_error
1190            .as_deref()
1191            .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e));
1192        if let Some(err) = sticky.or(live) {
1193            if let Some(hint) = w
1194                .issue_hint
1195                .as_deref()
1196                .filter(|h| !h.is_empty())
1197                .or_else(|| worker_issue_fix_hint(err))
1198            {
1199                return Some(format!("Worker {}: {err} — {hint}", w.label));
1200            }
1201            return Some(format!("Worker {}: {err}", w.label));
1202        }
1203        // Plan issue with hint but cleared last_error (sticky already shown above).
1204        if let Some(hint) = w.issue_hint.as_deref().filter(|h| !h.is_empty()) {
1205            return Some(format!("Worker {}: {hint}", w.label));
1206        }
1207    }
1208    None
1209}
1210
1211/// Fallback fix copy when the server did not send `issue_hint`.
1212pub fn worker_issue_fix_hint(err: &str) -> Option<&'static str> {
1213    let e = err.to_ascii_lowercase();
1214    if e.contains("missing")
1215        || e.contains("container not found")
1216        || e.contains("lodging container not found")
1217    {
1218        return Some("edit route (e): replace the missing chest/bed");
1219    }
1220    if e.contains("stranded at interior") || e.contains("interior map coords") {
1221        return Some("recovered — continuing route");
1222    }
1223    if e.contains("stuck inside")
1224        || e.contains("sent outside")
1225        || e.contains("sent to door")
1226        || e.contains("left building")
1227    {
1228        return Some("auto-exit for outdoor work — restart after update if it still loops");
1229    }
1230    if e.contains("collapsed") || e.contains("need food") {
1231        return Some("stock lodging bed with food and drink");
1232    }
1233    if e.contains("overburdened") {
1234        return Some("add a deposit/sell stop, or empty their pack");
1235    }
1236    if e.contains("storage full") {
1237        return Some("empty or upgrade the destination chest, or reassign the deposit");
1238    }
1239    if e.contains("need a hoe") || e.contains("need a dibber") {
1240        return Some("give them the tool or withdraw it on the route");
1241    }
1242    None
1243}
1244
1245/// True when a worker `last_error` is transient noise (soft-skip chatter).
1246pub fn worker_error_is_transient(err: &str) -> bool {
1247    let e = err.to_ascii_lowercase();
1248    e.contains("continuing route") || e.starts_with("nothing to withdraw")
1249}
1250
1251/// True for routine worker recoveries that should not paint the status bar.
1252/// Still logged when they change (see [`GameState::apply_hired_workers`]).
1253pub fn worker_error_is_hud_noise(err: &str) -> bool {
1254    let e = err.to_ascii_lowercase();
1255    // Durable complete-or-idle park must stay visible on HUD and attention lines.
1256    if e.contains("idling") && (e.contains("cannot reach") || e.contains("unreachable")) {
1257        return false;
1258    }
1259    e.contains("returned to lodging after path")
1260        || e.contains("path failure")
1261        || e.contains("no path to")
1262        || e.contains("pathfinding")
1263        // Soft stuck recovery — worker keeps working / replans; not a player action item.
1264        || e.contains("repathing")
1265        || e.contains("nudged clear")
1266        // Auto space rescue — informative in the log, not a red alert.
1267        || e.contains("auto-recovery")
1268        || e.contains("stranded at interior map coords")
1269}
1270
1271/// In-flight `SetWorkerJob` waiting for IntentAck (or a reject Interaction).
1272#[derive(Debug, Clone)]
1273pub struct PendingWorkerJobAck {
1274    pub seq: u32,
1275    pub worker_instance_id: String,
1276    pub worker_label: String,
1277    pub idle: bool,
1278    pub stop_count: usize,
1279    pub prev_route: Option<flatland_protocol::WorkerRouteView>,
1280    pub prev_mode: flatland_protocol::WorkerModeView,
1281    pub prev_step_label: String,
1282    pub prev_last_error: Option<String>,
1283}
1284
1285fn push_inventory_rows(
1286    rows: &mut Vec<InventoryRow>,
1287    depth: usize,
1288    stack: &flatland_protocol::ItemStack,
1289    from: &flatland_protocol::InventoryLocation,
1290    from_parent_instance_id: Option<uuid::Uuid>,
1291    section: InventorySection,
1292) {
1293    push_inventory_rows_filtered(
1294        rows,
1295        depth,
1296        stack,
1297        from,
1298        from_parent_instance_id,
1299        section,
1300        "",
1301    );
1302}
1303
1304fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
1305    if filter.is_empty() {
1306        return true;
1307    }
1308    let f = filter.to_ascii_lowercase();
1309    let name = stack
1310        .display_name
1311        .as_deref()
1312        .unwrap_or("")
1313        .to_ascii_lowercase();
1314    let tid = stack.template_id.to_ascii_lowercase();
1315    name.contains(&f)
1316        || tid.contains(&f)
1317        || stack
1318            .contents
1319            .iter()
1320            .any(|c| stack_matches_filter(c, filter))
1321}
1322
1323fn push_inventory_rows_filtered(
1324    rows: &mut Vec<InventoryRow>,
1325    depth: usize,
1326    stack: &flatland_protocol::ItemStack,
1327    from: &flatland_protocol::InventoryLocation,
1328    from_parent_instance_id: Option<uuid::Uuid>,
1329    section: InventorySection,
1330    filter: &str,
1331) {
1332    if !filter.is_empty() && !stack_matches_filter(stack, filter) {
1333        return;
1334    }
1335    let self_hit = filter.is_empty() || {
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) || tid.contains(&f)
1344    };
1345    rows.push(InventoryRow {
1346        depth,
1347        stack: stack.clone(),
1348        from: from.clone(),
1349        from_parent_instance_id,
1350        is_equip_shell: false,
1351        is_chest_shell: false,
1352        section,
1353    });
1354    for child in &stack.contents {
1355        if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
1356            push_inventory_rows_filtered(
1357                rows,
1358                depth + 1,
1359                child,
1360                from,
1361                stack.item_instance_id,
1362                section,
1363                if self_hit { "" } else { filter },
1364            );
1365        }
1366    }
1367}
1368
1369#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1370pub enum ShopTab {
1371    #[default]
1372    Buy,
1373    Sell,
1374}
1375
1376#[derive(Debug, Clone)]
1377pub struct NpcChatState {
1378    pub npc_id: String,
1379    pub npc_label: String,
1380    pub lines: Vec<String>,
1381    pub input: String,
1382    pub pending: bool,
1383    pub talk_depth: flatland_protocol::NpcTalkDepth,
1384    pub trade_allowed: bool,
1385    pub banner: Option<String>,
1386    pub suggested_topics: Vec<String>,
1387}
1388
1389impl Default for NpcChatState {
1390    fn default() -> Self {
1391        Self {
1392            npc_id: String::new(),
1393            npc_label: String::new(),
1394            lines: Vec::new(),
1395            input: String::new(),
1396            pending: false,
1397            talk_depth: flatland_protocol::NpcTalkDepth::Full,
1398            trade_allowed: true,
1399            banner: None,
1400            suggested_topics: Vec::new(),
1401        }
1402    }
1403}
1404
1405/// World XY for HUD, probes, and gfx: prefer live entity transform for wildlife combat bodies.
1406pub fn npc_world_xy(state: &GameState, npc: &NpcView) -> (f32, f32) {
1407    npc.entity_id
1408        .and_then(|eid| state.entities.iter().find(|e| e.id == eid))
1409        .map(|e| (e.transform.position.x, e.transform.position.y))
1410        .unwrap_or((npc.x, npc.y))
1411}
1412
1413#[derive(Debug, Clone)]
1414pub struct GameState {
1415    pub session_id: SessionId,
1416    pub entity_id: EntityId,
1417    /// Logged-in character — used to show owner-only container labels.
1418    pub character_id: Option<uuid::Uuid>,
1419    pub tick: Tick,
1420    pub chunk_rev: u64,
1421    pub content_rev: u64,
1422    pub publish_rev: u64,
1423    pub entities: Vec<EntityState>,
1424    pub player: Option<EntityState>,
1425    pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1426    /// Worker Harvest picker cache — region-wide nodes from welcome, kept across AOI ticks.
1427    pub harvest_route_nodes: Vec<flatland_protocol::ResourceNodeView>,
1428    pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1429    pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1430    pub buildings: Vec<BuildingView>,
1431    pub doors: Vec<DoorView>,
1432    pub interior_map: Option<InteriorMapView>,
1433    pub npcs: Vec<NpcView>,
1434    pub blueprints: Vec<BlueprintView>,
1435    /// Wall/roof packs for the B plot-build menu.
1436    pub building_materials: Vec<flatland_protocol::BuildingMaterialView>,
1437    /// Outdoor play AABB origin (meters).
1438    pub world_x0: f32,
1439    pub world_y0: f32,
1440    pub world_width_m: f32,
1441    pub world_height_m: f32,
1442    pub terrain_zones: Vec<TerrainZoneView>,
1443    pub z_platforms: Vec<ZPlatformView>,
1444    pub z_transitions: Vec<ZTransitionView>,
1445    /// Outdoor z-bands saved before an interior overwrite; restored when leaving.
1446    /// Tick deltas never refresh `z_platforms`, so without this, interior platforms stick.
1447    #[doc(hidden)]
1448    pub z_bands_outdoor_backup: Option<(Vec<ZPlatformView>, Vec<ZTransitionView>)>,
1449    pub world_clock: flatland_protocol::WorldClock,
1450    pub inventory: std::collections::HashMap<String, u32>,
1451    pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1452    /// Server item catalog (labels/categories). Survives inventory resync.
1453    pub item_catalog: std::collections::HashMap<String, ItemCatalogEntryView>,
1454    pub logs: VecDeque<String>,
1455    pub intents_sent: u64,
1456    pub ticks_received: u64,
1457    pub connected: bool,
1458    pub disconnect_reason: Option<String>,
1459    pub show_stats: bool,
1460    /// When true, the bottom system LOG dock is collapsed (sidebar gains the space).
1461    pub hud_log_hidden: bool,
1462    pub show_equip_menu: bool,
1463    pub equip_menu_index: usize,
1464    pub show_craft_menu: bool,
1465    pub craft_menu_index: usize,
1466    /// How many timed crafts to queue when confirming the craft menu.
1467    pub craft_batch_quantity: u32,
1468    /// Active craft list tab (Ready default).
1469    pub craft_tab: CraftTab,
1470    /// Search within the active craft tab (`/` to focus).
1471    pub craft_filter: String,
1472    pub craft_filter_focused: bool,
1473    /// Per-character favorites / recent (loaded when character id is known).
1474    pub craft_prefs: crate::craft_prefs::CraftCharacterPrefs,
1475    /// B — wall/roof material picker for plot buildings.
1476    pub show_plot_build_menu: bool,
1477    /// True = navigate walls; false = navigate roofs (Tab switches).
1478    pub plot_build_focus_wall: bool,
1479    pub plot_build_wall_index: usize,
1480    pub plot_build_roof_index: usize,
1481    pub show_shop_menu: bool,
1482    pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1483    pub bank_panel: Option<flatland_protocol::BankPanel>,
1484    pub bank_menu_index: usize,
1485    pub bank_ui_mode: BankUiMode,
1486    pub storage_panel: Option<flatland_protocol::StoragePanel>,
1487    pub market_panel: Option<flatland_protocol::MarketPanel>,
1488    /// Selected row among filtered market browse listings.
1489    pub market_menu_index: usize,
1490    /// Browse / list-pick text filter (`/` to focus).
1491    pub market_filter: String,
1492    pub market_filter_focused: bool,
1493    /// Category group filter (`None` = All). Values match [`inventory_category_group`] labels.
1494    pub market_category_filter: Option<&'static str>,
1495    /// Pending buy confirm: (listing_id, qty, unit_price, line_total, display_name).
1496    pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
1497    pub market_ui_mode: MarketUiMode,
1498    pub storage_menu_index: usize,
1499    pub storage_ui_mode: StorageUiMode,
1500    pub shop_tab: ShopTab,
1501    pub shop_menu_index: usize,
1502    pub shop_quantity: u32,
1503    /// Recent buy/sell lines while the shop panel is open (gfx dock).
1504    pub shop_trade_log: VecDeque<String>,
1505    pub show_npc_verb_menu: bool,
1506    pub npc_verb_target: Option<String>,
1507    pub npc_verb_index: usize,
1508    /// Last refuse/system line shown while the Talk/Trade verb dock is open.
1509    pub npc_verb_notice: Option<String>,
1510    /// Nearby player Speak / Whisper / Trade dock.
1511    pub player_verbs: crate::social::PlayerVerbState,
1512    pub social_chat: crate::social::SocialChatState,
1513    pub trade_ui: crate::social::TradeUiState,
1514    pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1515    pub show_npc_chat: bool,
1516    pub npc_chat: Option<NpcChatState>,
1517    pub show_inventory_menu: bool,
1518    pub inventory_menu_index: usize,
1519    pub inventory_tab: InventoryTab,
1520    pub inventory_filter: String,
1521    pub inventory_filter_focused: bool,
1522    pub show_move_picker: bool,
1523    pub move_picker_index: usize,
1524    pub move_picker: Option<MovePicker>,
1525    pub show_grant_picker: bool,
1526    pub grant_picker_index: usize,
1527    pub grant_picker: Option<GrantTargetPicker>,
1528    pub show_destroy_picker: bool,
1529    pub destroy_confirm_pending: bool,
1530    pub destroy_picker: Option<DestroyPicker>,
1531    /// Rename prompt for a selected container (`n` in inventory).
1532    pub show_rename_prompt: bool,
1533    /// When set, the rename prompt targets a property plot (deed or on-plot).
1534    pub rename_plot_id: Option<uuid::Uuid>,
1535    /// Plot AABB to emphasize on the map (farm picker / deed focus).
1536    pub highlighted_plot_id: Option<uuid::Uuid>,
1537    /// Rename prompt for a hired worker (`n` in workers menu).
1538    pub show_worker_rename: bool,
1539    pub rename_buffer: String,
1540    /// Slot-1 combat target (mirrors server after SetTarget).
1541    pub combat_target: Option<EntityId>,
1542    pub combat_target_label: Option<String>,
1543    /// Local free-aim ground target (world meters) set via Shift+click on open ground.
1544    /// Used as `Intent::Cast.target_point` for ground/either aim-mode abilities.
1545    pub ground_target: Option<(f32, f32, f32)>,
1546    /// Active combat footprints from the server (`plans/39`).
1547    pub combat_fx: Vec<flatland_protocol::CombatFx>,
1548    /// Lingering ground hazards (boss puddles) from the server.
1549    pub ground_hazards: Vec<flatland_protocol::GroundHazardView>,
1550    /// Authored crown property zones (claimable land) — plan 40.
1551    pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1552    /// Tax overlays for claim cost premium preview.
1553    pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1554    /// Growth / fertility overlays (farming, respawn).
1555    pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1556    /// Climate / biome overlays.
1557    pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1558    /// Terrain kind speeds / impassable flags for auto-nav (from content).
1559    pub terrain_kind_nav: Vec<flatland_protocol::TerrainKindNavView>,
1560    /// Claimed property plots near the observer.
1561    pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1562    /// Server knobs for client-side claim quotes.
1563    pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1564    /// Local claim-footprint editor (not server state).
1565    pub claim_mode: Option<ClaimModeState>,
1566    /// Local relocate ghost for a placed chest/lodging (not server state).
1567    pub relocate_mode: Option<RelocateModeState>,
1568    /// Second `f` within a short window confirms selling this plot back to the crown.
1569    pub sell_plot_confirm: Option<uuid::Uuid>,
1570    /// When `sell_plot_confirm` was armed (for double-tap window).
1571    pub sell_plot_armed_at: Option<Instant>,
1572    /// Choose seed type when planting on owned tilled soil (`f`).
1573    pub show_plant_menu: bool,
1574    pub plant_menu_index: usize,
1575    /// Deed-holder farm access panel (public toggle + allow-list).
1576    pub show_farm_access: bool,
1577    /// Draft name for adding a tenant from the farm-access panel.
1578    pub farm_access_name_draft: String,
1579    /// Public / grant discount draft (bps) while editing farm access.
1580    pub farm_access_discount_bps: u32,
1581    /// Selected row in the farm-access panel (0 = public toggle).
1582    pub farm_access_index: usize,
1583    pub plant_quantity: u32,
1584    pub in_combat: bool,
1585    pub auto_attack: bool,
1586    pub combat_has_los: bool,
1587    pub attack_cd_ticks: u64,
1588    pub gcd_ticks: u64,
1589    pub weapon_ability_id: String,
1590    pub mainhand_template_id: Option<String>,
1591    pub mainhand_label: Option<String>,
1592    pub mainhand_instance_id: Option<uuid::Uuid>,
1593    pub offhand_template_id: Option<String>,
1594    pub offhand_label: Option<String>,
1595    pub offhand_instance_id: Option<uuid::Uuid>,
1596    pub mainhand_hand_slots: u8,
1597    pub defense: Option<flatland_protocol::DefenseHud>,
1598    /// Worn body-slot items — armor, cloak, jewelry, backpack, belt.
1599    pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1600    pub carry_mass: f32,
1601    pub carry_mass_max: f32,
1602    pub encumbrance: flatland_protocol::EncumbranceState,
1603    /// Walk m/s after encumbrance / DEX (from combat HUD). `0` until first HUD.
1604    pub move_speed_mps: f32,
1605    /// Encumbrance+status walk multiplier (`1.0` unloaded). `0` until first HUD.
1606    pub move_speed_mult: f32,
1607    /// Full nested inventory stacks from the server (root only; worn are separate).
1608    pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1609    /// Keys stowed on the virtual keychain (zero carry mass).
1610    pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1611    /// Whisper stones in the pouch (zero carry mass).
1612    pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1613    /// Active status effects on the local player (buff/debuff strip).
1614    pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1615    pub combat_target_detail: Option<CombatTargetHud>,
1616    pub cast_progress: Option<CastProgressHud>,
1617    /// Till / plant / other non-combat timed channels on the local player.
1618    pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1619    /// Owned plot underfoot offer (pad + storage pool) for the B menu.
1620    pub plot_build_offer: Option<flatland_protocol::PlotBuildOfferHud>,
1621    pub ability_cooldowns: Vec<AbilityCooldownHud>,
1622    pub blocking_active: bool,
1623    pub max_target_slots: u8,
1624    pub combat_slots: Vec<CombatSlotHud>,
1625    pub rotation_presets: Vec<RotationPreset>,
1626    /// Learned abilities from combat HUD (usable casts).
1627    pub known_abilities: Vec<String>,
1628    /// Aim / blast metadata for known abilities (ground cast UX), keyed by ability id.
1629    pub ability_meta: std::collections::HashMap<String, flatland_protocol::AbilityMetaHud>,
1630    /// Per-ability mastery from combat HUD (tier / XP).
1631    pub ability_mastery: std::collections::HashMap<String, flatland_protocol::AbilityMasteryHud>,
1632    /// Server-persisted hotbar bindings (index 0 = key 1).
1633    pub hotbar: Vec<Option<String>>,
1634    /// Max abilities allowed in one rotation (mind score).
1635    pub max_abilities_per_rotation: u8,
1636    pub show_loadout_menu: bool,
1637    pub show_keychain_menu: bool,
1638    pub keychain_menu_index: usize,
1639    pub show_rotation_editor: bool,
1640    /// Selected rotation preset in the loadout menu.
1641    pub loadout_menu_index: usize,
1642    /// Selected hotbar slot (`1`–`9`) for bind/clear in the loadout menu.
1643    pub loadout_hotbar_slot: u8,
1644    /// Selected known-ability row in the loadout menu.
1645    pub loadout_ability_index: usize,
1646    /// When true, ↑/↓ navigate presets; when false, navigate known abilities.
1647    pub loadout_focus_presets: bool,
1648    pub rotation_editor: RotationEditorState,
1649    /// True after a harvest intent is accepted until result/reject/disconnect.
1650    pub harvest_in_progress: bool,
1651    /// Wall-clock start of the current harvest; clears stale client state on timeout.
1652    pub harvest_started_at: Option<Instant>,
1653    /// Craft log deferred until the server acks the craft intent.
1654    pub pending_craft_ack: Option<(u32, String, u32)>,
1655    /// Blueprint kept on the Ready tab while its craft channel runs (inputs
1656    /// are spent at begin, so `can_craft` goes false before progress finishes).
1657    pub craft_channel_blueprint_id: Option<String>,
1658    pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1659    pub interactables: Vec<flatland_protocol::InteractableView>,
1660    pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1661    pub career: Option<flatland_protocol::PlayerCareerView>,
1662    pub character_sheet_tab: CharacterSheetTab,
1663    pub ledger_period: LedgerPeriod,
1664    pub show_quest_offer: bool,
1665    pub pending_quest_offers: Vec<flatland_protocol::QuestOffer>,
1666    pub quest_offer_index: usize,
1667    pub show_quest_menu: bool,
1668    pub quest_menu_index: usize,
1669    pub quest_withdraw_confirm: bool,
1670    pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1671    pub show_workers_menu: bool,
1672    pub workers_menu_index: usize,
1673    pub worker_dismiss_confirmation: Option<WorkerDismissConfirmation>,
1674    /// Workers panel: one-line rows instead of full cards (more on screen).
1675    pub workers_menu_compact: bool,
1676    /// Coarse `step:` line held per worker so AOI ticks cannot thrash the menu.
1677    /// Public so out-of-crate tests can construct [`GameState`].
1678    pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1679    /// Held worker route errors (readable when server clears `last_error` each tick).
1680    pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1681    /// Wall-clock expiry for defender HP rings after their last observed hit.
1682    pub worker_health_ring_until: BTreeMap<EntityId, Instant>,
1683    /// Set after a hire request until its worker appears in a roster update.
1684    pub pending_worker_hire_since: Option<Instant>,
1685    /// Give-item sheet opened from the workers menu (`g`) — pick which item to give.
1686    pub show_worker_give_picker: bool,
1687    pub worker_give_picker_index: usize,
1688    pub worker_give_picker: Option<WorkerGivePicker>,
1689    /// Inventory `g` — pick which nearby worker receives the selected stack.
1690    pub show_worker_give_target_picker: bool,
1691    pub worker_give_target_picker_index: usize,
1692    pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1693    /// Take-item sheet opened from the workers menu (`i`) — pick which worker stack to take.
1694    pub show_worker_take_picker: bool,
1695    pub worker_take_picker_index: usize,
1696    pub worker_take_picker: Option<WorkerTakePicker>,
1697    /// Teach-blueprint sheet opened from the workers menu (`t`).
1698    pub show_worker_teach_picker: bool,
1699    pub worker_teach_picker_index: usize,
1700    pub worker_teach_picker: Option<WorkerTeachPicker>,
1701    /// Active harvest-route editor (`h` → `e` on a worker).
1702    pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1703    /// Awaiting IntentAck for the last route save (`SetWorkerJob`).
1704    pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1705    /// Worker currently paused via `AttendHiredWorker` (opened with `f`).
1706    pub attending_worker_instance_id: Option<String>,
1707    /// Server progression curve from the latest combat HUD (matches server-settings.yaml).
1708    pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1709}
1710
1711#[derive(Debug, Clone, PartialEq, Eq)]
1712pub enum NpcVerbAction {
1713    Talk,
1714    Trade,
1715    Bank,
1716    Storage,
1717    Market,
1718    QuestTalk { quest_id: String },
1719    QuestGive { quest_id: String },
1720}
1721
1722#[derive(Debug, Clone, PartialEq, Eq)]
1723pub struct NpcVerbChoice {
1724    pub label: String,
1725    pub action: NpcVerbAction,
1726}
1727
1728impl std::fmt::Display for NpcVerbChoice {
1729    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1730        f.write_str(&self.label)
1731    }
1732}
1733
1734impl GameState {
1735    pub fn selected_quest_offer(&self) -> Option<&flatland_protocol::QuestOffer> {
1736        self.pending_quest_offers.get(self.quest_offer_index)
1737    }
1738
1739    pub fn push_quest_offer(&mut self, offer: flatland_protocol::QuestOffer) {
1740        if self
1741            .pending_quest_offers
1742            .iter()
1743            .any(|existing| existing.quest_id == offer.quest_id)
1744        {
1745            self.show_quest_offer = true;
1746            return;
1747        }
1748        self.pending_quest_offers.push(offer);
1749        self.show_quest_offer = true;
1750    }
1751
1752    pub fn remove_quest_offer(&mut self, quest_id: &str) {
1753        self.pending_quest_offers
1754            .retain(|offer| offer.quest_id != quest_id);
1755        if self.pending_quest_offers.is_empty() {
1756            self.show_quest_offer = false;
1757            self.quest_offer_index = 0;
1758            return;
1759        }
1760        self.quest_offer_index = self
1761            .quest_offer_index
1762            .min(self.pending_quest_offers.len() - 1);
1763        self.show_quest_offer = true;
1764    }
1765
1766    pub fn clear_quest_offers(&mut self) {
1767        self.pending_quest_offers.clear();
1768        self.quest_offer_index = 0;
1769        self.show_quest_offer = false;
1770    }
1771
1772    pub fn move_quest_offer_selection(&mut self, delta: i32) {
1773        let n = self.pending_quest_offers.len();
1774        if n == 0 {
1775            self.quest_offer_index = 0;
1776            return;
1777        }
1778        let idx = self.quest_offer_index as i32;
1779        self.quest_offer_index = (idx + delta).rem_euclid(n as i32) as usize;
1780    }
1781
1782    pub fn push_log(&mut self, line: impl Into<String>) {
1783        self.logs.push_back(line.into());
1784        while self.logs.len() > MAX_LOG_LINES {
1785            self.logs.pop_front();
1786        }
1787    }
1788
1789    pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1790        self.shop_trade_log.push_back(line.into());
1791        while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1792            self.shop_trade_log.pop_front();
1793        }
1794    }
1795
1796    pub fn clear_shop_trade_log(&mut self) {
1797        self.shop_trade_log.clear();
1798    }
1799
1800    fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1801        if !self.show_shop_menu {
1802            return;
1803        }
1804        let msg = notice.message.trim();
1805        if msg.is_empty() {
1806            return;
1807        }
1808        if notice.coins_delta != 0
1809            || msg.starts_with("Bought ")
1810            || msg.starts_with("Sold ")
1811            || msg.contains("taught you how to craft")
1812            || msg.starts_with("need ")
1813        {
1814            self.push_shop_trade_log(msg);
1815        }
1816    }
1817
1818    pub fn is_alive(&self) -> bool {
1819        self.player
1820            .as_ref()
1821            .and_then(|p| p.vitals)
1822            .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1823            .unwrap_or(true)
1824    }
1825
1826    pub fn push_audio(&mut self, cue: crate::social::AudioCue) {
1827        self.social_chat.push_cue(cue);
1828    }
1829
1830    /// Rising/falling edges for combat and progression SFX (call after snapshot/tick apply).
1831    fn sync_gameplay_audio(&mut self) {
1832        use crate::social::AudioCue;
1833        use flatland_protocol::PrimaryAttributes;
1834
1835        let alive = self.is_alive();
1836        let casting = self.cast_progress.is_some();
1837        let telegraph = self.focus_attack_telegraph_active();
1838        let in_aoe = self.player_inside_spatial_telegraph();
1839        let quest_sig = self.quest_audio_signature();
1840        let entity_id = self.entity_id;
1841        let char_level = self
1842            .player
1843            .as_ref()
1844            .and_then(|p| p.attributes)
1845            .map(|a| {
1846                PrimaryAttributes::display(a.strength)
1847                    .saturating_add(PrimaryAttributes::display(a.dexterity))
1848                    .saturating_add(PrimaryAttributes::display(a.intelligence))
1849                    .saturating_add(PrimaryAttributes::display(a.stamina))
1850                    .saturating_add(PrimaryAttributes::display(a.vitality))
1851                    .saturating_add(PrimaryAttributes::display(a.wisdom))
1852                    .saturating_add(PrimaryAttributes::display(a.charisma))
1853            })
1854            .unwrap_or(0);
1855
1856        let fx_ids: Vec<u64> = self.combat_fx.iter().map(|fx| fx.id).collect();
1857        let mut hit_cues = Vec::new();
1858        {
1859            let seen = &self.social_chat.audio_seen_fx_ids;
1860            for fx in &self.combat_fx {
1861                if seen.contains(&fx.id) {
1862                    continue;
1863                }
1864                let Some(hit) = fx.hits.iter().find(|h| h.entity_id == entity_id) else {
1865                    continue;
1866                };
1867                if hit.outcome == CombatFxHitOutcome::Blocked {
1868                    hit_cues.push(AudioCue::CombatBlock);
1869                } else {
1870                    let heavy = matches!(
1871                        fx.kind,
1872                        CombatFxKind::Sphere | CombatFxKind::Cone | CombatFxKind::Beam
1873                    );
1874                    hit_cues.push(if heavy {
1875                        AudioCue::CombatHitHeavy
1876                    } else {
1877                        AudioCue::CombatHitLight
1878                    });
1879                }
1880            }
1881        }
1882
1883        let audio = &mut self.social_chat;
1884        if !audio.audio_bootstrapped {
1885            audio.audio_was_alive = alive;
1886            audio.audio_was_casting = casting;
1887            audio.audio_had_target_telegraph = telegraph;
1888            audio.audio_was_in_aoe = in_aoe;
1889            audio.audio_quest_sig = quest_sig;
1890            audio.audio_char_level = char_level;
1891            audio.audio_seen_fx_ids = fx_ids;
1892            audio.audio_bootstrapped = true;
1893            return;
1894        }
1895
1896        if telegraph && !audio.audio_had_target_telegraph {
1897            audio.push_cue(AudioCue::CombatTelegraphStart);
1898        } else if !telegraph && audio.audio_had_target_telegraph {
1899            audio.push_cue(AudioCue::CombatTelegraphImpact);
1900        }
1901        audio.audio_had_target_telegraph = telegraph;
1902
1903        if in_aoe && !audio.audio_was_in_aoe {
1904            audio.push_cue(AudioCue::CombatAoeWarn);
1905        }
1906        audio.audio_was_in_aoe = in_aoe;
1907
1908        if casting && !audio.audio_was_casting {
1909            audio.push_cue(AudioCue::AbilityCastSelf);
1910        }
1911        audio.audio_was_casting = casting;
1912
1913        if !alive && audio.audio_was_alive {
1914            audio.push_cue(AudioCue::PlayerDeath);
1915        }
1916        audio.audio_was_alive = alive;
1917
1918        if quest_sig != audio.audio_quest_sig && audio.audio_quest_sig != 0 {
1919            audio.push_cue(AudioCue::QuestUpdate);
1920        }
1921        audio.audio_quest_sig = quest_sig;
1922
1923        if char_level > audio.audio_char_level && audio.audio_char_level > 0 {
1924            audio.push_cue(AudioCue::LevelUp);
1925        }
1926        audio.audio_char_level = char_level;
1927
1928        for cue in hit_cues {
1929            audio.push_cue(cue);
1930        }
1931        audio.audio_seen_fx_ids = fx_ids;
1932    }
1933
1934    fn focus_attack_telegraph_active(&self) -> bool {
1935        let Some(tid) = self.combat_target else {
1936            return false;
1937        };
1938        self.entities
1939            .iter()
1940            .find(|e| e.id == tid)
1941            .map(|e| {
1942                e.combat_cues.iter().any(|c| {
1943                    matches!(c.kind, CombatCueKind::AttackTelegraph) && c.until_tick > self.tick
1944                })
1945            })
1946            .unwrap_or(false)
1947    }
1948
1949    fn player_inside_spatial_telegraph(&self) -> bool {
1950        let (px, py) = self.player_position();
1951        for e in &self.entities {
1952            for cue in &e.combat_cues {
1953                if !matches!(cue.kind, CombatCueKind::AttackTelegraph)
1954                    || cue.until_tick <= self.tick
1955                {
1956                    continue;
1957                }
1958                let Some(kind) = cue.telegraph_kind else {
1959                    continue;
1960                };
1961                let (ox, oy) = match (cue.origin_x, cue.origin_y) {
1962                    (Some(x), Some(y)) => (x, y),
1963                    _ => continue,
1964                };
1965                match kind {
1966                    CombatFxKind::Sphere => {
1967                        let r = cue.radius_m.unwrap_or(1.0);
1968                        let dx = px - ox;
1969                        let dy = py - oy;
1970                        if dx * dx + dy * dy <= r * r {
1971                            return true;
1972                        }
1973                    }
1974                    CombatFxKind::Cone | CombatFxKind::MeleeArc => {
1975                        let reach = cue.reach_m.unwrap_or(2.0);
1976                        let yaw = cue.yaw.unwrap_or(0.0);
1977                        let arc = cue.arc_deg.unwrap_or(90.0).to_radians();
1978                        let dx = px - ox;
1979                        let dy = py - oy;
1980                        let dist = (dx * dx + dy * dy).sqrt();
1981                        if dist > reach || dist < 0.05 {
1982                            continue;
1983                        }
1984                        let ang = dx.atan2(dy);
1985                        let mut delta = ang - yaw;
1986                        while delta > std::f32::consts::PI {
1987                            delta -= std::f32::consts::TAU;
1988                        }
1989                        while delta < -std::f32::consts::PI {
1990                            delta += std::f32::consts::TAU;
1991                        }
1992                        if delta.abs() <= arc * 0.5 {
1993                            return true;
1994                        }
1995                    }
1996                    _ => {}
1997                }
1998            }
1999        }
2000        false
2001    }
2002
2003    fn quest_audio_signature(&self) -> u64 {
2004        use std::collections::hash_map::DefaultHasher;
2005        use std::hash::{Hash, Hasher};
2006        let mut h = DefaultHasher::new();
2007        for q in &self.quest_log {
2008            q.quest_id.hash(&mut h);
2009            format!("{:?}", q.status).hash(&mut h);
2010            q.current_step_id.hash(&mut h);
2011            for o in &q.objectives {
2012                o.done.hash(&mut h);
2013                o.current.hash(&mut h);
2014            }
2015        }
2016        h.finish()
2017    }
2018
2019    /// Verb menu entries for the current `npc_verb_target`.
2020    pub fn npc_verb_options(&self) -> Vec<NpcVerbChoice> {
2021        let Some(ref id) = self.npc_verb_target else {
2022            return vec![];
2023        };
2024        let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
2025            return self.with_quest_verbs(id, vec![Self::talk_choice()]);
2026        };
2027        let role = npc.role.as_str();
2028        let rest = if Self::npc_role_is_bank(role) {
2029            vec![
2030                NpcVerbChoice {
2031                    label: "Bank".into(),
2032                    action: NpcVerbAction::Bank,
2033                },
2034                Self::talk_choice(),
2035            ]
2036        } else if Self::npc_role_is_storage(role) {
2037            vec![
2038                NpcVerbChoice {
2039                    label: "Storage".into(),
2040                    action: NpcVerbAction::Storage,
2041                },
2042                Self::talk_choice(),
2043            ]
2044        } else if Self::npc_role_is_market(role) {
2045            vec![
2046                NpcVerbChoice {
2047                    label: "Market".into(),
2048                    action: NpcVerbAction::Market,
2049                },
2050                Self::talk_choice(),
2051            ]
2052        } else if npc.can_trade || Self::npc_role_can_trade(role) {
2053            vec![
2054                Self::talk_choice(),
2055                NpcVerbChoice {
2056                    label: "Trade".into(),
2057                    action: NpcVerbAction::Trade,
2058                },
2059            ]
2060        } else {
2061            vec![Self::talk_choice()]
2062        };
2063        self.with_quest_verbs(id, rest)
2064    }
2065
2066    fn talk_choice() -> NpcVerbChoice {
2067        NpcVerbChoice {
2068            label: "Talk".into(),
2069            action: NpcVerbAction::Talk,
2070        }
2071    }
2072
2073    fn with_quest_verbs(&self, npc_id: &str, rest: Vec<NpcVerbChoice>) -> Vec<NpcVerbChoice> {
2074        let mut opts = self.quest_verb_choices(npc_id);
2075        opts.extend(rest);
2076        opts
2077    }
2078
2079    fn quest_verb_choices(&self, npc_id: &str) -> Vec<NpcVerbChoice> {
2080        if let Some(npc) = self.npcs.iter().find(|n| n.id == npc_id) {
2081            if !npc.quest_verbs.is_empty() {
2082                return npc
2083                    .quest_verbs
2084                    .iter()
2085                    .map(|v| {
2086                        let action = if v.kind == flatland_protocol::NpcQuestVerb::KIND_GIVE {
2087                            NpcVerbAction::QuestGive {
2088                                quest_id: v.quest_id.clone(),
2089                            }
2090                        } else {
2091                            NpcVerbAction::QuestTalk {
2092                                quest_id: v.quest_id.clone(),
2093                            }
2094                        };
2095                        NpcVerbChoice {
2096                            label: v.label.clone(),
2097                            action,
2098                        }
2099                    })
2100                    .collect();
2101            }
2102        }
2103        let catalog_ref = self.npc_quest_catalog_ref(npc_id);
2104        let mut opts = Vec::new();
2105        for q in &self.quest_log {
2106            if q.status != flatland_protocol::QuestStatusView::Active {
2107                continue;
2108            }
2109            let title = if q.title.trim().is_empty() {
2110                "Quest".to_string()
2111            } else {
2112                q.title.clone()
2113            };
2114            for o in &q.objectives {
2115                if o.done || o.npc_ref.as_deref() != Some(catalog_ref.as_str()) {
2116                    continue;
2117                }
2118                if o.kind == "give_item" {
2119                    opts.push(NpcVerbChoice {
2120                        label: format!("Turn in: {title}"),
2121                        action: NpcVerbAction::QuestGive {
2122                            quest_id: q.quest_id.clone(),
2123                        },
2124                    });
2125                } else if o.kind == "talk_npc" {
2126                    opts.push(NpcVerbChoice {
2127                        label: title.clone(),
2128                        action: NpcVerbAction::QuestTalk {
2129                            quest_id: q.quest_id.clone(),
2130                        },
2131                    });
2132                }
2133            }
2134        }
2135        opts
2136    }
2137
2138    fn npc_quest_catalog_ref(&self, npc_id: &str) -> String {
2139        self.npcs
2140            .iter()
2141            .find(|n| n.id == npc_id)
2142            .and_then(|n| n.paperdoll_ref.clone())
2143            .unwrap_or_else(|| npc_id.to_string())
2144    }
2145
2146    fn count_inventory_template(&self, template: &str) -> u32 {
2147        self.inventory_stacks
2148            .iter()
2149            .filter(|s| s.template_id == template)
2150            .map(|s| s.quantity)
2151            .sum()
2152    }
2153
2154    fn npc_role_can_trade(role: &str) -> bool {
2155        matches!(role, "broker" | "cook" | "farmer" | "merchant")
2156    }
2157
2158    fn npc_role_is_bank(role: &str) -> bool {
2159        role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
2160    }
2161
2162    fn npc_role_is_storage(role: &str) -> bool {
2163        role.eq_ignore_ascii_case("storage_manager")
2164    }
2165
2166    fn npc_role_is_market(role: &str) -> bool {
2167        role.eq_ignore_ascii_case("market_clerk")
2168    }
2169
2170    pub fn bank_menu_options(&self) -> Vec<&'static str> {
2171        vec![
2172            "Deposit…",
2173            "Withdraw…",
2174            "Deposit all",
2175            "Withdraw all",
2176            "Transfer…",
2177        ]
2178    }
2179
2180    pub fn storage_menu_options(&self) -> Vec<String> {
2181        let mut opts = vec!["Store…".into(), "Take…".into()];
2182        if let Some(panel) = &self.storage_panel {
2183            for dest in &panel.ship_destinations {
2184                opts.push(format!(
2185                    "Ship → {} ({} cp / {} ticks)",
2186                    dest.label, dest.fee_copper, dest.travel_ticks
2187                ));
2188            }
2189        }
2190        opts
2191    }
2192
2193    /// Loose on-person stacks eligible for town-storage store.
2194    /// Excludes hand-equipped weapons/tools (unequip first) — worn body gear is
2195    /// already outside root inventory.
2196    pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
2197        let equipped = self.hand_equipped_instance_ids();
2198        self.person_rows()
2199            .into_iter()
2200            .filter(|r| r.depth == 0)
2201            .filter_map(|r| {
2202                let id = r.stack.item_instance_id?;
2203                if equipped.contains(&id) {
2204                    return None;
2205                }
2206                Some(StoragePickOption {
2207                    item_instance_id: id,
2208                    template_id: r.stack.template_id.clone(),
2209                    label: storage_stack_label(&r.stack),
2210                    quantity: r.stack.quantity,
2211                    category: r.stack.category.clone().unwrap_or_default(),
2212                })
2213            })
2214            .collect()
2215    }
2216
2217    /// Instance ids currently occupying mainhand / offhand (for store / list filters).
2218    pub fn hand_equipped_instance_ids(&self) -> std::collections::HashSet<uuid::Uuid> {
2219        let mut ids = std::collections::HashSet::new();
2220        if let Some(id) = self.mainhand_instance_id {
2221            ids.insert(id);
2222        } else if let Some(tid) = &self.mainhand_template_id {
2223            if let Some(id) = self
2224                .inventory_stacks
2225                .iter()
2226                .find(|s| &s.template_id == tid)
2227                .and_then(|s| s.item_instance_id)
2228            {
2229                ids.insert(id);
2230            }
2231        }
2232        if let Some(id) = self.offhand_instance_id {
2233            ids.insert(id);
2234        } else if let Some(tid) = &self.offhand_template_id {
2235            if let Some(id) = self
2236                .inventory_stacks
2237                .iter()
2238                .find(|s| {
2239                    &s.template_id == tid
2240                        && s.item_instance_id.is_some_and(|iid| !ids.contains(&iid))
2241                })
2242                .and_then(|s| s.item_instance_id)
2243            {
2244                ids.insert(id);
2245            }
2246        }
2247        ids
2248    }
2249
2250    /// Vault stacks eligible for take / ship.
2251    pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
2252        let Some(panel) = &self.storage_panel else {
2253            return Vec::new();
2254        };
2255        panel
2256            .contents
2257            .iter()
2258            .filter_map(|s| {
2259                let id = s.item_instance_id?;
2260                Some(StoragePickOption {
2261                    item_instance_id: id,
2262                    template_id: s.template_id.clone(),
2263                    label: storage_stack_label(s),
2264                    quantity: s.quantity,
2265                    category: s.category.clone().unwrap_or_default(),
2266                })
2267            })
2268            .collect()
2269    }
2270
2271    /// Source rows for market list: on-person, then each eligible vault (only if they hold stacks).
2272    pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
2273        let mut opts = Vec::new();
2274        if !self
2275            .market_list_item_options(&MarketListSourceKind::Person)
2276            .is_empty()
2277        {
2278            opts.push((MarketListSourceKind::Person, "On person".into()));
2279        }
2280        if let Some(panel) = &self.market_panel {
2281            for vault in &panel.list_vaults {
2282                let source = MarketListSourceKind::TownStorage {
2283                    building_id: vault.building_id.clone(),
2284                };
2285                if self.market_list_item_options(&source).is_empty() {
2286                    continue;
2287                }
2288                let label = if vault.building_label.is_empty() {
2289                    format!("Town storage ({})", vault.building_id)
2290                } else {
2291                    format!("Town storage — {}", vault.building_label)
2292                };
2293                opts.push((source, label));
2294            }
2295        }
2296        opts
2297    }
2298
2299    /// Stacks eligible to list from the chosen market source (excludes non-listable templates).
2300    pub fn market_list_item_options(
2301        &self,
2302        source: &MarketListSourceKind,
2303    ) -> Vec<StoragePickOption> {
2304        let filter = self.market_filter.as_str();
2305        let cat_filter = self.market_category_filter;
2306        let mut opts: Vec<StoragePickOption> = match source {
2307            MarketListSourceKind::Person => {
2308                let equipped = self.hand_equipped_instance_ids();
2309                self.person_rows()
2310                    .into_iter()
2311                    .filter(|r| r.depth == 0)
2312                    .filter(|r| self.stack_is_market_listable(&r.stack))
2313                    .filter_map(|r| {
2314                        let id = r.stack.item_instance_id?;
2315                        if equipped.contains(&id) {
2316                            return None;
2317                        }
2318                        Some(StoragePickOption {
2319                            item_instance_id: id,
2320                            template_id: r.stack.template_id.clone(),
2321                            label: storage_stack_label(&r.stack),
2322                            quantity: r.stack.quantity,
2323                            category: r
2324                                .stack
2325                                .category
2326                                .clone()
2327                                .or_else(|| {
2328                                    self.inventory_item_category(&r.stack.template_id)
2329                                        .map(str::to_string)
2330                                })
2331                                .unwrap_or_default(),
2332                        })
2333                    })
2334                    .collect()
2335            }
2336            MarketListSourceKind::TownStorage { building_id } => {
2337                let Some(panel) = &self.market_panel else {
2338                    return Vec::new();
2339                };
2340                let Some(vault) = panel
2341                    .list_vaults
2342                    .iter()
2343                    .find(|v| &v.building_id == building_id)
2344                else {
2345                    return Vec::new();
2346                };
2347                vault
2348                    .contents
2349                    .iter()
2350                    .filter(|s| self.stack_is_market_listable(s))
2351                    .filter_map(|s| {
2352                        let id = s.item_instance_id?;
2353                        Some(StoragePickOption {
2354                            item_instance_id: id,
2355                            template_id: s.template_id.clone(),
2356                            label: storage_stack_label(s),
2357                            quantity: s.quantity,
2358                            category: s
2359                                .category
2360                                .clone()
2361                                .or_else(|| {
2362                                    self.inventory_item_category(&s.template_id)
2363                                        .map(str::to_string)
2364                                })
2365                                .unwrap_or_default(),
2366                        })
2367                    })
2368                    .collect()
2369            }
2370        };
2371        opts.retain(|o| {
2372            if !list_label_matches(&o.label, filter) {
2373                return false;
2374            }
2375            if let Some(group) = cat_filter {
2376                inventory_category_group(&o.category).0 == group
2377            } else {
2378                true
2379            }
2380        });
2381        opts
2382    }
2383
2384    /// Catalog `base_value_copper` when synced on stacks or inventory hints.
2385    pub fn item_base_value_copper_hint(&self, template_id: &str) -> Option<u32> {
2386        if let Some(hint) = self.inventory_hints.get(template_id) {
2387            if let Some(v) = hint.base_value_copper.filter(|v| *v > 0) {
2388                return Some(v);
2389            }
2390        }
2391        if let Some(v) = self
2392            .inventory_stacks
2393            .iter()
2394            .find(|s| s.template_id == template_id)
2395            .and_then(|s| s.base_value_copper.filter(|v| *v > 0))
2396        {
2397            return Some(v);
2398        }
2399        self.market_panel.as_ref().and_then(|panel| {
2400            panel.list_vaults.iter().find_map(|vault| {
2401                vault.contents.iter().find_map(|stack| {
2402                    (stack.template_id == template_id)
2403                        .then(|| stack.base_value_copper.filter(|v| *v > 0))
2404                        .flatten()
2405                })
2406            })
2407        })
2408    }
2409
2410    /// Estimated net copper per unit for hall NPC-price dump queue (default buyer rates).
2411    pub fn npc_market_dump_unit_estimate(&self, template_id: &str) -> Option<u32> {
2412        let base = self.item_base_value_copper_hint(template_id)?;
2413        npc_market_dump_unit_estimate_copper(base)
2414    }
2415
2416    fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
2417        if crate::currency::is_currency(&stack.template_id) {
2418            return false;
2419        }
2420        if let Some(flag) = stack.listable {
2421            return flag;
2422        }
2423        if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
2424            return hint.listable;
2425        }
2426        let cat = stack
2427            .category
2428            .as_deref()
2429            .or_else(|| self.inventory_item_category(&stack.template_id))
2430            .unwrap_or("");
2431        category_default_listable(cat)
2432    }
2433
2434    /// Category group labels present in the current browse book or list-pick source.
2435    pub fn market_available_category_groups(&self) -> Vec<&'static str> {
2436        let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
2437        match &self.market_ui_mode {
2438            MarketUiMode::ListPick { source, .. } => {
2439                let raw: Vec<_> = match source {
2440                    MarketListSourceKind::Person => self
2441                        .person_rows()
2442                        .into_iter()
2443                        .filter(|r| r.depth == 0)
2444                        .filter(|r| self.stack_is_market_listable(&r.stack))
2445                        .filter(|r| {
2446                            list_label_matches(&storage_stack_label(&r.stack), &self.market_filter)
2447                        })
2448                        .map(|r| {
2449                            r.stack
2450                                .category
2451                                .clone()
2452                                .or_else(|| {
2453                                    self.inventory_item_category(&r.stack.template_id)
2454                                        .map(str::to_string)
2455                                })
2456                                .unwrap_or_default()
2457                        })
2458                        .collect(),
2459                    MarketListSourceKind::TownStorage { building_id } => self
2460                        .market_panel
2461                        .as_ref()
2462                        .and_then(|p| p.list_vaults.iter().find(|v| &v.building_id == building_id))
2463                        .map(|vault| {
2464                            vault
2465                                .contents
2466                                .iter()
2467                                .filter(|s| self.stack_is_market_listable(s))
2468                                .filter(|s| {
2469                                    list_label_matches(&storage_stack_label(s), &self.market_filter)
2470                                })
2471                                .map(|s| {
2472                                    s.category
2473                                        .clone()
2474                                        .or_else(|| {
2475                                            self.inventory_item_category(&s.template_id)
2476                                                .map(str::to_string)
2477                                        })
2478                                        .unwrap_or_default()
2479                                })
2480                                .collect::<Vec<_>>()
2481                        })
2482                        .unwrap_or_default(),
2483                };
2484                for category in raw {
2485                    let (label, ord) = inventory_category_group(&category);
2486                    seen.insert(ord, label);
2487                }
2488            }
2489            _ => {
2490                if let Some(panel) = &self.market_panel {
2491                    for listing in &panel.listings {
2492                        if !list_label_matches(&listing.display_name, &self.market_filter)
2493                            && !list_label_matches(&listing.seller_label, &self.market_filter)
2494                        {
2495                            continue;
2496                        }
2497                        let (label, ord) = inventory_category_group(&listing.category);
2498                        seen.insert(ord, label);
2499                    }
2500                }
2501            }
2502        }
2503        seen.into_values().collect()
2504    }
2505
2506    /// Indices into `market_panel.listings` after category + text filter.
2507    pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
2508        let Some(panel) = &self.market_panel else {
2509            return Vec::new();
2510        };
2511        let filter = self.market_filter.as_str();
2512        let cat_filter = self.market_category_filter;
2513        panel
2514            .listings
2515            .iter()
2516            .enumerate()
2517            .filter(|(_, listing)| {
2518                if !list_label_matches(&listing.display_name, filter)
2519                    && !list_label_matches(&listing.seller_label, filter)
2520                    && !list_label_matches(&listing.template_id, filter)
2521                {
2522                    return false;
2523                }
2524                if let Some(group) = cat_filter {
2525                    inventory_category_group(&listing.category).0 == group
2526                } else {
2527                    true
2528                }
2529            })
2530            .map(|(i, _)| i)
2531            .collect()
2532    }
2533
2534    pub fn clear_harvest_state(&mut self) {
2535        self.harvest_in_progress = false;
2536        self.harvest_started_at = None;
2537    }
2538
2539    fn harvest_state_stale(&self) -> bool {
2540        match self.harvest_started_at {
2541            Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
2542            None => self.harvest_in_progress,
2543        }
2544    }
2545
2546    pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
2547        self.player.as_ref().and_then(|p| p.vitals)
2548    }
2549
2550    pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
2551        let materials_ok = blueprint.inputs.iter().all(|input| {
2552            self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
2553        });
2554        let tools_ok = blueprint
2555            .required_tools
2556            .iter()
2557            .all(|tool| self.player_has_craft_tool(&tool.item));
2558        let station_ok = match blueprint.station.as_deref() {
2559            None | Some("hand") => true,
2560            Some(tag) => self.player_at_station_tag(tag),
2561        };
2562        materials_ok
2563            && tools_ok
2564            && station_ok
2565            && self.craft_has_vessel_room_for_output(blueprint)
2566    }
2567
2568    /// Inventory/worn **or** a nearby placed furniture tool in the same space.
2569    pub fn player_has_craft_tool(&self, tool_template: &str) -> bool {
2570        if self.inventory.get(tool_template).copied().unwrap_or(0) >= 1 {
2571            return true;
2572        }
2573        let Some(player) = self.player.as_ref() else {
2574            return false;
2575        };
2576        let px = player.transform.position.x;
2577        let py = player.transform.position.y;
2578        // Match sim `CONTAINER_INTERACTION_RADIUS_M`.
2579        const RANGE: f32 = 3.0;
2580        self.placed_containers.iter().any(|c| {
2581            if c.template_id != tool_template {
2582                return false;
2583            }
2584            if !self.placed_container_in_current_space(c) {
2585                return false;
2586            }
2587            let dx = c.x - px;
2588            let dy = c.y - py;
2589            dx * dx + dy * dy <= RANGE * RANGE
2590        })
2591    }
2592
2593    /// Bulk/liquid craft outputs need an empty (or matching) vessel after inputs drain.
2594    fn craft_output_needs_vessel(&self, blueprint: &BlueprintView) -> bool {
2595        matches!(
2596            self.inventory_item_category(&blueprint.output),
2597            Some("bulk") | Some("liquid")
2598        ) || matches!(
2599            blueprint.output.as_str(),
2600            "dirt" | "mud" | "sand" | "water" | "milk"
2601        )
2602    }
2603
2604    fn craft_output_category(&self, blueprint: &BlueprintView) -> Option<&str> {
2605        self.inventory_item_category(&blueprint.output).or_else(|| {
2606            match blueprint.output.as_str() {
2607                "dirt" | "mud" | "sand" => Some("bulk"),
2608                "water" | "milk" => Some("liquid"),
2609                _ => None,
2610            }
2611        })
2612    }
2613
2614    fn craft_has_vessel_room_for_output(&self, blueprint: &BlueprintView) -> bool {
2615        if !self.craft_output_needs_vessel(blueprint) {
2616            return true;
2617        }
2618        let need = blueprint.output_qty.max(1);
2619        self.vessel_room_after_craft_inputs(blueprint) >= need
2620    }
2621
2622    /// Free capacity in vessels that can hold `blueprint.output` after consuming inputs.
2623    fn vessel_room_after_craft_inputs(&self, blueprint: &BlueprintView) -> u32 {
2624        let mut stacks = self.inventory_stacks.clone();
2625        for worn in self.worn.values() {
2626            stacks.push(worn.clone());
2627        }
2628        for input in &blueprint.inputs {
2629            let mut left = input.quantity;
2630            drain_payload_from_stacks(&mut stacks, &input.template_id, &mut left);
2631            if left > 0 {
2632                return 0;
2633            }
2634        }
2635        vessel_room_for_payload_in_stacks(
2636            &stacks,
2637            &blueprint.output,
2638            self.craft_output_category(blueprint),
2639        )
2640    }
2641
2642    /// Vessel inventory transparency for the craft detail panel.
2643    pub fn craft_vessel_status(&self, blueprint: &BlueprintView) -> CraftVesselStatus {
2644        let output_label = self.blueprint_output_label(blueprint);
2645        let needs_vessel = self.craft_output_needs_vessel(blueprint);
2646        let need_units = if needs_vessel {
2647            blueprint.output_qty.max(1)
2648        } else {
2649            0
2650        };
2651        let free_after_inputs = if needs_vessel {
2652            self.vessel_room_after_craft_inputs(blueprint)
2653        } else {
2654            0
2655        };
2656        let payload_cat = self.craft_output_category(blueprint);
2657        let mut vessels = Vec::new();
2658        Self::collect_craft_vessel_lines(
2659            &self.inventory_stacks,
2660            "pack",
2661            &blueprint.output,
2662            payload_cat,
2663            &mut vessels,
2664        );
2665        for worn in self.worn.values() {
2666            Self::collect_craft_vessel_lines(
2667                std::slice::from_ref(worn),
2668                "worn",
2669                &blueprint.output,
2670                payload_cat,
2671                &mut vessels,
2672            );
2673        }
2674        CraftVesselStatus {
2675            needs_vessel,
2676            output_label,
2677            need_units,
2678            free_after_inputs,
2679            ok: !needs_vessel || free_after_inputs >= need_units,
2680            vessels,
2681        }
2682    }
2683
2684    fn collect_craft_vessel_lines(
2685        stacks: &[flatland_protocol::ItemStack],
2686        location: &'static str,
2687        payload_id: &str,
2688        payload_category: Option<&str>,
2689        out: &mut Vec<CraftVesselLine>,
2690    ) {
2691        for stack in stacks {
2692            if is_serving_vessel_stack(stack) {
2693                let cap = serving_capacity_of(stack);
2694                let used = payload_units_in_vessel(stack);
2695                let free = vessel_free_room_for_payload(stack, payload_id, payload_category);
2696                let holds = stack
2697                    .props
2698                    .get("serving_holds")
2699                    .cloned()
2700                    .unwrap_or_else(|| {
2701                        if stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
2702                            && stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
2703                        {
2704                            "liquid,bulk".into()
2705                        } else if stack.props.get("bulk_vessel").is_some_and(|v| v == "1") {
2706                            "bulk".into()
2707                        } else if stack.props.get("liquid_vessel").is_some_and(|v| v == "1") {
2708                            "liquid".into()
2709                        } else {
2710                            "?".into()
2711                        }
2712                    });
2713                let label = stack
2714                    .display_name
2715                    .clone()
2716                    .unwrap_or_else(|| stack.template_id.clone());
2717                out.push(CraftVesselLine {
2718                    label,
2719                    holds,
2720                    capacity: cap,
2721                    used,
2722                    free,
2723                    quantity: stack.quantity.max(1),
2724                    accepts_output: free > 0,
2725                    location,
2726                });
2727            }
2728            Self::collect_craft_vessel_lines(
2729                &stack.contents,
2730                location,
2731                payload_id,
2732                payload_category,
2733                out,
2734            );
2735        }
2736    }
2737
2738    fn craft_prefs_key(&self) -> String {
2739        if let Some(cid) = self.character_id {
2740            cid.to_string()
2741        } else if self.entity_id != 0 {
2742            format!("entity:{}", self.entity_id)
2743        } else {
2744            String::new()
2745        }
2746    }
2747
2748    pub fn reload_craft_prefs(&mut self) {
2749        let key = self.craft_prefs_key();
2750        self.craft_prefs = crate::craft_prefs::load_for_character(&key);
2751    }
2752
2753    fn persist_craft_prefs(&self) {
2754        crate::craft_prefs::save_for_character(&self.craft_prefs_key(), &self.craft_prefs);
2755    }
2756
2757    /// Distinct craft tiers present in known blueprints (sorted ascending).
2758    pub fn craft_known_tiers(&self) -> Vec<u32> {
2759        let mut tiers: Vec<u32> = self
2760            .blueprints
2761            .iter()
2762            .map(|bp| bp.craft_tier.max(1))
2763            .collect::<std::collections::BTreeSet<_>>()
2764            .into_iter()
2765            .collect();
2766        tiers.sort_unstable();
2767        tiers
2768    }
2769
2770    /// Tab strip order: Ready, ★, Recent, then T1..Tn that have known recipes.
2771    pub fn craft_tab_strip(&self) -> Vec<CraftTab> {
2772        let mut tabs = vec![CraftTab::Ready, CraftTab::Favorites, CraftTab::Recent];
2773        for t in self.craft_known_tiers() {
2774            tabs.push(CraftTab::Tier(t));
2775        }
2776        tabs
2777    }
2778
2779    pub fn craft_set_tab(&mut self, tab: CraftTab) {
2780        self.craft_tab = tab;
2781        self.craft_menu_index = 0;
2782        self.clamp_craft_menu_index();
2783        self.clamp_craft_batch_quantity();
2784    }
2785
2786    pub fn craft_cycle_tab(&mut self, delta: i32) {
2787        let tabs = self.craft_tab_strip();
2788        if tabs.is_empty() {
2789            return;
2790        }
2791        let cur = tabs
2792            .iter()
2793            .position(|t| *t == self.craft_tab)
2794            .unwrap_or(0) as i32;
2795        let next = (cur + delta).rem_euclid(tabs.len() as i32) as usize;
2796        self.craft_set_tab(tabs[next]);
2797    }
2798
2799    pub fn craft_matches_search(&self, bp: &BlueprintView) -> bool {
2800        let f = self.craft_filter.trim();
2801        if f.is_empty() {
2802            return true;
2803        }
2804        if list_label_matches(&bp.label, f)
2805            || list_label_matches(&bp.output, f)
2806            || list_label_matches(&bp.output_display_name, f)
2807            || bp
2808                .category
2809                .as_deref()
2810                .is_some_and(|c| list_label_matches(c, f))
2811            || bp
2812                .station
2813                .as_deref()
2814                .is_some_and(|s| list_label_matches(s, f))
2815        {
2816            return true;
2817        }
2818        bp.inputs.iter().any(|i| {
2819            list_label_matches(&i.template_id, f) || list_label_matches(&i.display_name, f)
2820        }) || bp.required_tools.iter().any(|t| {
2821            list_label_matches(&t.item, f) || list_label_matches(&t.display_name, f)
2822        })
2823    }
2824
2825    /// True while this blueprint's craft channel is running (Ready-tab pin).
2826    pub fn craft_blueprint_in_channel(&self, blueprint_id: &str) -> bool {
2827        self.craft_channel_blueprint_id.as_deref() == Some(blueprint_id)
2828            && self.active_craft_channel().is_some()
2829    }
2830
2831    /// Blueprint indices for the active tab + search, ready-first then A–Z.
2832    pub fn craft_filtered_indices(&self) -> Vec<usize> {
2833        let mut idxs: Vec<usize> = (0..self.blueprints.len())
2834            .filter(|&i| {
2835                let bp = &self.blueprints[i];
2836                if !self.craft_matches_search(bp) {
2837                    return false;
2838                }
2839                match self.craft_tab {
2840                    CraftTab::Ready => {
2841                        self.can_craft_blueprint(bp) || self.craft_blueprint_in_channel(&bp.id)
2842                    }
2843                    CraftTab::Favorites => self.craft_prefs.is_favorite(&bp.id),
2844                    CraftTab::Recent => self.craft_prefs.recent.iter().any(|id| id == &bp.id),
2845                    CraftTab::Tier(t) => bp.craft_tier.max(1) == t,
2846                }
2847            })
2848            .collect();
2849        match self.craft_tab {
2850            CraftTab::Recent => {
2851                idxs.sort_by_key(|&i| {
2852                    self.craft_prefs
2853                        .recent
2854                        .iter()
2855                        .position(|id| id == &self.blueprints[i].id)
2856                        .unwrap_or(usize::MAX)
2857                });
2858            }
2859            _ => {
2860                idxs.sort_by(|&a, &b| {
2861                    let ba = &self.blueprints[a];
2862                    let bb = &self.blueprints[b];
2863                    let ia = self.craft_blueprint_in_channel(&ba.id);
2864                    let ib = self.craft_blueprint_in_channel(&bb.id);
2865                    // In-progress first, then craftable, then A–Z.
2866                    ib.cmp(&ia)
2867                        .then_with(|| {
2868                            let ra = self.can_craft_blueprint(ba);
2869                            let rb = self.can_craft_blueprint(bb);
2870                            rb.cmp(&ra)
2871                        })
2872                        .then_with(|| ba.label.to_ascii_lowercase().cmp(&bb.label.to_ascii_lowercase()))
2873                });
2874            }
2875        }
2876        idxs
2877    }
2878
2879    pub fn craft_selected_blueprint(&self) -> Option<&BlueprintView> {
2880        let idxs = self.craft_filtered_indices();
2881        idxs.get(self.craft_menu_index)
2882            .and_then(|&i| self.blueprints.get(i))
2883    }
2884
2885    pub fn clamp_craft_menu_index(&mut self) {
2886        let n = self.craft_filtered_indices().len();
2887        if n == 0 {
2888            self.craft_menu_index = 0;
2889        } else {
2890            self.craft_menu_index = self.craft_menu_index.min(n - 1);
2891        }
2892    }
2893
2894    pub fn craft_is_favorite(&self, blueprint_id: &str) -> bool {
2895        self.craft_prefs.is_favorite(blueprint_id)
2896    }
2897
2898    pub fn craft_toggle_favorite_selected(&mut self) {
2899        let Some(id) = self.craft_selected_blueprint().map(|bp| bp.id.clone()) else {
2900            return;
2901        };
2902        self.craft_prefs.toggle_favorite(&id);
2903        self.persist_craft_prefs();
2904        if matches!(self.craft_tab, CraftTab::Favorites) {
2905            self.clamp_craft_menu_index();
2906        }
2907    }
2908
2909    pub fn craft_record_completed(&mut self, blueprint_id: &str) {
2910        self.craft_prefs.record_crafted(blueprint_id);
2911        self.persist_craft_prefs();
2912    }
2913
2914    pub fn focus_craft_filter(&mut self) {
2915        self.craft_filter_focused = true;
2916    }
2917
2918    pub fn append_craft_filter_char(&mut self, ch: char) {
2919        if !self.craft_filter_focused {
2920            return;
2921        }
2922        if is_list_filter_char(ch) {
2923            self.craft_filter.push(ch);
2924            self.craft_menu_index = 0;
2925            self.clamp_craft_menu_index();
2926        }
2927    }
2928
2929    pub fn craft_filter_backspace(&mut self) {
2930        if !self.craft_filter_focused {
2931            return;
2932        }
2933        self.craft_filter.pop();
2934        self.craft_menu_index = 0;
2935        self.clamp_craft_menu_index();
2936    }
2937
2938    /// Esc while filter focused: clear then blur. Returns true if handled.
2939    pub fn clear_or_blur_craft_filter(&mut self) -> bool {
2940        if self.craft_filter_focused {
2941            if !self.craft_filter.is_empty() {
2942                self.craft_filter.clear();
2943                self.craft_menu_index = 0;
2944                self.clamp_craft_menu_index();
2945            } else {
2946                self.craft_filter_focused = false;
2947            }
2948            return true;
2949        }
2950        if !self.craft_filter.is_empty() {
2951            self.craft_filter.clear();
2952            self.craft_menu_index = 0;
2953            self.clamp_craft_menu_index();
2954            return true;
2955        }
2956        false
2957    }
2958
2959    pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
2960        if !self.can_craft_blueprint(blueprint) {
2961            return 0;
2962        }
2963        let mut limit = u32::MAX;
2964        for input in &blueprint.inputs {
2965            if input.quantity == 0 {
2966                continue;
2967            }
2968            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2969            limit = limit.min(have / input.quantity);
2970        }
2971        for tool in &blueprint.required_tools {
2972            if tool.consumed {
2973                let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2974                limit = limit.min(have);
2975            }
2976        }
2977        if self.craft_output_needs_vessel(blueprint) {
2978            let need = blueprint.output_qty.max(1);
2979            let room = self.vessel_room_after_craft_inputs(blueprint);
2980            if need > 0 {
2981                limit = limit.min(room / need);
2982            }
2983        }
2984        limit.min(CRAFT_BATCH_SELECT_CAP)
2985    }
2986
2987    /// How many sequential crafts current stamina can finish before a rest.
2988    pub fn craft_stamina_batch_cap(&self) -> u32 {
2989        let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
2990        if CRAFT_STAMINA_COST > 0.0 {
2991            (stamina / CRAFT_STAMINA_COST).floor() as u32
2992        } else {
2993            u32::MAX
2994        }
2995    }
2996
2997    pub fn clamp_craft_batch_quantity(&mut self) {
2998        let Some(bp) = self.craft_selected_blueprint().cloned() else {
2999            self.craft_batch_quantity = 1;
3000            return;
3001        };
3002        let max = self.max_craft_batches(&bp).max(1);
3003        self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
3004    }
3005
3006    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
3007        let Some(bp) = self.craft_selected_blueprint().cloned() else {
3008            return;
3009        };
3010        let max = self.max_craft_batches(&bp).max(1);
3011        let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
3012        self.craft_batch_quantity = next as u32;
3013    }
3014
3015    pub fn craft_batch_set_max(&mut self) {
3016        let Some(bp) = self.craft_selected_blueprint().cloned() else {
3017            return;
3018        };
3019        let max = self.max_craft_batches(&bp);
3020        self.craft_batch_quantity = if max == 0 { 1 } else { max };
3021    }
3022
3023    pub fn craft_batch_set_min(&mut self) {
3024        self.craft_batch_quantity = 1;
3025    }
3026
3027    pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
3028        let preserve_ui = self.show_shop_menu;
3029        let tab = self.shop_tab;
3030        let index = self.shop_menu_index;
3031        let qty = self.shop_quantity;
3032
3033        self.show_shop_menu = true;
3034        self.bank_panel = None;
3035        self.show_craft_menu = false;
3036        self.show_inventory_menu = false;
3037        self.show_stats = false;
3038        if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
3039            self.npc_verb_target = Some(catalog.npc_id.clone());
3040        }
3041        self.shop_catalog = Some(catalog);
3042
3043        if preserve_ui {
3044            self.shop_tab = tab;
3045            self.shop_menu_index = index;
3046            self.shop_quantity = qty;
3047        } else {
3048            self.shop_tab = ShopTab::Buy;
3049            self.shop_menu_index = 0;
3050            self.shop_quantity = 1;
3051            self.clear_shop_trade_log();
3052        }
3053        self.show_npc_verb_menu = false;
3054        self.clamp_shop_selection();
3055    }
3056
3057    pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
3058        let same_teller = self
3059            .bank_panel
3060            .as_ref()
3061            .is_some_and(|p| p.npc_id == panel.npc_id);
3062        self.bank_panel = Some(panel);
3063        self.storage_panel = None;
3064        self.market_panel = None;
3065        self.shop_catalog = None;
3066        self.show_shop_menu = false;
3067        self.show_craft_menu = false;
3068        self.show_inventory_menu = false;
3069        self.show_stats = false;
3070        self.show_npc_verb_menu = false;
3071        self.show_npc_chat = false;
3072        self.npc_chat = None;
3073        if !same_teller {
3074            self.bank_menu_index = 0;
3075            self.bank_ui_mode = BankUiMode::Menu;
3076        }
3077        if let Some(panel) = &self.bank_panel {
3078            if self.npc_verb_target.is_none() {
3079                self.npc_verb_target = Some(panel.npc_id.clone());
3080            }
3081        }
3082    }
3083
3084    pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
3085        let same_manager = self
3086            .storage_panel
3087            .as_ref()
3088            .is_some_and(|p| p.npc_id == panel.npc_id);
3089        self.storage_panel = Some(panel);
3090        self.bank_panel = None;
3091        self.market_panel = None;
3092        self.bank_ui_mode = BankUiMode::Menu;
3093        self.shop_catalog = None;
3094        self.show_shop_menu = false;
3095        self.show_craft_menu = false;
3096        self.show_inventory_menu = false;
3097        self.show_stats = false;
3098        self.show_npc_verb_menu = false;
3099        self.show_npc_chat = false;
3100        self.npc_chat = None;
3101        if !same_manager {
3102            self.storage_menu_index = 0;
3103            self.storage_ui_mode = StorageUiMode::Menu;
3104        } else {
3105            self.clamp_storage_pick_index();
3106        }
3107        if let Some(panel) = &self.storage_panel {
3108            if self.npc_verb_target.is_none() {
3109                self.npc_verb_target = Some(panel.npc_id.clone());
3110            }
3111        }
3112    }
3113
3114    pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
3115        for vault in &panel.list_vaults {
3116            self.merge_stack_catalog_hints(&vault.contents);
3117        }
3118        self.market_panel = Some(panel);
3119        self.bank_panel = None;
3120        self.storage_panel = None;
3121        self.shop_catalog = None;
3122        self.show_shop_menu = false;
3123        self.show_craft_menu = false;
3124        self.show_inventory_menu = false;
3125        self.show_stats = false;
3126        self.show_npc_verb_menu = false;
3127        self.show_npc_chat = false;
3128        self.npc_chat = None;
3129        self.market_menu_index = 0;
3130        self.market_buy_confirm = None;
3131        self.market_ui_mode = MarketUiMode::Browse;
3132        self.market_filter.clear();
3133        self.market_filter_focused = false;
3134        self.market_category_filter = None;
3135        if let Some(panel) = &self.market_panel {
3136            if self.npc_verb_target.is_none() {
3137                self.npc_verb_target = Some(panel.npc_id.clone());
3138            }
3139        }
3140    }
3141
3142    pub fn clear_market_panel(&mut self) {
3143        self.market_panel = None;
3144        self.market_menu_index = 0;
3145        self.market_buy_confirm = None;
3146        self.market_ui_mode = MarketUiMode::Browse;
3147        self.market_filter.clear();
3148        self.market_filter_focused = false;
3149        self.market_category_filter = None;
3150    }
3151
3152    pub fn clear_bank_panel(&mut self) {
3153        self.bank_panel = None;
3154        self.bank_menu_index = 0;
3155        self.bank_ui_mode = BankUiMode::Menu;
3156    }
3157
3158    pub fn clear_storage_panel(&mut self) {
3159        self.storage_panel = None;
3160        self.storage_menu_index = 0;
3161        self.storage_ui_mode = StorageUiMode::Menu;
3162    }
3163
3164    fn clamp_storage_pick_index(&mut self) {
3165        match &self.storage_ui_mode {
3166            StorageUiMode::StorePick { index } => {
3167                let n = self.storage_store_options().len();
3168                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3169                self.storage_ui_mode = StorageUiMode::StorePick { index: next };
3170            }
3171            StorageUiMode::TakePick { index } => {
3172                let n = self.storage_vault_options().len();
3173                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3174                self.storage_ui_mode = StorageUiMode::TakePick { index: next };
3175            }
3176            StorageUiMode::ShipPick {
3177                dest_building_id,
3178                dest_label,
3179                index,
3180            } => {
3181                let n = self.storage_vault_options().len();
3182                let next = if n == 0 { 0 } else { (*index).min(n - 1) };
3183                self.storage_ui_mode = StorageUiMode::ShipPick {
3184                    dest_building_id: dest_building_id.clone(),
3185                    dest_label: dest_label.clone(),
3186                    index: next,
3187                };
3188            }
3189            StorageUiMode::Menu
3190            | StorageUiMode::StoreAmount { .. }
3191            | StorageUiMode::TakeAmount { .. }
3192            | StorageUiMode::ShipAmount { .. } => {}
3193        }
3194    }
3195
3196    pub fn shop_list_len(&self) -> usize {
3197        let Some(catalog) = &self.shop_catalog else {
3198            return 0;
3199        };
3200        match self.shop_tab {
3201            ShopTab::Buy => catalog.sells.len(),
3202            ShopTab::Sell => catalog.buys.len(),
3203        }
3204    }
3205
3206    pub fn shop_menu_move(&mut self, delta: i32) {
3207        let n = self.shop_list_len();
3208        if n == 0 {
3209            return;
3210        }
3211        let idx = self.shop_menu_index as i32;
3212        let next = (idx + delta).rem_euclid(n as i32);
3213        self.shop_menu_index = next as usize;
3214        self.clamp_shop_quantity();
3215    }
3216
3217    pub fn shop_quantity_adjust(&mut self, delta: i32) {
3218        let max = self.shop_quantity_max();
3219        if max == 0 {
3220            self.shop_quantity = 0;
3221            return;
3222        }
3223        let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
3224        self.shop_quantity = next as u32;
3225    }
3226
3227    pub(crate) fn clamp_shop_selection(&mut self) {
3228        let n = self.shop_list_len();
3229        if n == 0 {
3230            self.shop_menu_index = 0;
3231        } else {
3232            self.shop_menu_index = self.shop_menu_index.min(n - 1);
3233        }
3234        self.clamp_shop_quantity();
3235    }
3236
3237    fn shop_quantity_max(&self) -> u32 {
3238        let Some(catalog) = &self.shop_catalog else {
3239            return 1;
3240        };
3241        match self.shop_tab {
3242            ShopTab::Buy => {
3243                if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
3244                    if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
3245                        return 1;
3246                    }
3247                }
3248                99
3249            }
3250            ShopTab::Sell => catalog
3251                .buys
3252                .get(self.shop_menu_index)
3253                .map(|l| l.quantity)
3254                .unwrap_or(0),
3255        }
3256    }
3257
3258    pub fn shop_quantity_set_max(&mut self) {
3259        self.shop_quantity = self.shop_quantity_max();
3260    }
3261
3262    pub fn shop_quantity_set_min(&mut self) {
3263        let max = self.shop_quantity_max();
3264        self.shop_quantity = if max == 0 { 0 } else { 1 };
3265    }
3266
3267    fn clamp_shop_quantity(&mut self) {
3268        let max = self.shop_quantity_max();
3269        if max == 0 {
3270            self.shop_quantity = 0;
3271        } else {
3272            self.shop_quantity = self.shop_quantity.max(1).min(max);
3273        }
3274    }
3275
3276    pub fn player_at_station_tag(&self, tag: &str) -> bool {
3277        let Some(id) = self.effective_inside_building() else {
3278            return false;
3279        };
3280        self.buildings
3281            .iter()
3282            .find(|b| b.id == id)
3283            .is_some_and(|b| b.tags.iter().any(|t| t == tag))
3284    }
3285
3286    /// Short hint for UI when a recipe cannot be started.
3287    pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
3288        if self.can_craft_blueprint(blueprint) {
3289            return None;
3290        }
3291        let mut missing = Vec::new();
3292        for input in &blueprint.inputs {
3293            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
3294            if have < input.quantity {
3295                let name = self.blueprint_ingredient_label(input);
3296                let vessel_note = if self.inventory_item_category(&input.template_id)
3297                    == Some("liquid")
3298                    || matches!(input.template_id.as_str(), "water" | "milk")
3299                {
3300                    "; fill a bottle/waterskin"
3301                } else if self.inventory_item_category(&input.template_id) == Some("bulk")
3302                    || matches!(input.template_id.as_str(), "dirt" | "mud" | "sand")
3303                {
3304                    "; scoop into a sack/bucket"
3305                } else {
3306                    ""
3307                };
3308                missing.push(format!(
3309                    "{}×{} (have {have}{vessel_note})",
3310                    input.quantity, name
3311                ));
3312            }
3313        }
3314        for tool in &blueprint.required_tools {
3315            if !self.player_has_craft_tool(&tool.item) {
3316                missing.push(format!("tool: {}", self.blueprint_tool_label(tool)));
3317            }
3318        }
3319        if let Some(station) = blueprint.station.as_deref() {
3320            if station != "hand" && !self.player_at_station_tag(station) {
3321                missing.push(format!("station: {station} (enter building)"));
3322            }
3323        }
3324        if self.craft_output_needs_vessel(blueprint) && !self.craft_has_vessel_room_for_output(blueprint)
3325        {
3326            let name = self
3327                .inventory_hints
3328                .get(&blueprint.output)
3329                .map(|h| h.display_name.as_str())
3330                .unwrap_or(blueprint.output.as_str());
3331            let need = blueprint.output_qty.max(1);
3332            let free = self.vessel_room_after_craft_inputs(blueprint);
3333            let accepting = self
3334                .craft_vessel_status(blueprint)
3335                .vessels
3336                .iter()
3337                .filter(|v| v.accepts_output)
3338                .count();
3339            missing.push(format!(
3340                "vessel room for {name}: need {need} free after inputs, have {free} ({accepting} accepting vessel(s))"
3341            ));
3342        }
3343        if missing.is_empty() {
3344            None
3345        } else {
3346            Some(missing.join(", "))
3347        }
3348    }
3349
3350    /// In-progress inventory craft (n-menu), if any.
3351    pub fn active_craft_channel(&self) -> Option<&flatland_protocol::TimedChannelHud> {
3352        self.timed_channel
3353            .as_ref()
3354            .filter(|c| c.channel == flatland_protocol::TimedChannelKind::Craft)
3355    }
3356
3357    pub fn player_entity(&self) -> Option<&EntityState> {
3358        self.player
3359            .as_ref()
3360            .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
3361    }
3362
3363    /// Apply persisted HUD / workers UI prefs from `client.json`.
3364    pub fn apply_client_ui_prefs(&mut self) {
3365        let cfg = crate::client_config::ClientConfig::load();
3366        if let Some(hidden) = cfg.hud_log_hidden {
3367            self.hud_log_hidden = hidden;
3368        }
3369        if let Some(compact) = cfg.workers_menu_compact {
3370            self.workers_menu_compact = compact;
3371        }
3372    }
3373
3374    pub fn player_position(&self) -> (f32, f32) {
3375        let (x, y, _) = self.player_position_with_z();
3376        (x, y)
3377    }
3378
3379    pub fn player_position_with_z(&self) -> (f32, f32, f32) {
3380        if let Some(p) = self.player_entity() {
3381            (
3382                p.transform.position.x,
3383                p.transform.position.y,
3384                p.transform.position.z,
3385            )
3386        } else {
3387            (0.0, 0.0, 0.0)
3388        }
3389    }
3390
3391    pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
3392        let mut rows: Vec<(String, u32, String)> = self
3393            .inventory
3394            .iter()
3395            .filter(|(_, q)| **q > 0)
3396            .map(|(id, qty)| {
3397                let label = self
3398                    .inventory_hints
3399                    .get(id)
3400                    .map(|h| h.display_name.clone())
3401                    .unwrap_or_else(|| id.clone());
3402                (id.clone(), *qty, label)
3403            })
3404            .collect();
3405        rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
3406        rows
3407    }
3408
3409    pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
3410        self.inventory_hints
3411            .get(template_id)
3412            .map(|h| h.category.as_str())
3413            .filter(|c| !c.is_empty())
3414    }
3415
3416    pub fn stack_is_serving(stack: &flatland_protocol::ItemStack) -> bool {
3417        stack.props.get("serving").is_some_and(|v| v == "1")
3418            || Self::stack_is_liquid_vessel(stack)
3419    }
3420
3421    pub fn stack_is_liquid_vessel(stack: &flatland_protocol::ItemStack) -> bool {
3422        stack.props.get("liquid_vessel").is_some_and(|v| v == "1")
3423            || stack.props.get("serving_holds").is_some_and(|v| {
3424                v.split(',').any(|p| p.trim() == "liquid")
3425            })
3426    }
3427
3428    pub fn stack_is_food_serving(stack: &flatland_protocol::ItemStack) -> bool {
3429        stack.props.get("serving_holds").is_some_and(|v| {
3430            v.split(',').any(|p| p.trim() == "food")
3431        })
3432    }
3433
3434    pub fn stack_is_bulk_vessel(stack: &flatland_protocol::ItemStack) -> bool {
3435        stack.props.get("bulk_vessel").is_some_and(|v| v == "1")
3436            || stack.props.get("serving_holds").is_some_and(|v| {
3437                v.split(',').any(|p| p.trim() == "bulk")
3438            })
3439    }
3440
3441    pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
3442        stack
3443            .props
3444            .get("grants_item_status_effect")
3445            .map(|s| !s.is_empty())
3446            .unwrap_or(false)
3447    }
3448
3449    pub fn stack_is_blueprint_scroll(stack: &flatland_protocol::ItemStack) -> bool {
3450        stack
3451            .props
3452            .get("teaches_blueprint")
3453            .map(|s| !s.trim().is_empty())
3454            .unwrap_or(false)
3455    }
3456
3457    pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
3458        stack
3459            .props
3460            .get("grants_item_status_effect")
3461            .map(String::as_str)
3462            .filter(|s| !s.is_empty())
3463    }
3464
3465    pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
3466        stack
3467            .props
3468            .get("grants_item_status_mode")
3469            .map(String::as_str)
3470            .unwrap_or("on_hit")
3471    }
3472
3473    /// Candidate gear for a grant consumable (inventory + worn).
3474    pub fn grant_target_options(
3475        &self,
3476        grant: &flatland_protocol::ItemStack,
3477    ) -> Vec<GrantTargetOption> {
3478        let mode = Self::grant_mode(grant);
3479        let grant_tags: Vec<&str> = grant
3480            .props
3481            .get("grants_item_status_tags")
3482            .map(|s| {
3483                s.split(',')
3484                    .map(str::trim)
3485                    .filter(|t| !t.is_empty())
3486                    .collect()
3487            })
3488            .unwrap_or_default();
3489        let grant_id = grant.item_instance_id;
3490        let mut out = Vec::new();
3491        let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
3492            let Some(iid) = stack.item_instance_id else {
3493                return;
3494            };
3495            if Some(iid) == grant_id {
3496                return;
3497            }
3498            if stack.props.get("enchantable").map(String::as_str) == Some("0") {
3499                return;
3500            }
3501            if !grant_target_matches_mode(stack, mode) {
3502                return;
3503            }
3504            if !grant_tags_match(stack, &grant_tags) {
3505                return;
3506            }
3507            let name = stack
3508                .display_name
3509                .clone()
3510                .unwrap_or_else(|| stack.template_id.clone());
3511            let bindings = if stack.status_bindings.is_empty() {
3512                String::new()
3513            } else {
3514                format!(
3515                    " · {}",
3516                    stack
3517                        .status_bindings
3518                        .iter()
3519                        .map(|b| b.effect_id.as_str())
3520                        .collect::<Vec<_>>()
3521                        .join(", ")
3522                )
3523            };
3524            out.push(GrantTargetOption {
3525                label: format!("{where_label}: {name}{bindings}"),
3526                target_instance_id: iid,
3527            });
3528        };
3529        fn walk(
3530            stacks: &[flatland_protocol::ItemStack],
3531            where_label: &str,
3532            push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
3533        ) {
3534            for s in stacks {
3535                push(s, where_label);
3536                if !s.contents.is_empty() {
3537                    let nested = format!(
3538                        "{where_label}/{}",
3539                        s.display_name.as_deref().unwrap_or(s.template_id.as_str())
3540                    );
3541                    walk(&s.contents, &nested, push);
3542                }
3543            }
3544        }
3545        walk(&self.inventory_stacks, "Bag", &mut push);
3546        for (slot, stack) in &self.worn {
3547            push(stack, body_slot_label(*slot));
3548            let nest = format!(
3549                "{}/{}",
3550                body_slot_label(*slot),
3551                stack
3552                    .display_name
3553                    .as_deref()
3554                    .unwrap_or(stack.template_id.as_str())
3555            );
3556            walk(&stack.contents, &nest, &mut push);
3557        }
3558        out
3559    }
3560
3561    pub fn item_base_mass(&self, template_id: &str) -> f32 {
3562        self.inventory_hints
3563            .get(template_id)
3564            .and_then(|h| h.base_mass)
3565            .unwrap_or(0.5)
3566    }
3567
3568    pub fn item_base_volume(&self, template_id: &str) -> f32 {
3569        self.inventory_hints
3570            .get(template_id)
3571            .and_then(|h| h.base_volume)
3572            .unwrap_or(1.0)
3573    }
3574
3575    pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
3576        let unit = stack
3577            .base_mass
3578            .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
3579        unit * stack.quantity as f32
3580    }
3581
3582    fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
3583        let unit = stack.base_volume.unwrap_or(1.0);
3584        unit * stack.quantity as f32
3585            + stack
3586                .contents
3587                .iter()
3588                .map(Self::stack_tree_volume)
3589                .sum::<f32>()
3590    }
3591
3592    fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
3593        contents.iter().map(Self::stack_tree_volume).sum()
3594    }
3595
3596    fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
3597        self.inventory_hints
3598            .get(template_id)
3599            .and_then(|h| h.capacity_volume)
3600            .filter(|c| *c > 0.0)
3601    }
3602
3603    fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
3604        stack
3605            .capacity_volume
3606            .filter(|c| *c > 0.0)
3607            .or_else(|| self.template_capacity_volume(&stack.template_id))
3608    }
3609
3610    /// Volume used / capacity / free space label for storage containers in the inventory UI.
3611    pub fn container_volume_label(&self, row: &InventoryRow) -> String {
3612        let Some((used, cap)) = self.container_volume_stats(row) else {
3613            return String::new();
3614        };
3615        let free = (cap - used).max(0.0);
3616        format!("  vol {used:.0}/{cap:.0} ({free:.0} free)")
3617    }
3618
3619    fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
3620        if row.is_chest_shell {
3621            let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
3622                return None;
3623            };
3624            let chest = self
3625                .placed_containers
3626                .iter()
3627                .find(|c| c.id == *container_id)?;
3628            let cap = self
3629                .stack_capacity_volume(&row.stack)
3630                .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
3631            let used = if chest.accessible {
3632                Self::contents_used_volume(&chest.contents)
3633            } else {
3634                0.0
3635            };
3636            return Some((used, cap));
3637        }
3638
3639        let cap = self.stack_capacity_volume(&row.stack)?;
3640        let used = Self::contents_used_volume(&row.stack.contents);
3641        Some((used, cap))
3642    }
3643
3644    pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
3645        if row.is_chest_shell {
3646            return true;
3647        }
3648        if row.is_equip_shell {
3649            return self.inventory_item_category(&row.stack.template_id) == Some("container");
3650        }
3651        self.inventory_item_category(&row.stack.template_id) == Some("container")
3652            || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
3653    }
3654
3655    fn container_stack_for(
3656        &self,
3657        location: &flatland_protocol::InventoryLocation,
3658        parent_instance_id: Option<uuid::Uuid>,
3659    ) -> Option<flatland_protocol::ItemStack> {
3660        match location {
3661            flatland_protocol::InventoryLocation::Root => {
3662                let pid = parent_instance_id?;
3663                self.find_stack_by_instance(&self.inventory_stacks, pid)
3664            }
3665            flatland_protocol::InventoryLocation::Worn { slot } => {
3666                let worn = self.worn.get(slot)?;
3667                if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
3668                    Some(worn.clone())
3669                } else {
3670                    self.find_stack_by_instance(&worn.contents, parent_instance_id?)
3671                }
3672            }
3673            flatland_protocol::InventoryLocation::Placed { container_id } => {
3674                let chest = self
3675                    .placed_containers
3676                    .iter()
3677                    .find(|c| c.id == *container_id)?;
3678                if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
3679                    Some(flatland_protocol::ItemStack {
3680                        template_id: chest.template_id.clone(),
3681                        quantity: 1,
3682                        item_instance_id: chest.item_instance_id,
3683                        props: Default::default(),
3684                        status_bindings: Vec::new(),
3685                        contents: chest.contents.clone(),
3686                        display_name: Some(chest.display_name.clone()),
3687                        category: Some("container".into()),
3688                        capacity_volume: self
3689                            .inventory_hints
3690                            .get(&chest.template_id)
3691                            .and_then(|h| h.capacity_volume),
3692                        worker_lodging_capacity: chest.worker_lodging_capacity,
3693                        ..Default::default()
3694                    })
3695                } else {
3696                    self.find_stack_by_instance(&chest.contents, parent_instance_id?)
3697                }
3698            }
3699            flatland_protocol::InventoryLocation::Keychain => None,
3700            flatland_protocol::InventoryLocation::WhisperPouch => None,
3701        }
3702    }
3703
3704    fn find_stack_by_instance(
3705        &self,
3706        stacks: &[flatland_protocol::ItemStack],
3707        instance_id: uuid::Uuid,
3708    ) -> Option<flatland_protocol::ItemStack> {
3709        for stack in stacks {
3710            if stack.item_instance_id == Some(instance_id) {
3711                return Some(stack.clone());
3712            }
3713            if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
3714                return Some(found);
3715            }
3716        }
3717        None
3718    }
3719
3720    /// Client-side estimate of how many units can move to `to` (server clamps authoritatively).
3721    pub fn max_movable_to(
3722        &self,
3723        template_id: &str,
3724        stack_qty: u32,
3725        from: &flatland_protocol::InventoryLocation,
3726        to: &flatland_protocol::InventoryLocation,
3727        parent_instance_id: Option<uuid::Uuid>,
3728    ) -> u32 {
3729        let unit_vol = self.item_base_volume(template_id);
3730        let mut limit = stack_qty;
3731
3732        if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
3733            let cap = parent
3734                .capacity_volume
3735                .or_else(|| {
3736                    self.inventory_hints
3737                        .get(&parent.template_id)
3738                        .and_then(|h| h.capacity_volume)
3739                })
3740                .unwrap_or(0.0);
3741            if cap > 0.0 && unit_vol > 0.0 {
3742                let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
3743                limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
3744            }
3745        }
3746
3747        let _ = from;
3748        limit.max(0).min(stack_qty)
3749    }
3750
3751    pub fn move_picker_max_at_selection(&self) -> u32 {
3752        let Some(picker) = &self.move_picker else {
3753            return 1;
3754        };
3755        let Some(opt) = picker.options.get(self.move_picker_index) else {
3756            return picker.stack_quantity;
3757        };
3758        match &opt.kind {
3759            MoveOptionKind::Cancel
3760            | MoveOptionKind::Drop
3761            | MoveOptionKind::Use
3762            | MoveOptionKind::GrantApply
3763            | MoveOptionKind::SellPlotToCrown { .. }
3764            | MoveOptionKind::PickupPlaced { .. }
3765            | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
3766            MoveOptionKind::Move {
3767                location,
3768                parent_instance_id,
3769            } => self.max_movable_to(
3770                &picker.template_id,
3771                picker.stack_quantity,
3772                &picker.from,
3773                location,
3774                *parent_instance_id,
3775            ),
3776        }
3777    }
3778
3779    pub fn clamp_move_picker_quantity(&mut self) {
3780        let max = self.move_picker_max_at_selection();
3781        if let Some(picker) = &mut self.move_picker {
3782            if max == 0 {
3783                picker.quantity = 1;
3784            } else {
3785                picker.quantity = picker.quantity.clamp(1, max);
3786            }
3787        }
3788    }
3789
3790    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
3791        let max = self.move_picker_max_at_selection().max(1);
3792        if let Some(picker) = &mut self.move_picker {
3793            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3794            picker.quantity = next as u32;
3795        }
3796    }
3797
3798    pub fn move_picker_set_quantity_max(&mut self) {
3799        let max = self.move_picker_max_at_selection();
3800        if let Some(picker) = &mut self.move_picker {
3801            picker.quantity = if max == 0 {
3802                1
3803            } else {
3804                max.min(picker.stack_quantity)
3805            };
3806        }
3807    }
3808
3809    pub fn move_picker_set_quantity_min(&mut self) {
3810        if let Some(picker) = &mut self.move_picker {
3811            picker.quantity = 1;
3812        }
3813    }
3814
3815    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
3816        if let Some(picker) = &mut self.destroy_picker {
3817            let max = picker.stack_quantity.max(1);
3818            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
3819            picker.quantity = next as u32;
3820        }
3821    }
3822
3823    pub fn destroy_picker_set_quantity_max(&mut self) {
3824        if let Some(picker) = &mut self.destroy_picker {
3825            picker.quantity = picker.stack_quantity.max(1);
3826        }
3827    }
3828
3829    pub fn destroy_picker_set_quantity_min(&mut self) {
3830        if let Some(picker) = &mut self.destroy_picker {
3831            picker.quantity = 1;
3832        }
3833    }
3834
3835    pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3836        let have = self.inventory.get(template_id).copied().unwrap_or(0);
3837        (have, have >= need)
3838    }
3839
3840    /// Have/need using plot-build storage pool (town vault / nearby chest + inventory).
3841    pub fn plot_build_stock_status(&self, template_id: &str, need: u32) -> (u32, bool) {
3842        let have = self
3843            .plot_build_offer
3844            .as_ref()
3845            .and_then(|o| {
3846                o.available
3847                    .iter()
3848                    .find(|s| s.template_id == template_id)
3849                    .map(|s| s.quantity)
3850            })
3851            .unwrap_or_else(|| self.inventory.get(template_id).copied().unwrap_or(0));
3852        (have, have >= need)
3853    }
3854
3855    pub fn plot_build_wall_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3856        self.building_materials
3857            .iter()
3858            .filter(|m| m.can_wall)
3859            .collect()
3860    }
3861
3862    pub fn plot_build_roof_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
3863        self.building_materials
3864            .iter()
3865            .filter(|m| m.can_roof)
3866            .collect()
3867    }
3868
3869    pub fn plot_build_selected_wall(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3870        self.plot_build_wall_options()
3871            .get(self.plot_build_wall_index)
3872            .copied()
3873    }
3874
3875    pub fn plot_build_selected_roof(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
3876        self.plot_build_roof_options()
3877            .get(self.plot_build_roof_index)
3878            .copied()
3879    }
3880
3881    /// BOM lines for the current wall/roof selection and tilled pad area.
3882    pub fn plot_build_bom_lines(&self) -> Vec<(String, String, u32)> {
3883        let Some(wall) = self.plot_build_selected_wall() else {
3884            return Vec::new();
3885        };
3886        let Some(roof) = self.plot_build_selected_roof() else {
3887            return Vec::new();
3888        };
3889        let area = self
3890            .plot_build_offer
3891            .as_ref()
3892            .filter(|o| o.pad_ok)
3893            .map(|o| o.pad_width_m * o.pad_depth_m)
3894            .unwrap_or(0.0);
3895        if area <= 0.0 {
3896            return Vec::new();
3897        }
3898        let mut map: std::collections::HashMap<String, (String, u32)> =
3899            std::collections::HashMap::new();
3900        for line in &wall.wall_bom {
3901            let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3902            if qty == 0 {
3903                continue;
3904            }
3905            let name = if line.display_name.is_empty() {
3906                line.template_id.clone()
3907            } else {
3908                line.display_name.clone()
3909            };
3910            let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3911            entry.1 = entry.1.saturating_add(qty);
3912        }
3913        for line in &roof.roof_bom {
3914            let qty = (area * line.per_m2).ceil().max(0.0) as u32;
3915            if qty == 0 {
3916                continue;
3917            }
3918            let name = if line.display_name.is_empty() {
3919                line.template_id.clone()
3920            } else {
3921                line.display_name.clone()
3922            };
3923            let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
3924            entry.1 = entry.1.saturating_add(qty);
3925        }
3926        let mut out: Vec<_> = map
3927            .into_iter()
3928            .map(|(id, (name, qty))| (id, name, qty))
3929            .collect();
3930        out.sort_by(|a, b| a.0.cmp(&b.0));
3931        out
3932    }
3933
3934    pub fn plot_build_duration_secs(&self) -> Option<f32> {
3935        let wall = self.plot_build_selected_wall()?;
3936        let roof = self.plot_build_selected_roof()?;
3937        let offer = self.plot_build_offer.as_ref()?;
3938        if !offer.pad_ok {
3939            return None;
3940        }
3941        let area = offer.pad_width_m * offer.pad_depth_m;
3942        let mult = wall.tick_mult.max(roof.tick_mult).max(0.1);
3943        let ticks = (offer.base_ticks as f32 + area * offer.tick_per_m2 as f32 * mult).ceil();
3944        Some(ticks.max(2.0) / 30.0)
3945    }
3946
3947    pub fn plot_build_can_afford(&self) -> bool {
3948        if self.plot_build_offer.as_ref().is_none_or(|o| !o.pad_ok) {
3949            return false;
3950        }
3951        self.plot_build_bom_lines()
3952            .iter()
3953            .all(|(id, _, need)| self.plot_build_stock_status(id, *need).1)
3954    }
3955
3956    pub fn currency_display(&self) -> String {
3957        crate::currency::currency_line(&self.inventory)
3958    }
3959
3960    /// True when standing in a shallow-water terrain zone from the segment snapshot.
3961    pub fn in_shallow_water(&self) -> bool {
3962        let (px, py) = self.player_position();
3963        self.terrain_at(px, py)
3964            .is_some_and(|k| k == TerrainKindView::ShallowWater)
3965    }
3966
3967    /// On or orthogonally next to fillable water, or next to a well-tagged building.
3968    pub fn near_liquid_fill_source(&self) -> bool {
3969        let (px, py) = self.player_position();
3970        const CELL: f32 = 1.0;
3971        let offsets = [
3972            (0.0, 0.0),
3973            (CELL, 0.0),
3974            (-CELL, 0.0),
3975            (0.0, CELL),
3976            (0.0, -CELL),
3977        ];
3978        for (dx, dy) in offsets {
3979            if matches!(
3980                self.terrain_at(px + dx, py + dy),
3981                Some(TerrainKindView::ShallowWater | TerrainKindView::DeepWater)
3982            ) {
3983                return true;
3984            }
3985        }
3986        self.buildings.iter().any(|b| {
3987            if !b.tags.iter().any(|t| t == "well") {
3988                return false;
3989            }
3990            let hw = b.width_m * 0.5;
3991            let hd = b.depth_m * 0.5;
3992            let nx = px.clamp(b.x - hw, b.x + hw);
3993            let ny = py.clamp(b.y - hd, b.y + hd);
3994            let dx = px - nx;
3995            let dy = py - ny;
3996            dx * dx + dy * dy <= INTERACTION_RADIUS_M * INTERACTION_RADIUS_M
3997        })
3998    }
3999
4000    pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
4001        self.terrain_zone_at(x, y).map(|z| z.kind)
4002    }
4003
4004    /// First terrain zone containing `(x, y)` — highest `z_order` wins.
4005    pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
4006        use std::cell::RefCell;
4007
4008        const CHUNK: i32 = 8;
4009        thread_local! {
4010            static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
4011                RefCell::new(None);
4012        }
4013
4014        let zones = &self.terrain_zones;
4015        if zones.is_empty() {
4016            return None;
4017        }
4018        if zones.len() <= 48 {
4019            return zones
4020                .iter()
4021                .enumerate()
4022                .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
4023                .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
4024                .map(|(_, z)| z);
4025        }
4026
4027        let ptr = zones.as_ptr();
4028        let len = zones.len();
4029        INDEX.with(|cell| {
4030            let mut slot = cell.borrow_mut();
4031            let stale = match slot.as_ref() {
4032                Some((p, l, _)) => *p != ptr || *l != len,
4033                None => true,
4034            };
4035            if stale {
4036                let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
4037                    std::collections::HashMap::new();
4038                for (zi, z) in zones.iter().enumerate() {
4039                    let x0 = z.x0.min(z.x1).floor() as i32;
4040                    let y0 = z.y0.min(z.y1).floor() as i32;
4041                    let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
4042                    let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
4043                    let cx0 = x0.div_euclid(CHUNK);
4044                    let cy0 = y0.div_euclid(CHUNK);
4045                    let cx1 = x1.div_euclid(CHUNK);
4046                    let cy1 = y1.div_euclid(CHUNK);
4047                    for cy in cy0..=cy1 {
4048                        for cx in cx0..=cx1 {
4049                            chunks.entry((cx, cy)).or_default().push(zi);
4050                        }
4051                    }
4052                }
4053                *slot = Some((ptr, len, chunks));
4054            }
4055            let chunks = &slot.as_ref().expect("index").2;
4056            let cx = (x.floor() as i32).div_euclid(CHUNK);
4057            let cy = (y.floor() as i32).div_euclid(CHUNK);
4058            let mut best: Option<(usize, &TerrainZoneView)> = None;
4059            if let Some(list) = chunks.get(&(cx, cy)) {
4060                for &zi in list {
4061                    let Some(z) = zones.get(zi) else { continue };
4062                    if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
4063                        continue;
4064                    }
4065                    best = match best {
4066                        None => Some((zi, z)),
4067                        Some((bi, bz)) => {
4068                            if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
4069                                Some((zi, z))
4070                            } else {
4071                                Some((bi, bz))
4072                            }
4073                        }
4074                    };
4075                }
4076            }
4077            best.map(|(_, z)| z)
4078        })
4079    }
4080
4081    /// Ground elevation from terrain zones (m).
4082    pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
4083        self.terrain_zone_at(x, y)
4084            .map(|z| z.elevation)
4085            .unwrap_or(0.0)
4086    }
4087
4088    /// Walkable z levels at a map column (terrain + platforms).
4089    pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
4090        const TOL: f32 = 0.35;
4091        let mut levels = vec![self.elevation_at(x, y)];
4092        for p in &self.z_platforms {
4093            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
4094                levels.push(p.z);
4095            }
4096        }
4097        levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
4098        levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
4099        levels
4100    }
4101
4102    pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
4103        const TOL: f32 = 0.35;
4104        self.walkable_levels_at(x, y)
4105            .iter()
4106            .any(|&l| (l - z).abs() <= TOL)
4107    }
4108
4109    pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
4110        let mut top = self.elevation_at(x, y);
4111        for p in &self.z_platforms {
4112            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
4113                top = top.max(p.z);
4114            }
4115        }
4116        top
4117    }
4118
4119    /// Authoritative interior context from the server (`inside_building` flag).
4120    pub fn effective_inside_building(&self) -> Option<String> {
4121        self.player_entity().and_then(|p| p.inside_building.clone())
4122    }
4123
4124    /// True when a placed chest shares the player's outdoor/interior space.
4125    /// Owned indoor chests may still arrive in AOI while outdoors (worker route
4126    /// labels); they must not be drawn or treated as nearby world props.
4127    pub fn placed_container_in_current_space(
4128        &self,
4129        c: &flatland_protocol::PlacedContainerView,
4130    ) -> bool {
4131        match (
4132            self.effective_inside_building().as_deref(),
4133            c.building_id.as_deref(),
4134        ) {
4135            (None, None) => true,
4136            (Some(a), Some(b)) => a == b,
4137            _ => false,
4138        }
4139    }
4140
4141    fn merge_stack_catalog_hints(&mut self, stacks: &[flatland_protocol::ItemStack]) {
4142        fn walk(
4143            stacks: &[flatland_protocol::ItemStack],
4144            hints: &mut std::collections::HashMap<String, InventoryHint>,
4145        ) {
4146            for stack in stacks {
4147                if stack.display_name.is_some()
4148                    || stack.category.is_some()
4149                    || stack.base_mass.is_some()
4150                    || stack.base_volume.is_some()
4151                    || stack.base_value_copper.is_some()
4152                {
4153                    hints.insert(
4154                        stack.template_id.clone(),
4155                        InventoryHint {
4156                            display_name: stack
4157                                .display_name
4158                                .clone()
4159                                .unwrap_or_else(|| stack.template_id.clone()),
4160                            category: stack.category.clone().unwrap_or_default(),
4161                            base_mass: stack.base_mass,
4162                            base_volume: stack.base_volume,
4163                            capacity_volume: stack.capacity_volume,
4164                            stackable: stack.stackable.unwrap_or(true),
4165                            listable: stack.listable.unwrap_or_else(|| {
4166                                category_default_listable(stack.category.as_deref().unwrap_or(""))
4167                            }),
4168                            base_value_copper: stack.base_value_copper,
4169                        },
4170                    );
4171                }
4172                walk(&stack.contents, hints);
4173            }
4174        }
4175        walk(stacks, &mut self.inventory_hints);
4176    }
4177
4178    pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
4179        self.inventory_stacks = stacks.to_vec();
4180        self.inventory.clear();
4181        self.inventory_hints.clear();
4182        fn walk(
4183            stacks: &[flatland_protocol::ItemStack],
4184            inventory: &mut std::collections::HashMap<String, u32>,
4185            hints: &mut std::collections::HashMap<String, InventoryHint>,
4186        ) {
4187            for stack in stacks {
4188                *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
4189                if stack.display_name.is_some()
4190                    || stack.category.is_some()
4191                    || stack.base_mass.is_some()
4192                    || stack.base_volume.is_some()
4193                    || stack.base_value_copper.is_some()
4194                {
4195                    hints.insert(
4196                        stack.template_id.clone(),
4197                        InventoryHint {
4198                            display_name: stack
4199                                .display_name
4200                                .clone()
4201                                .unwrap_or_else(|| stack.template_id.clone()),
4202                            category: stack.category.clone().unwrap_or_default(),
4203                            base_mass: stack.base_mass,
4204                            base_volume: stack.base_volume,
4205                            capacity_volume: stack.capacity_volume,
4206                            stackable: stack.stackable.unwrap_or(true),
4207                            listable: stack.listable.unwrap_or_else(|| {
4208                                category_default_listable(stack.category.as_deref().unwrap_or(""))
4209                            }),
4210                            base_value_copper: stack.base_value_copper,
4211                        },
4212                    );
4213                }
4214                walk(&stack.contents, inventory, hints);
4215            }
4216        }
4217        walk(stacks, &mut self.inventory, &mut self.inventory_hints);
4218        // Include worn items (and nested contents, e.g. belt-clipped pouches) in craft counts.
4219        for item in self.worn.values() {
4220            walk(
4221                std::slice::from_ref(item),
4222                &mut self.inventory,
4223                &mut self.inventory_hints,
4224            );
4225        }
4226    }
4227
4228    fn sync_item_catalog(&mut self, entries: &[ItemCatalogEntryView]) {
4229        if entries.is_empty() {
4230            return;
4231        }
4232        self.item_catalog.clear();
4233        self.item_catalog.reserve(entries.len());
4234        for entry in entries {
4235            if entry.template_id.is_empty() {
4236                continue;
4237            }
4238            self.item_catalog
4239                .insert(entry.template_id.clone(), entry.clone());
4240        }
4241    }
4242
4243    /// Remove a carried stack by instance id (root or nested in a worn bag).
4244    /// Used for optimistic give-to-worker so the bag UI does not keep the item
4245    /// until the next tick — and so a later "Gave …" interaction cannot add it back.
4246    fn remove_carried_instance(&mut self, instance_id: uuid::Uuid, quantity: Option<u32>) {
4247        fn take_from(
4248            stacks: &mut Vec<flatland_protocol::ItemStack>,
4249            instance_id: uuid::Uuid,
4250            qty: Option<u32>,
4251        ) -> bool {
4252            if let Some(i) = stacks
4253                .iter()
4254                .position(|s| s.item_instance_id == Some(instance_id))
4255            {
4256                let have = stacks[i].quantity;
4257                let take = qty.unwrap_or(have).min(have);
4258                if take >= have {
4259                    stacks.remove(i);
4260                } else {
4261                    stacks[i].quantity = have - take;
4262                }
4263                return true;
4264            }
4265            stacks
4266                .iter_mut()
4267                .any(|stack| take_from(&mut stack.contents, instance_id, qty))
4268        }
4269
4270        if take_from(&mut self.inventory_stacks, instance_id, quantity) {
4271            let stacks = self.inventory_stacks.clone();
4272            self.sync_inventory_from_stacks(&stacks);
4273            self.refresh_inventory_ui();
4274            return;
4275        }
4276        let slots: Vec<_> = self.worn.keys().copied().collect();
4277        for slot in slots {
4278            let Some(item) = self.worn.get_mut(&slot) else {
4279                continue;
4280            };
4281            if take_from(&mut item.contents, instance_id, quantity) {
4282                let stacks = self.inventory_stacks.clone();
4283                self.sync_inventory_from_stacks(&stacks);
4284                self.refresh_inventory_ui();
4285                return;
4286            }
4287        }
4288    }
4289
4290    /// Apply server interaction deltas immediately (quest rewards, shop, etc.) so the
4291    /// inventory UI updates before the next tick snapshot arrives.
4292    pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
4293        // Worker handoff notices reuse `inventory_delta` as a toast payload, not a
4294        // gain. The same tick already dropped the stack; applying it as an add put
4295        // the item back in the player bag.
4296        if notice.message.starts_with("Gave ") {
4297            if notice.coins_delta != 0 {
4298                crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
4299                let stacks = self.inventory_stacks.clone();
4300                self.sync_inventory_from_stacks(&stacks);
4301            }
4302            self.record_shop_trade_notice(notice);
4303            return;
4304        }
4305        let subtract_items =
4306            notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
4307        for stack in &notice.inventory_delta {
4308            if stack.quantity == 0 {
4309                continue;
4310            }
4311            if subtract_items {
4312                crate::currency::drain_template_stacks(
4313                    &mut self.inventory_stacks,
4314                    &stack.template_id,
4315                    stack.quantity,
4316                );
4317                continue;
4318            }
4319            let stackable = self
4320                .inventory_hints
4321                .get(&stack.template_id)
4322                .map(|h| h.stackable)
4323                .or(stack.stackable)
4324                .unwrap_or(true);
4325            if stackable {
4326                if let Some(existing) = self
4327                    .inventory_stacks
4328                    .iter_mut()
4329                    .find(|s| s.template_id == stack.template_id)
4330                {
4331                    existing.quantity = existing.quantity.saturating_add(stack.quantity);
4332                    if stack.display_name.is_some() {
4333                        existing.display_name = stack.display_name.clone();
4334                    }
4335                    if stack.category.is_some() {
4336                        existing.category = stack.category.clone();
4337                    }
4338                    continue;
4339                }
4340            }
4341            self.inventory_stacks.push(stack.clone());
4342        }
4343        if notice.coins_delta != 0 {
4344            crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
4345        }
4346        if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
4347            let stacks = self.inventory_stacks.clone();
4348            self.sync_inventory_from_stacks(&stacks);
4349        }
4350        self.record_shop_trade_notice(notice);
4351    }
4352
4353    /// Worn body-slot items — each shown as a shell row (unequip via Enter) followed by
4354    /// its nested contents (e.g. pouches clipped onto a worn belt). `BodySlot` derives
4355    /// `Ord` in display order (Head/Body/Arms/Legs/Feet/Back/Waist), so `BTreeMap`
4356    /// iteration alone gives a stable row order.
4357    pub fn worn_rows(&self) -> Vec<InventoryRow> {
4358        let mut rows = Vec::new();
4359        for (slot, item) in &self.worn {
4360            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4361            rows.push(InventoryRow {
4362                depth: 0,
4363                stack: item.clone(),
4364                from: from.clone(),
4365                from_parent_instance_id: None,
4366                is_equip_shell: true,
4367                is_chest_shell: false,
4368                section: InventorySection::Worn,
4369            });
4370            for child in &item.contents {
4371                push_inventory_rows(
4372                    &mut rows,
4373                    1,
4374                    child,
4375                    &from,
4376                    item.item_instance_id,
4377                    InventorySection::Worn,
4378                );
4379            }
4380        }
4381        rows
4382    }
4383
4384    /// Root stacks eligible to present in a player trade (excludes hand-equipped gear).
4385    pub fn trade_presentable_stacks(&self) -> Vec<&flatland_protocol::ItemStack> {
4386        let equipped = self.hand_equipped_instance_ids();
4387        self.inventory_stacks
4388            .iter()
4389            .filter(|s| s.item_instance_id.is_some_and(|id| !equipped.contains(&id)))
4390            .collect()
4391    }
4392
4393    /// Root on-person stacks that can be handed to a hired worker.
4394    pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
4395        let equipped = self.hand_equipped_instance_ids();
4396        self.inventory_stacks
4397            .iter()
4398            .filter_map(|stack| {
4399                let item_instance_id = stack.item_instance_id?;
4400                if equipped.contains(&item_instance_id) {
4401                    return None;
4402                }
4403                let label = stack
4404                    .display_name
4405                    .clone()
4406                    .unwrap_or_else(|| stack.template_id.clone());
4407                let label = if stack.quantity > 1 {
4408                    format!("{label} ×{}", stack.quantity)
4409                } else {
4410                    label
4411                };
4412                Some(WorkerGiveOption {
4413                    item_instance_id,
4414                    label,
4415                    quantity: stack.quantity,
4416                    template_id: stack.template_id.clone(),
4417                })
4418            })
4419            .collect()
4420    }
4421
4422    /// Employer-known blueprints the selected worker does not yet know.
4423    pub fn teachable_blueprint_options(
4424        &self,
4425        worker: &flatland_protocol::HiredWorkerView,
4426    ) -> Vec<WorkerTeachOption> {
4427        let copper = crate::currency::copper_from_counts(&self.inventory);
4428        let mut options: Vec<WorkerTeachOption> = self
4429            .blueprints
4430            .iter()
4431            .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
4432            .map(|bp| {
4433                let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
4434                let cost = bp.worker_train_copper;
4435                WorkerTeachOption {
4436                    blueprint_id: bp.id.clone(),
4437                    label: if bp.label.is_empty() {
4438                        bp.id.clone()
4439                    } else {
4440                        bp.label.clone()
4441                    },
4442                    cost_copper: cost,
4443                    min_level,
4444                    worker_level: worker.level,
4445                    can_afford: copper >= cost,
4446                    level_ok: worker.level >= min_level,
4447                }
4448            })
4449            .collect();
4450        options.sort_by(|a, b| a.label.cmp(&b.label));
4451        options
4452    }
4453
4454    /// Loose on-person inventory (not worn, not inside a placed chest).
4455    /// Root stacks are ordered by category group; nested contents stay under their parent.
4456    pub fn person_rows(&self) -> Vec<InventoryRow> {
4457        self.person_rows_filtered("")
4458    }
4459
4460    pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4461        let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
4462        roots.sort_by(|a, b| {
4463            let ca = a
4464                .category
4465                .as_deref()
4466                .or_else(|| self.inventory_item_category(&a.template_id))
4467                .unwrap_or("");
4468            let cb = b
4469                .category
4470                .as_deref()
4471                .or_else(|| self.inventory_item_category(&b.template_id))
4472                .unwrap_or("");
4473            let ga = inventory_category_group(ca).1;
4474            let gb = inventory_category_group(cb).1;
4475            ga.cmp(&gb).then_with(|| {
4476                let na = a.display_name.as_deref().unwrap_or(a.template_id.as_str());
4477                let nb = b.display_name.as_deref().unwrap_or(b.template_id.as_str());
4478                na.cmp(nb)
4479            })
4480        });
4481        let mut rows = Vec::new();
4482        for stack in roots {
4483            push_inventory_rows_filtered(
4484                &mut rows,
4485                0,
4486                stack,
4487                &flatland_protocol::InventoryLocation::Root,
4488                None,
4489                InventorySection::Person,
4490                filter,
4491            );
4492        }
4493        rows
4494    }
4495
4496    pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4497        if filter.is_empty() {
4498            return self.worn_rows();
4499        }
4500        let mut rows = Vec::new();
4501        for (slot, item) in &self.worn {
4502            if !stack_matches_filter(item, filter) {
4503                continue;
4504            }
4505            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4506            let self_hit = {
4507                let f = filter.to_ascii_lowercase();
4508                let name = item
4509                    .display_name
4510                    .as_deref()
4511                    .unwrap_or("")
4512                    .to_ascii_lowercase();
4513                let tid = item.template_id.to_ascii_lowercase();
4514                name.contains(&f) || tid.contains(&f)
4515            };
4516            rows.push(InventoryRow {
4517                depth: 0,
4518                stack: item.clone(),
4519                from: from.clone(),
4520                from_parent_instance_id: None,
4521                is_equip_shell: true,
4522                is_chest_shell: false,
4523                section: InventorySection::Worn,
4524            });
4525            for child in &item.contents {
4526                if self_hit || stack_matches_filter(child, filter) {
4527                    push_inventory_rows_filtered(
4528                        &mut rows,
4529                        1,
4530                        child,
4531                        &from,
4532                        item.item_instance_id,
4533                        InventorySection::Worn,
4534                        if self_hit { "" } else { filter },
4535                    );
4536                }
4537            }
4538        }
4539        rows
4540    }
4541
4542    /// Items carried inside equipped containers, without showing the equipped
4543    /// container shells themselves. Equipped gear belongs in the paperdoll;
4544    /// these rows are still movable because the contents are carried by the player.
4545    pub fn carried_worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
4546        let mut rows = Vec::new();
4547        for (slot, item) in &self.worn {
4548            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
4549            for child in &item.contents {
4550                push_inventory_rows_filtered(
4551                    &mut rows,
4552                    0,
4553                    child,
4554                    &from,
4555                    item.item_instance_id,
4556                    InventorySection::Person,
4557                    filter,
4558                );
4559            }
4560        }
4561        rows
4562    }
4563
4564    /// Legacy alias used by the HUD sidebar summary (worn + on-person, unchanged).
4565    pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
4566        let mut rows = self.worn_rows();
4567        rows.extend(self.person_rows());
4568        rows.into_iter().map(|r| (r.depth, r.stack)).collect()
4569    }
4570
4571    /// Placed chests within `CONTAINER_RANGE_M`, nearest first. Contents are only
4572    /// populated when `accessible` — this is what makes a chest's contents
4573    /// disappear the moment you walk away or it's locked without your key.
4574    pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
4575        let (px, py) = self.player_position();
4576        let mut list: Vec<NearbyContainer> = self
4577            .placed_containers
4578            .iter()
4579            .filter(|c| self.placed_container_in_current_space(c))
4580            .filter_map(|c| {
4581                let distance_m = (c.x - px).hypot(c.y - py);
4582                if distance_m > CONTAINER_RANGE_M {
4583                    return None;
4584                }
4585                let mut rows = Vec::new();
4586                let from = flatland_protocol::InventoryLocation::Placed {
4587                    container_id: c.id.clone(),
4588                };
4589                rows.push(InventoryRow {
4590                    depth: 0,
4591                    stack: flatland_protocol::ItemStack {
4592                        template_id: c.template_id.clone(),
4593                        quantity: 1,
4594                        item_instance_id: c.item_instance_id,
4595                        props: Default::default(),
4596                        status_bindings: Vec::new(),
4597                        contents: Vec::new(),
4598                        display_name: Some(c.display_name.clone()),
4599                        category: Some("container".into()),
4600                        capacity_volume: c.capacity_volume,
4601                        worker_lodging_capacity: c.worker_lodging_capacity,
4602                        ..Default::default()
4603                    },
4604                    from: from.clone(),
4605                    from_parent_instance_id: None,
4606                    is_equip_shell: false,
4607                    is_chest_shell: true,
4608                    section: InventorySection::Nearby,
4609                });
4610                if c.accessible {
4611                    for child in &c.contents {
4612                        push_inventory_rows(
4613                            &mut rows,
4614                            1,
4615                            child,
4616                            &from,
4617                            c.item_instance_id,
4618                            InventorySection::Nearby,
4619                        );
4620                    }
4621                }
4622                Some(NearbyContainer {
4623                    view: c.clone(),
4624                    distance_m,
4625                    rows,
4626                })
4627            })
4628            .collect();
4629        list.sort_by(|a, b| {
4630            a.distance_m
4631                .partial_cmp(&b.distance_m)
4632                .unwrap_or(std::cmp::Ordering::Equal)
4633        });
4634        list
4635    }
4636
4637    /// Nearest placed chest within `max_dist`, regardless of accessibility.
4638    pub fn nearest_placed_container(
4639        &self,
4640        max_dist: f32,
4641    ) -> Option<flatland_protocol::PlacedContainerView> {
4642        let (px, py) = self.player_position();
4643        self.placed_containers
4644            .iter()
4645            .filter(|c| self.placed_container_in_current_space(c))
4646            .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
4647            .min_by(|a, b| {
4648                let da = (a.x - px).hypot(a.y - py);
4649                let db = (b.x - px).hypot(b.y - py);
4650                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
4651            })
4652            .cloned()
4653    }
4654
4655    /// Selectable rows for the **active inventory tab** (and current filter).
4656    /// Index into this with `inventory_menu_index`; browser lines must use the same order.
4657    pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
4658        let filter = self.inventory_filter.as_str();
4659        match self.inventory_tab {
4660            InventoryTab::OnPerson => {
4661                let mut rows = self.carried_worn_rows_filtered(filter);
4662                rows.extend(self.person_rows_filtered(filter));
4663                rows
4664            }
4665            InventoryTab::Nearby => {
4666                let mut rows = Vec::new();
4667                for nc in self.nearby_containers() {
4668                    if filter.is_empty() {
4669                        rows.extend(nc.rows);
4670                        continue;
4671                    }
4672                    let shell = nc.rows.first().cloned();
4673                    let contents: Vec<_> = nc
4674                        .rows
4675                        .iter()
4676                        .skip(1)
4677                        .filter(|r| stack_matches_filter(&r.stack, filter))
4678                        .cloned()
4679                        .collect();
4680                    let shell_hit = shell
4681                        .as_ref()
4682                        .map(|s| stack_matches_filter(&s.stack, filter))
4683                        .unwrap_or(false);
4684                    if shell_hit || !contents.is_empty() {
4685                        if let Some(s) = shell {
4686                            rows.push(s);
4687                        }
4688                        if shell_hit {
4689                            rows.extend(nc.rows.into_iter().skip(1));
4690                        } else {
4691                            rows.extend(contents);
4692                        }
4693                    }
4694                }
4695                rows
4696            }
4697        }
4698    }
4699
4700    pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
4701        self.inventory_selectable_rows()
4702            .into_iter()
4703            .nth(self.inventory_menu_index)
4704    }
4705
4706    fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
4707        let cat = self
4708            .inventory_item_category(&row.stack.template_id)
4709            .unwrap_or("");
4710        if cat == "key" {
4711            self.key_inventory_label(&row.stack)
4712        } else {
4713            row.stack
4714                .display_name
4715                .clone()
4716                .unwrap_or_else(|| row.stack.template_id.clone())
4717        }
4718    }
4719
4720    /// Signature of everything already shown on the gfx row title (bindings, grants, qty, worn slot).
4721    fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
4722        let bindings =
4723            format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
4724        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
4725            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
4726            let mode = Self::grant_mode(&row.stack);
4727            format!(" [grant {effect} · {mode} — e apply]")
4728        } else {
4729            String::new()
4730        };
4731        let qty = if row.stack.quantity > 1 {
4732            format!(" ×{}", row.stack.quantity)
4733        } else {
4734            String::new()
4735        };
4736        let worn_slot = if row.is_equip_shell {
4737            match row.from {
4738                flatland_protocol::InventoryLocation::Worn { slot } => {
4739                    format!(" ({})", body_slot_label(slot))
4740                }
4741                _ => String::new(),
4742            }
4743        } else {
4744            String::new()
4745        };
4746        format!("{grant_hint}{bindings}{qty}{worn_slot}")
4747    }
4748
4749    fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
4750        (
4751            row.stack.template_id.clone(),
4752            self.inventory_row_base_label(row),
4753            self.inventory_row_visible_mod_signature(row),
4754        )
4755    }
4756
4757    /// Keys for which two or more instanced rows look the same in the browser (need hover disambiguation).
4758    fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
4759        let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
4760        for row in self.inventory_selectable_rows() {
4761            if row.stack.item_instance_id.is_none() {
4762                continue;
4763            }
4764            let key = self.inventory_row_instance_identity_key(&row);
4765            *counts.entry(key).or_default() += 1;
4766        }
4767        counts
4768            .into_iter()
4769            .filter(|(_, n)| *n > 1)
4770            .map(|(k, _)| k)
4771            .collect()
4772    }
4773
4774    fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
4775        let hex: String = id
4776            .as_simple()
4777            .to_string()
4778            .chars()
4779            .filter(|c| c.is_ascii_hexdigit())
4780            .collect();
4781        let short = if hex.len() >= 4 {
4782            &hex[hex.len() - 4..]
4783        } else {
4784            hex.as_str()
4785        };
4786        format!("Instance {id} (#{short})")
4787    }
4788
4789    /// Format one selectable inventory row for TUI/gfx (label + hints + mass/volume).
4790    pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
4791        let cat = self
4792            .inventory_item_category(&row.stack.template_id)
4793            .unwrap_or("");
4794        let label = self.inventory_row_base_label(row);
4795        let hint: String = if row.is_equip_shell {
4796            " [worn — Enter to unequip]".into()
4797        } else if row.is_chest_shell {
4798            let (locked, lodging_note) = match &row.from {
4799                flatland_protocol::InventoryLocation::Placed { container_id } => {
4800                    let locked = self
4801                        .placed_containers
4802                        .iter()
4803                        .find(|c| c.id == *container_id)
4804                        .map(|c| c.locked)
4805                        .unwrap_or(false);
4806                    let lodging_note = self
4807                        .lodging_occupancy_label(container_id)
4808                        .map(|who| format!(" [lodging: {who}]"))
4809                        .unwrap_or_default();
4810                    (locked, lodging_note)
4811                }
4812                _ => (false, String::new()),
4813            };
4814            if locked {
4815                format!(" [locked — Enter pick up · l unlock]{lodging_note}")
4816            } else {
4817                format!(" [Enter pick up · l lock]{lodging_note}")
4818            }
4819        } else if cat == "key" {
4820            self.key_inventory_hint(&row.stack)
4821        } else {
4822            match cat {
4823                "weapon" => " [weapon]".into(),
4824                "container" => " [bag/chest/belt]".into(),
4825                "lodging" => " [worker lodging]".into(),
4826                "armor" => " [armor]".into(),
4827                _ => String::new(),
4828            }
4829        };
4830        let qty = if row.stack.quantity > 1 {
4831            format!(" ×{}", row.stack.quantity)
4832        } else {
4833            String::new()
4834        };
4835        let bindings =
4836            format_status_bindings_suffix(&row.stack.status_bindings, self.tick, DEFAULT_TICK_HZ);
4837        let grant_hint = if Self::stack_is_item_grant(&row.stack) {
4838            let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
4839            let mode = Self::grant_mode(&row.stack);
4840            format!(" [grant {effect} · {mode} — e apply]")
4841        } else {
4842            String::new()
4843        };
4844        let mass = self.stack_mass(&row.stack);
4845        let mass_kg = (mass >= 0.05).then_some(mass);
4846        let mass_str = mass_kg.map(|m| format!("  {m:.1} kg")).unwrap_or_default();
4847        let volume = self.container_volume_stats(row);
4848        let vol_str = self.container_volume_label(row);
4849
4850        let mut title = label.clone();
4851        title.push_str(&qty);
4852        if row.is_equip_shell {
4853            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
4854                title.push_str(&format!(" ({})", body_slot_label(slot)));
4855            }
4856        }
4857
4858        InventoryRowView {
4859            depth: row.depth,
4860            text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
4861            title: format!("{title}{grant_hint}{bindings}"),
4862            mass_kg,
4863            volume,
4864            instance_tooltip: None,
4865        }
4866    }
4867
4868    fn push_browser_item(
4869        &self,
4870        lines: &mut Vec<InventoryBrowserLine>,
4871        row: &InventoryRow,
4872        global_idx: &mut usize,
4873        target: usize,
4874        highlight: bool,
4875        ambiguous_instance_keys: &HashSet<(String, String, String)>,
4876    ) {
4877        let mut view = self.format_inventory_row(row);
4878        if let Some(id) = row.stack.item_instance_id {
4879            let key = self.inventory_row_instance_identity_key(row);
4880            if ambiguous_instance_keys.contains(&key) {
4881                view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
4882            }
4883        }
4884        lines.push(InventoryBrowserLine::Item {
4885            selectable_index: *global_idx,
4886            selected: highlight && *global_idx == target,
4887            depth: view.depth,
4888            text: view.text,
4889            title: view.title,
4890            mass_kg: view.mass_kg,
4891            volume: view.volume,
4892            instance_tooltip: view.instance_tooltip,
4893        });
4894        *global_idx += 1;
4895    }
4896
4897    /// Sectioned inventory browser lines for the active tab. Selectable rows carry
4898    /// `selectable_index` matching `inventory_menu_index`.
4899    pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
4900        let mut lines = Vec::new();
4901        let target = self.inventory_menu_index;
4902        let highlight = !self.show_move_picker && !self.show_grant_picker;
4903        let filter = self.inventory_filter.as_str();
4904        let mut global_idx = 0usize;
4905        let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
4906
4907        match self.inventory_tab {
4908            InventoryTab::OnPerson => {
4909                lines.push(InventoryBrowserLine::Section("— In carried bags —".into()));
4910                let carried = self.carried_worn_rows_filtered(filter);
4911                if carried.is_empty() {
4912                    lines.push(InventoryBrowserLine::Hint(
4913                        "  (no items in carried bags)".into(),
4914                    ));
4915                } else {
4916                    for row in &carried {
4917                        self.push_browser_item(
4918                            &mut lines,
4919                            row,
4920                            &mut global_idx,
4921                            target,
4922                            highlight,
4923                            &ambiguous_instance_keys,
4924                        );
4925                    }
4926                }
4927
4928                lines.push(InventoryBrowserLine::Blank);
4929                lines.push(InventoryBrowserLine::Section(
4930                    "— On you (loose, not worn) —".into(),
4931                ));
4932                let person = self.person_rows_filtered(filter);
4933                if person.is_empty() {
4934                    lines.push(InventoryBrowserLine::Hint("  (empty)".into()));
4935                } else {
4936                    let mut last_group: Option<&'static str> = None;
4937                    for row in &person {
4938                        if row.depth == 0 {
4939                            let cat = row
4940                                .stack
4941                                .category
4942                                .as_deref()
4943                                .or_else(|| self.inventory_item_category(&row.stack.template_id))
4944                                .unwrap_or("");
4945                            let (group, _) = inventory_category_group(cat);
4946                            if last_group != Some(group) {
4947                                lines.push(InventoryBrowserLine::SlotLabel(format!("  {group}")));
4948                                last_group = Some(group);
4949                            }
4950                        }
4951                        self.push_browser_item(
4952                            &mut lines,
4953                            row,
4954                            &mut global_idx,
4955                            target,
4956                            highlight,
4957                            &ambiguous_instance_keys,
4958                        );
4959                    }
4960                }
4961            }
4962            InventoryTab::Nearby => {
4963                let nearby = self.nearby_containers();
4964                if nearby.is_empty() {
4965                    lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
4966                    lines.push(InventoryBrowserLine::Hint(
4967                        "  (none within reach — walk up to a chest)".into(),
4968                    ));
4969                    lines.push(InventoryBrowserLine::Hint(
4970                        "  Select an on-person item, then m / Enter → move into chest.".into(),
4971                    ));
4972                } else {
4973                    let mut any_visible = false;
4974                    for nc in &nearby {
4975                        let shell = nc.rows.first();
4976                        let contents: Vec<&InventoryRow> = if filter.is_empty() {
4977                            nc.rows.iter().skip(1).collect()
4978                        } else {
4979                            let shell_hit = shell
4980                                .map(|s| {
4981                                    let f = filter.to_ascii_lowercase();
4982                                    let name = s
4983                                        .stack
4984                                        .display_name
4985                                        .as_deref()
4986                                        .unwrap_or("")
4987                                        .to_ascii_lowercase();
4988                                    let tid = s.stack.template_id.to_ascii_lowercase();
4989                                    name.contains(&f) || tid.contains(&f)
4990                                })
4991                                .unwrap_or(false);
4992                            if shell_hit {
4993                                nc.rows.iter().skip(1).collect()
4994                            } else {
4995                                nc.rows
4996                                    .iter()
4997                                    .skip(1)
4998                                    .filter(|r| stack_matches_filter(&r.stack, filter))
4999                                    .collect()
5000                            }
5001                        };
5002                        let shell_visible = filter.is_empty()
5003                            || shell
5004                                .map(|s| stack_matches_filter(&s.stack, filter))
5005                                .unwrap_or(false)
5006                            || !contents.is_empty();
5007                        if !shell_visible && shell.is_some() {
5008                            continue;
5009                        }
5010                        any_visible = true;
5011                        lines.push(InventoryBrowserLine::Blank);
5012                        let lock_note = if nc.view.locked && nc.view.accessible {
5013                            "  unlocked with your key"
5014                        } else if nc.view.locked {
5015                            "  locked"
5016                        } else {
5017                            ""
5018                        };
5019                        lines.push(InventoryBrowserLine::Section(format!(
5020                            "— {} ({:.0}m away){lock_note} —",
5021                            nc.view.display_name, nc.distance_m
5022                        )));
5023                        if !nc.view.accessible {
5024                            lines.push(InventoryBrowserLine::Hint(
5025                                "  locked — need the matching key (l to try)".into(),
5026                            ));
5027                        } else if nc.rows.is_empty() {
5028                            lines.push(InventoryBrowserLine::Hint(
5029                                "  (empty — switch to On person, select an item, m to move in)"
5030                                    .into(),
5031                            ));
5032                        } else if let Some(shell_row) = shell {
5033                            self.push_browser_item(
5034                                &mut lines,
5035                                shell_row,
5036                                &mut global_idx,
5037                                target,
5038                                highlight,
5039                                &ambiguous_instance_keys,
5040                            );
5041                            for row in contents {
5042                                self.push_browser_item(
5043                                    &mut lines,
5044                                    row,
5045                                    &mut global_idx,
5046                                    target,
5047                                    highlight,
5048                                    &ambiguous_instance_keys,
5049                                );
5050                            }
5051                        }
5052                    }
5053                    if !any_visible {
5054                        lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
5055                        lines.push(InventoryBrowserLine::Hint(
5056                            "  (no matching items — clear filter with Esc)".into(),
5057                        ));
5058                    }
5059                }
5060            }
5061        }
5062        lines
5063    }
5064
5065    /// Destinations for picking up a placed chest/crate into inventory.
5066    pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
5067        let mut opts = Vec::new();
5068        opts.push(MoveOption {
5069            label: "Relocate…".into(),
5070            kind: MoveOptionKind::RelocatePlaced {
5071                container_id: container_id.to_string(),
5072            },
5073        });
5074        opts.push(MoveOption {
5075            label: "On your person (loose)".into(),
5076            kind: MoveOptionKind::PickupPlaced {
5077                container_id: container_id.to_string(),
5078                nest_location: flatland_protocol::InventoryLocation::Root,
5079                nest_parent_instance_id: None,
5080            },
5081        });
5082        for (slot, item) in &self.worn {
5083            if item.category.as_deref() != Some("container") {
5084                continue;
5085            }
5086            if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
5087                continue;
5088            }
5089            let Some(parent_id) = item.item_instance_id else {
5090                continue;
5091            };
5092            let shell_name = item
5093                .display_name
5094                .clone()
5095                .unwrap_or_else(|| item.template_id.clone());
5096            opts.push(MoveOption {
5097                label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
5098                kind: MoveOptionKind::PickupPlaced {
5099                    container_id: container_id.to_string(),
5100                    nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
5101                    nest_parent_instance_id: Some(parent_id),
5102                },
5103            });
5104            // Nested pouches inside the worn bag.
5105            Self::append_chest_pickup_nested(
5106                &mut opts,
5107                container_id,
5108                flatland_protocol::InventoryLocation::Worn { slot: *slot },
5109                item,
5110                &format!("in {shell_name}"),
5111            );
5112        }
5113        opts.push(MoveOption {
5114            label: "Cancel".into(),
5115            kind: MoveOptionKind::Cancel,
5116        });
5117        opts
5118    }
5119
5120    fn append_chest_pickup_nested(
5121        opts: &mut Vec<MoveOption>,
5122        container_id: &str,
5123        location: flatland_protocol::InventoryLocation,
5124        parent: &flatland_protocol::ItemStack,
5125        context: &str,
5126    ) {
5127        for child in &parent.contents {
5128            if child.category.as_deref() != Some("container") {
5129                continue;
5130            }
5131            if !Self::is_volume_container_stack(child) {
5132                continue;
5133            }
5134            // Skip placeable nested chests — relocating into another chest is not useful.
5135            if child.world_placeable == Some(true) {
5136                continue;
5137            }
5138            let Some(child_id) = child.item_instance_id else {
5139                continue;
5140            };
5141            let name = child
5142                .display_name
5143                .clone()
5144                .unwrap_or_else(|| child.template_id.clone());
5145            opts.push(MoveOption {
5146                label: format!("{name} ({context})"),
5147                kind: MoveOptionKind::PickupPlaced {
5148                    container_id: container_id.to_string(),
5149                    nest_location: location.clone(),
5150                    nest_parent_instance_id: Some(child_id),
5151                },
5152            });
5153            Self::append_chest_pickup_nested(
5154                opts,
5155                container_id,
5156                location.clone(),
5157                child,
5158                &format!("in {name}"),
5159            );
5160        }
5161    }
5162
5163    /// Build the "move to…" destination list for an item currently at `from`.
5164    pub fn move_destinations_for(
5165        &self,
5166        from: &flatland_protocol::InventoryLocation,
5167        from_parent_instance_id: Option<uuid::Uuid>,
5168        moving_instance_id: Option<uuid::Uuid>,
5169        moving_template_id: &str,
5170    ) -> Vec<MoveOption> {
5171        let mut opts = Vec::new();
5172        if *from != flatland_protocol::InventoryLocation::Root {
5173            opts.push(MoveOption {
5174                label: "On your person (loose)".into(),
5175                kind: MoveOptionKind::Move {
5176                    location: flatland_protocol::InventoryLocation::Root,
5177                    parent_instance_id: None,
5178                },
5179            });
5180        }
5181        for (slot, item) in &self.worn {
5182            if item.category.as_deref() != Some("container") {
5183                continue;
5184            }
5185            let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
5186            let shell_name = item
5187                .display_name
5188                .clone()
5189                .unwrap_or_else(|| item.template_id.clone());
5190
5191            // Backpack and other worn volume containers — store directly inside the shell.
5192            if *slot != BodySlot::Waist
5193                && item.item_instance_id != moving_instance_id
5194                && Self::is_volume_container_stack(item)
5195            {
5196                Self::push_move_destination(
5197                    &mut opts,
5198                    format!("{shell_name} (worn {})", body_slot_label(*slot)),
5199                    location.clone(),
5200                    item.item_instance_id,
5201                    from,
5202                    from_parent_instance_id,
5203                );
5204            }
5205
5206            // Belt loops only accept pouch attachments — not loose materials.
5207            if *slot == BodySlot::Waist
5208                && Self::attaches_to_belt_loop(moving_template_id)
5209                && item.item_instance_id != moving_instance_id
5210            {
5211                Self::push_move_destination(
5212                    &mut opts,
5213                    format!("{shell_name} (belt loop)"),
5214                    location.clone(),
5215                    item.item_instance_id,
5216                    from,
5217                    from_parent_instance_id,
5218                );
5219            }
5220
5221            let context = if *slot == BodySlot::Waist {
5222                format!("on {shell_name}")
5223            } else {
5224                format!("in {shell_name}")
5225            };
5226            Self::append_nested_container_destinations(
5227                &mut opts,
5228                location,
5229                item,
5230                &context,
5231                from,
5232                from_parent_instance_id,
5233                moving_instance_id,
5234            );
5235        }
5236        for nc in self.nearby_containers() {
5237            if !nc.view.accessible {
5238                continue;
5239            }
5240            let location = flatland_protocol::InventoryLocation::Placed {
5241                container_id: nc.view.id.clone(),
5242            };
5243            Self::push_move_destination(
5244                &mut opts,
5245                format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
5246                location,
5247                nc.view.item_instance_id,
5248                from,
5249                from_parent_instance_id,
5250            );
5251        }
5252        let allow_drop = moving_instance_id
5253            .map(|id| !self.hand_equipped_instance_ids().contains(&id))
5254            .unwrap_or(true)
5255            && moving_instance_id
5256                .and_then(|id| self.stack_for_instance(id))
5257                .map(|stack| {
5258                    !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
5259                })
5260                .unwrap_or(
5261                    moving_template_id != KEY_TEMPLATE
5262                        && moving_template_id != PROPERTY_DEED_TEMPLATE,
5263                );
5264        if allow_drop {
5265            opts.push(MoveOption {
5266                label: "Drop on the ground".into(),
5267                kind: MoveOptionKind::Drop,
5268            });
5269        }
5270        opts.push(MoveOption {
5271            label: "Cancel".into(),
5272            kind: MoveOptionKind::Cancel,
5273        });
5274        opts
5275    }
5276
5277    fn is_same_container_dest(
5278        dest_location: &flatland_protocol::InventoryLocation,
5279        dest_parent: Option<uuid::Uuid>,
5280        from: &flatland_protocol::InventoryLocation,
5281        from_parent: Option<uuid::Uuid>,
5282    ) -> bool {
5283        dest_location == from && dest_parent == from_parent
5284    }
5285
5286    fn push_move_destination(
5287        opts: &mut Vec<MoveOption>,
5288        label: String,
5289        location: flatland_protocol::InventoryLocation,
5290        parent_instance_id: Option<uuid::Uuid>,
5291        from: &flatland_protocol::InventoryLocation,
5292        from_parent_instance_id: Option<uuid::Uuid>,
5293    ) {
5294        if Self::is_same_container_dest(
5295            &location,
5296            parent_instance_id,
5297            from,
5298            from_parent_instance_id,
5299        ) {
5300            return;
5301        }
5302        opts.push(MoveOption {
5303            label,
5304            kind: MoveOptionKind::Move {
5305                location,
5306                parent_instance_id,
5307            },
5308        });
5309    }
5310
5311    fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
5312        stack.capacity_volume.is_some_and(|c| c > 0.0)
5313    }
5314
5315    fn attaches_to_belt_loop(template_id: &str) -> bool {
5316        matches!(template_id, "leather_pouch" | "dimensional_pouch")
5317    }
5318
5319    fn append_nested_container_destinations(
5320        opts: &mut Vec<MoveOption>,
5321        location: flatland_protocol::InventoryLocation,
5322        container: &flatland_protocol::ItemStack,
5323        context: &str,
5324        from: &flatland_protocol::InventoryLocation,
5325        from_parent_instance_id: Option<uuid::Uuid>,
5326        moving_instance_id: Option<uuid::Uuid>,
5327    ) {
5328        for child in &container.contents {
5329            if Self::is_volume_container_stack(child)
5330                && child.item_instance_id != moving_instance_id
5331            {
5332                let name = child
5333                    .display_name
5334                    .clone()
5335                    .unwrap_or_else(|| child.template_id.clone());
5336                Self::push_move_destination(
5337                    opts,
5338                    format!("{name} ({context})"),
5339                    location.clone(),
5340                    child.item_instance_id,
5341                    from,
5342                    from_parent_instance_id,
5343                );
5344            }
5345            let nested_context = format!(
5346                "in {}",
5347                child.display_name.as_deref().unwrap_or(&child.template_id)
5348            );
5349            Self::append_nested_container_destinations(
5350                opts,
5351                location.clone(),
5352                child,
5353                &nested_context,
5354                from,
5355                from_parent_instance_id,
5356                moving_instance_id,
5357            );
5358        }
5359    }
5360
5361    fn clamp_inventory_indices(&mut self) {
5362        let n = self.inventory_selectable_rows().len();
5363        self.inventory_menu_index = if n == 0 {
5364            0
5365        } else {
5366            self.inventory_menu_index.min(n - 1)
5367        };
5368        if let Some(picker) = &self.move_picker {
5369            let pn = picker.options.len();
5370            self.move_picker_index = if pn == 0 {
5371                0
5372            } else {
5373                self.move_picker_index.min(pn - 1)
5374            };
5375        }
5376    }
5377
5378    /// Drop interior map layers whenever the player is outdoors.
5379    /// Restores outdoor z-bands saved before [`Self::sync_interior_z_bands`] overwrote them.
5380    /// Tick deltas never refresh `z_platforms`; without restore, stale interior platforms
5381    /// force `build_grid` into a full-map z pass on every click until client restart.
5382    fn sync_interior_map_context(&mut self) {
5383        if self.effective_inside_building().is_none() {
5384            self.interior_map = None;
5385            if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
5386                self.z_platforms = platforms;
5387                self.z_transitions = transitions;
5388            }
5389            return;
5390        }
5391        self.sync_interior_z_bands();
5392    }
5393
5394    /// While indoors, z-bands come from the active interior blueprint (multi-floor stairs).
5395    fn sync_interior_z_bands(&mut self) {
5396        if self.effective_inside_building().is_some() {
5397            if let Some(map) = &self.interior_map {
5398                if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
5399                    if self.z_bands_outdoor_backup.is_none() {
5400                        self.z_bands_outdoor_backup = Some((
5401                            std::mem::take(&mut self.z_platforms),
5402                            std::mem::take(&mut self.z_transitions),
5403                        ));
5404                    }
5405                    self.z_platforms = map.z_platforms.clone();
5406                    self.z_transitions = map.z_transitions.clone();
5407                }
5408            }
5409        }
5410    }
5411
5412    fn apply_snapshot_fields(
5413        &mut self,
5414        snapshot: &flatland_protocol::Snapshot,
5415        entity_id: EntityId,
5416    ) {
5417        self.tick = snapshot.tick;
5418        self.chunk_rev = snapshot.chunk_rev;
5419        self.content_rev = snapshot.content_rev;
5420        self.publish_rev = snapshot.publish_rev;
5421        self.resource_nodes = snapshot.resource_nodes.clone();
5422        self.replace_harvest_route_nodes(&snapshot.resource_nodes);
5423        self.ground_drops = snapshot.ground_drops.clone();
5424        self.placed_containers = snapshot.placed_containers.clone();
5425        self.world_x0 = snapshot.world_x0;
5426        self.world_y0 = snapshot.world_y0;
5427        self.world_width_m = snapshot.world_width_m;
5428        self.world_height_m = snapshot.world_height_m;
5429        self.world_clock = snapshot.world_clock;
5430        self.terrain_zones = snapshot.terrain_zones.clone();
5431        self.z_platforms = snapshot.z_platforms.clone();
5432        self.z_transitions = snapshot.z_transitions.clone();
5433        // Snapshot is authoritative for the current context; drop any prior enter/leave stash.
5434        self.z_bands_outdoor_backup = None;
5435        self.buildings = snapshot.buildings.clone();
5436        self.doors = snapshot.doors.clone();
5437        self.interior_map = snapshot.interior_map.clone();
5438        self.npcs = snapshot.npcs.clone();
5439        self.blueprints = snapshot.blueprints.clone();
5440        self.building_materials = snapshot.building_materials.clone();
5441        self.sync_inventory_from_stacks(&snapshot.inventory);
5442        self.player = snapshot
5443            .entities
5444            .iter()
5445            .find(|e| e.id == entity_id)
5446            .cloned();
5447        self.entities = snapshot.entities.clone();
5448        self.quest_log = snapshot.quest_log.clone();
5449        self.apply_hired_workers(snapshot.hired_workers.clone());
5450        self.interactables = snapshot.interactables.clone();
5451        self.ledger = snapshot.ledger.clone();
5452        self.career = snapshot.career.clone();
5453        self.combat_fx = snapshot.combat_fx.clone();
5454        self.ground_hazards = snapshot.ground_hazards.clone();
5455        self.property_zones = snapshot.property_zones.clone();
5456        self.tax_zones = snapshot.tax_zones.clone();
5457        self.growth_zones = snapshot.growth_zones.clone();
5458        self.biome_zones = snapshot.biome_zones.clone();
5459        self.terrain_kind_nav = snapshot.terrain_kind_nav.clone();
5460        self.property_plots = snapshot.property_plots.clone();
5461        self.property_plot_settings = snapshot.property_plot_settings.clone();
5462        self.sync_item_catalog(&snapshot.item_catalog);
5463        // If welcome/reconnect lands indoors, seed an empty outdoor restore target so
5464        // sync_interior_z_bands does not stash interior snapshot bands as "outdoor".
5465        if self.effective_inside_building().is_some() {
5466            self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
5467        }
5468        self.sync_interior_map_context();
5469        self.refresh_whisper_range();
5470        self.sync_gameplay_audio();
5471    }
5472
5473    fn replace_harvest_route_nodes(
5474        &mut self,
5475        incoming: &[flatland_protocol::ResourceNodeView],
5476    ) {
5477        self.harvest_route_nodes = incoming
5478            .iter()
5479            .filter(|n| crate::is_harvest_route_node(n))
5480            .cloned()
5481            .collect();
5482    }
5483
5484    fn upsert_harvest_route_nodes(
5485        &mut self,
5486        incoming: &[flatland_protocol::ResourceNodeView],
5487    ) {
5488        for node in incoming.iter().filter(|n| crate::is_harvest_route_node(n)) {
5489            if let Some(existing) = self
5490                .harvest_route_nodes
5491                .iter_mut()
5492                .find(|n| n.id == node.id)
5493            {
5494                *existing = node.clone();
5495            } else {
5496                self.harvest_route_nodes.push(node.clone());
5497            }
5498        }
5499    }
5500
5501    /// Re-derive inventory selection state after a snapshot/tick — chests that
5502    /// went out of range or got locked simply vanish from the row list, and the
5503    /// move picker (if any) closes once its item is no longer reachable.
5504    fn refresh_inventory_ui(&mut self) {
5505        if let Some(picker) = &self.move_picker {
5506            let instance_id = picker.item_instance_id;
5507            let still_exists = self
5508                .inventory_selectable_rows()
5509                .iter()
5510                .any(|r| r.stack.item_instance_id == Some(instance_id));
5511            if !still_exists {
5512                self.move_picker = None;
5513                self.show_move_picker = false;
5514            }
5515        }
5516        if let Some(picker) = &self.destroy_picker {
5517            let instance_id = picker.item_instance_id;
5518            let still_exists = self
5519                .inventory_selectable_rows()
5520                .iter()
5521                .any(|r| r.stack.item_instance_id == Some(instance_id));
5522            if !still_exists {
5523                self.destroy_picker = None;
5524                self.show_destroy_picker = false;
5525                self.destroy_confirm_pending = false;
5526            }
5527        }
5528        self.clamp_inventory_indices();
5529    }
5530
5531    /// Replace the hired-worker list from a snapshot/delta.
5532    ///
5533    /// Keeps a stable sort and remaps `workers_menu_index` by instance id so the
5534    /// selected worker (and its step line) does not jump when the server rebuilds
5535    /// the list.
5536    fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
5537        let selected_id = self
5538            .hired_workers
5539            .get(self.workers_menu_index)
5540            .map(|w| w.instance_id.clone());
5541        let previous_worker_ids: HashSet<String> = self
5542            .hired_workers
5543            .iter()
5544            .map(|worker| worker.instance_id.clone())
5545            .collect();
5546        workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
5547        let now = Instant::now();
5548        let saw_new_worker = workers
5549            .iter()
5550            .any(|worker| !previous_worker_ids.contains(&worker.instance_id));
5551        for worker in &workers {
5552            let was_hit = self
5553                .hired_workers
5554                .iter()
5555                .find(|previous| previous.instance_id == worker.instance_id)
5556                .is_some_and(|previous| {
5557                    matches!(worker.mode, flatland_protocol::WorkerModeView::Defender)
5558                        && worker.vitals.health_pct + 0.01 < previous.vitals.health_pct
5559                });
5560            if was_hit {
5561                self.worker_health_ring_until
5562                    .insert(worker.entity_id, now + WORKER_HEALTH_RING_HOLD);
5563            }
5564        }
5565        let worker_entity_ids: HashSet<EntityId> =
5566            workers.iter().map(|worker| worker.entity_id).collect();
5567        self.worker_health_ring_until
5568            .retain(|entity_id, _| worker_entity_ids.contains(entity_id));
5569        for w in &workers {
5570            let prev_err = self
5571                .hired_workers
5572                .iter()
5573                .find(|p| p.instance_id == w.instance_id)
5574                .and_then(|p| p.last_error.as_deref());
5575            let new_err = w.last_error.as_deref();
5576            if new_err != prev_err {
5577                if let Some(err) = new_err {
5578                    if !worker_error_is_transient(err) {
5579                        self.push_log(format!("Worker {}: {err}", w.label));
5580                    }
5581                }
5582            }
5583        }
5584        let mut next_display = BTreeMap::new();
5585        let mut next_errors = BTreeMap::new();
5586        for w in &workers {
5587            let mut sticky = self
5588                .worker_step_display
5589                .remove(&w.instance_id)
5590                .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
5591            sticky.observe(&w.step_label, now);
5592            next_display.insert(w.instance_id.clone(), sticky);
5593
5594            let mut err_sticky = self
5595                .worker_error_display
5596                .remove(&w.instance_id)
5597                .unwrap_or_default();
5598            err_sticky.observe(w.last_error.as_deref(), now);
5599            if err_sticky.shown(now).is_some() {
5600                next_errors.insert(w.instance_id.clone(), err_sticky);
5601            }
5602        }
5603        self.worker_step_display = next_display;
5604        self.worker_error_display = next_errors;
5605        self.hired_workers = workers;
5606        if saw_new_worker {
5607            self.pending_worker_hire_since = None;
5608        }
5609        self.sync_worker_take_picker_from_hired();
5610        if let Some(id) = selected_id {
5611            if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
5612                self.workers_menu_index = idx;
5613                return;
5614            }
5615        }
5616        if self.workers_menu_index >= self.hired_workers.len() {
5617            self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
5618        }
5619    }
5620
5621    /// Keep the take-from-worker sheet in sync after hired-worker inventory updates.
5622    fn sync_worker_take_picker_from_hired(&mut self) {
5623        if !self.show_worker_take_picker {
5624            return;
5625        }
5626        let Some(picker) = self.worker_take_picker.clone() else {
5627            return;
5628        };
5629        let Some(worker) = self
5630            .hired_workers
5631            .iter()
5632            .find(|w| w.instance_id == picker.worker_instance_id)
5633            .cloned()
5634        else {
5635            self.show_worker_take_picker = false;
5636            self.worker_take_picker = None;
5637            self.worker_take_picker_index = 0;
5638            return;
5639        };
5640        let options: Vec<WorkerGiveOption> = worker
5641            .inventory
5642            .iter()
5643            .filter_map(|stack| {
5644                let item_instance_id = stack.item_instance_id?;
5645                let label = stack
5646                    .display_name
5647                    .clone()
5648                    .unwrap_or_else(|| stack.template_id.clone());
5649                let label = if stack.quantity > 1 {
5650                    format!("{label} ×{}", stack.quantity)
5651                } else {
5652                    label
5653                };
5654                Some(WorkerGiveOption {
5655                    item_instance_id,
5656                    label,
5657                    quantity: stack.quantity,
5658                    template_id: stack.template_id.clone(),
5659                })
5660            })
5661            .collect();
5662        if options.is_empty() {
5663            self.show_worker_take_picker = false;
5664            self.worker_take_picker = None;
5665            self.worker_take_picker_index = 0;
5666            return;
5667        }
5668        let prev_id = picker
5669            .options
5670            .get(self.worker_take_picker_index)
5671            .map(|o| o.item_instance_id);
5672        let idx = prev_id
5673            .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
5674            .unwrap_or(0)
5675            .min(options.len().saturating_sub(1));
5676        let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
5677        let quantity = picker.quantity.clamp(1, max_qty);
5678        self.worker_take_picker_index = idx;
5679        self.worker_take_picker = Some(WorkerTakePicker {
5680            worker_instance_id: picker.worker_instance_id,
5681            worker_label: picker.worker_label,
5682            options,
5683            quantity,
5684        });
5685    }
5686
5687    /// Held coarse step label for the workers menu (`step:` line).
5688    pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
5689        self.worker_step_display
5690            .get(worker_instance_id)
5691            .map(|s| s.shown.as_str())
5692            .or_else(|| {
5693                self.hired_workers
5694                    .iter()
5695                    .find(|w| w.instance_id == worker_instance_id)
5696                    .map(|w| w.step_label.as_str())
5697            })
5698            .unwrap_or("")
5699    }
5700
5701    /// Held error line for workers UI (detail + compact).
5702    pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
5703        let now = Instant::now();
5704        self.worker_error_display
5705            .get(worker_instance_id)
5706            .and_then(|s| s.shown(now))
5707            .or_else(|| {
5708                self.hired_workers
5709                    .iter()
5710                    .find(|w| w.instance_id == worker_instance_id)
5711                    .and_then(|w| w.last_error.as_deref())
5712                    .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
5713            })
5714            .filter(|e| !worker_error_is_hud_noise(e))
5715    }
5716
5717    fn apply_combat_hud(&mut self, combat: &CombatHud) {
5718        self.in_combat = combat.in_combat;
5719        self.auto_attack = combat.auto_attack;
5720        self.combat_has_los = combat.has_los;
5721        self.attack_cd_ticks = combat.attack_cd_ticks;
5722        self.gcd_ticks = combat.gcd_ticks;
5723        self.weapon_ability_id = combat.ability_id.clone();
5724        self.mainhand_template_id = combat.mainhand_template_id.clone();
5725        self.mainhand_label = combat.mainhand_label.clone();
5726        self.mainhand_instance_id = combat.mainhand_instance_id;
5727        self.offhand_template_id = combat.offhand_template_id.clone();
5728        self.offhand_label = combat.offhand_label.clone();
5729        self.offhand_instance_id = combat.offhand_instance_id;
5730        self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
5731            1
5732        } else {
5733            combat.mainhand_hand_slots
5734        };
5735        self.defense = combat.defense.clone();
5736        self.worn = combat.worn.iter().cloned().collect();
5737        self.carry_mass = combat.carry_mass;
5738        self.carry_mass_max = combat.carry_mass_max;
5739        self.encumbrance = combat.encumbrance;
5740        self.move_speed_mps = combat.move_speed_mps;
5741        self.move_speed_mult = combat.move_speed_mult;
5742        self.cast_progress = combat.cast.clone();
5743        self.timed_channel = combat.timed_channel.clone();
5744        if self.active_craft_channel().is_none() {
5745            self.craft_channel_blueprint_id = None;
5746        }
5747        self.plot_build_offer = combat.plot_build.clone();
5748        self.ability_cooldowns = combat.ability_cooldowns.clone();
5749        self.blocking_active = combat.blocking_active;
5750        self.max_target_slots = combat.max_target_slots.max(1);
5751        self.combat_slots = combat.slots.clone();
5752        self.rotation_presets = combat.rotation_presets.clone();
5753        self.known_abilities = combat.known_abilities.clone();
5754        self.ability_meta = combat
5755            .ability_meta
5756            .iter()
5757            .cloned()
5758            .map(|meta| (meta.id.clone(), meta))
5759            .collect();
5760        self.ability_mastery = combat
5761            .ability_mastery
5762            .iter()
5763            .cloned()
5764            .map(|row| (row.ability_id.clone(), row))
5765            .collect();
5766        self.hotbar = combat.hotbar.clone();
5767        self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
5768        self.keychain_stacks = combat.keychain.clone();
5769        self.whisper_pouch_stacks = combat.whisper_pouch.clone();
5770        self.combat_target_detail = combat.target.clone();
5771        self.statuses = combat.statuses.clone();
5772        self.combat_target = combat.target_entity_id;
5773        if combat.progression_xp_base > 0.0 {
5774            self.progression_curve = Some(flatland_protocol::ProgressionCurve {
5775                baseline_display: combat.progression_baseline,
5776                xp_base: combat.progression_xp_base,
5777                xp_growth: combat.progression_xp_growth,
5778            });
5779        }
5780        if let Some(xp) = &combat.progression_xp {
5781            if let Some(player) = &mut self.player {
5782                player.progression_xp = Some(xp.clone());
5783                if let Some(attrs) = combat.attributes {
5784                    player.attributes = Some(attrs);
5785                }
5786                if let Some(skills) = &combat.skills {
5787                    player.skills = Some(skills.clone());
5788                }
5789            }
5790        }
5791        if let Some(label) = &combat.target_label {
5792            self.combat_target_label = Some(label.clone());
5793        } else if let Some(id) = combat.target_entity_id {
5794            self.combat_target_label = self
5795                .entities
5796                .iter()
5797                .find(|e| e.id == id)
5798                .map(|e| e.label.clone())
5799                .or_else(|| self.combat_target_label.clone());
5800        }
5801        self.refresh_inventory_ui();
5802    }
5803
5804    /// Entity id assigned to a combat slot (from server HUD).
5805    pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
5806        self.combat_slots
5807            .iter()
5808            .find(|s| s.slot_index == slot)
5809            .and_then(|s| s.target_entity_id)
5810            .or_else(|| if slot == 1 { self.combat_target } else { None })
5811    }
5812
5813    /// True if `ability_id` may be cast at a free ground point (`aim_mode` `ground` or `either`).
5814    pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
5815        self.ability_meta
5816            .get(ability_id)
5817            .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
5818            // Until HUD meta syncs, still allow ground cast when an aim point is armed —
5819            // the server validates aim_mode.
5820            .unwrap_or(self.ground_target.is_some())
5821    }
5822
5823    /// True if `ability_id` requires a free ground point (`aim_mode` `ground`).
5824    pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
5825        self.ability_meta
5826            .get(ability_id)
5827            .map(|meta| meta.aim_mode == "ground")
5828            .unwrap_or(false)
5829    }
5830
5831    /// True when the ability may be placed in an auto-rotation preset.
5832    /// Missing meta (pre-sync) defaults to eligible; server still validates.
5833    pub fn ability_auto_rotation_eligible(&self, ability_id: &str) -> bool {
5834        self.ability_meta
5835            .get(ability_id)
5836            .map(|meta| meta.auto_rotation_eligible)
5837            .unwrap_or(true)
5838    }
5839
5840    /// Set the local free-aim ground target (world meters, Shift+click on open ground).
5841    pub fn set_ground_target(&mut self, x: f32, y: f32) {
5842        self.ground_target = Some((x, y, 0.0));
5843    }
5844
5845    /// Clear the local free-aim ground target.
5846    pub fn clear_ground_target(&mut self) {
5847        self.ground_target = None;
5848    }
5849
5850    /// Ability bound to hotbar key `1`–`9` from server-persisted state.
5851    /// May be an ability id or `item:<template_id>` consumable binding.
5852    pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
5853        if !(1..=9).contains(&slot_1_to_9) {
5854            return None;
5855        }
5856        self.hotbar
5857            .get((slot_1_to_9 - 1) as usize)
5858            .and_then(|a| a.as_deref())
5859            .filter(|id| !id.is_empty())
5860    }
5861
5862    /// Short label for a hotbar slot (ability id, or consumable name × qty).
5863    pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
5864        let binding = self.hotbar_ability(slot_1_to_9)?;
5865        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
5866            let name = self
5867                .inventory_hints
5868                .get(template_id)
5869                .map(|h| h.display_name.as_str())
5870                .unwrap_or(template_id);
5871            let qty = self.inventory.get(template_id).copied().unwrap_or(0);
5872            Some(format!("{name}×{qty}"))
5873        } else {
5874            Some(binding.to_string())
5875        }
5876    }
5877
5878    /// Known abilities plus current weapon ability (for loadout / rotation pickers).
5879    pub fn loadout_ability_choices(&self) -> Vec<String> {
5880        let mut out = self.known_abilities.clone();
5881        let weapon = self.weapon_ability_id.trim();
5882        if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
5883            out.push(weapon.to_string());
5884        }
5885        out
5886    }
5887
5888    /// Hotbar bind candidates: learned abilities, then on-person consumables.
5889    pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
5890        let mut out = Vec::new();
5891        for ability in self.loadout_ability_choices() {
5892            let meta = if ability == self.weapon_ability_id {
5893                Some("weapon".into())
5894            } else {
5895                None
5896            };
5897            out.push(LoadoutHotbarChoice {
5898                binding: ability.clone(),
5899                label: ability,
5900                meta,
5901            });
5902        }
5903        let mut consumables: Vec<(String, String, u32)> = Vec::new();
5904        for stack in &self.inventory_stacks {
5905            if Self::stack_is_item_grant(stack) {
5906                continue;
5907            }
5908            if Self::stack_is_blueprint_scroll(stack) {
5909                continue;
5910            }
5911            if self.inventory_item_category(&stack.template_id) != Some("consumable")
5912                && !Self::stack_is_serving(stack)
5913            {
5914                continue;
5915            }
5916            let qty = stack.quantity.max(1);
5917            if let Some((_, _, existing)) = consumables
5918                .iter_mut()
5919                .find(|(id, _, _)| id == &stack.template_id)
5920            {
5921                *existing = existing.saturating_add(qty);
5922            } else {
5923                let label = stack
5924                    .display_name
5925                    .clone()
5926                    .or_else(|| {
5927                        self.inventory_hints
5928                            .get(&stack.template_id)
5929                            .map(|h| h.display_name.clone())
5930                    })
5931                    .unwrap_or_else(|| stack.template_id.clone());
5932                consumables.push((stack.template_id.clone(), label, qty));
5933            }
5934        }
5935        consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
5936        for (template_id, label, qty) in consumables {
5937            out.push(LoadoutHotbarChoice {
5938                binding: flatland_protocol::hotbar_consumable_binding(&template_id),
5939                label: format!("{label} ×{qty}"),
5940                meta: Some("use".into()),
5941            });
5942        }
5943        out
5944    }
5945
5946    /// Hostile wildlife / monsters for T1 (`Tab`).
5947    pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
5948        self.combat_candidates()
5949    }
5950
5951    /// Allies first, then monsters, for T2 (`Shift+Tab`). Includes self for heals.
5952    pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
5953        let (px, py) = self.player_position();
5954        let dist = |id: EntityId| {
5955            self.entities
5956                .iter()
5957                .find(|e| e.id == id)
5958                .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
5959                .unwrap_or(f32::MAX)
5960        };
5961
5962        let mut allies = Vec::new();
5963        // Self first — heal_touch on T2.
5964        if let Some(me) = self.player.as_ref() {
5965            let alive = me
5966                .vitals
5967                .as_ref()
5968                .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
5969                .unwrap_or(true);
5970            if alive {
5971                allies.push((self.entity_id, "Yourself".into()));
5972            }
5973        }
5974        for entity in &self.entities {
5975            if entity.id == self.entity_id {
5976                continue;
5977            }
5978            if entity.vitals.is_some() {
5979                let alive = entity
5980                    .vitals
5981                    .as_ref()
5982                    .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
5983                    .unwrap_or(true);
5984                if alive {
5985                    allies.push((entity.id, entity.label.clone()));
5986                }
5987            }
5988        }
5989        allies.sort_by(|(a, _), (b, _)| {
5990            if *a == self.entity_id {
5991                return std::cmp::Ordering::Less;
5992            }
5993            if *b == self.entity_id {
5994                return std::cmp::Ordering::Greater;
5995            }
5996            dist(*a)
5997                .partial_cmp(&dist(*b))
5998                .unwrap_or(std::cmp::Ordering::Equal)
5999        });
6000
6001        let mut monsters = self.combat_candidates();
6002        monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
6003        allies.into_iter().chain(monsters).collect()
6004    }
6005
6006    fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
6007        match slot_index {
6008            2 => self.t2_candidates(),
6009            _ => self.t1_candidates(),
6010        }
6011    }
6012
6013    /// Nearest combat candidate within `radius_m` of world click (gfx click-to-target).
6014    pub fn pick_combat_target_at(
6015        &self,
6016        wx: f32,
6017        wy: f32,
6018        slot_index: u8,
6019        radius_m: f32,
6020    ) -> Option<(EntityId, String)> {
6021        let mut best: Option<(f32, EntityId, String)> = None;
6022        for (id, label) in self.candidates_for_slot(slot_index) {
6023            let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
6024                // NPCs may only appear in NpcView — fall back to npc list coords.
6025                if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
6026                    let d = distance(wx, wy, npc.x, npc.y);
6027                    if d <= radius_m {
6028                        best = match best {
6029                            Some((bd, _, _)) if bd <= d => best,
6030                            _ => Some((d, id, label)),
6031                        };
6032                    }
6033                }
6034                continue;
6035            };
6036            let d = distance(
6037                wx,
6038                wy,
6039                entity.transform.position.x,
6040                entity.transform.position.y,
6041            );
6042            if d <= radius_m {
6043                best = match best {
6044                    Some((bd, _, _)) if bd <= d => best,
6045                    _ => Some((d, id, label)),
6046                };
6047            }
6048        }
6049        best.map(|(_, id, label)| (id, label))
6050    }
6051
6052    /// Full client reset after Welcome (initial connect or reconnect).
6053    pub(crate) fn restore_from_welcome(
6054        &mut self,
6055        session_id: SessionId,
6056        entity_id: EntityId,
6057        snapshot: &flatland_protocol::Snapshot,
6058    ) {
6059        self.clear_harvest_state();
6060        self.disconnect_reason = None;
6061        self.show_stats = false;
6062        self.show_craft_menu = false;
6063        self.show_shop_menu = false;
6064        self.shop_catalog = None;
6065        self.show_inventory_menu = false;
6066        self.session_id = session_id;
6067        self.entity_id = entity_id;
6068        self.connected = true;
6069        self.apply_snapshot_fields(snapshot, entity_id);
6070        if let Some(combat) = &snapshot.combat {
6071            self.apply_combat_hud(combat);
6072            let stacks = self.inventory_stacks.clone();
6073            self.sync_inventory_from_stacks(&stacks);
6074        }
6075    }
6076
6077    fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
6078        self.tick = delta.tick;
6079        self.world_clock = delta.world_clock;
6080
6081        // Degenerate AOI tick (observer missing server-side): keep welcome snapshot layers.
6082        if delta.entities.is_empty() {
6083            self.ground_drops = delta.ground_drops.clone();
6084            self.combat_fx = delta.combat_fx.clone();
6085            self.ground_hazards = delta.ground_hazards.clone();
6086            self.property_plots = delta.property_plots.clone();
6087            self.apply_terrain_overlays(&delta.terrain_overlays);
6088            if let Some(combat) = &delta.combat {
6089                self.apply_combat_hud(combat);
6090                let stacks = self.inventory_stacks.clone();
6091                self.sync_inventory_from_stacks(&stacks);
6092            }
6093            // Peer vanished from AOI — drop directed whisper.
6094            self.refresh_whisper_range();
6095            self.sync_gameplay_audio();
6096            return;
6097        }
6098        if !delta.buildings.is_empty() {
6099            self.buildings = delta.buildings.clone();
6100        }
6101        if !delta.blueprints.is_empty() {
6102            self.blueprints = delta.blueprints.clone();
6103        }
6104        if !delta.building_materials.is_empty() {
6105            self.building_materials = delta.building_materials.clone();
6106        }
6107        self.sync_inventory_from_stacks(&delta.inventory);
6108
6109        if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
6110            self.player = Some(updated.clone());
6111        }
6112        self.entities = delta.entities.clone();
6113        if self.player.is_none() {
6114            self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
6115        }
6116
6117        self.sync_interior_map_context();
6118
6119        // Static world layers: ticks often omit these (indoors, or unchanged).
6120        // Never wipe the welcome snapshot with an empty vec — except when indoors,
6121        // where empty means "no nodes in this building" (clear outdoor leftovers).
6122        if !delta.resource_nodes.is_empty() {
6123            self.resource_nodes = delta.resource_nodes.clone();
6124            self.upsert_harvest_route_nodes(&delta.resource_nodes);
6125        } else if delta.interior_map.is_some() || self.effective_inside_building().is_some() {
6126            self.resource_nodes = delta.resource_nodes.clone();
6127        }
6128        self.ground_drops = delta.ground_drops.clone();
6129        // Always replace — empty indoors means no chests in this building (do not keep outdoor ghosts).
6130        self.placed_containers = delta.placed_containers.clone();
6131        if !delta.doors.is_empty() {
6132            self.doors = delta.doors.clone();
6133        }
6134        if self.effective_inside_building().is_some() {
6135            if let Some(map) = &delta.interior_map {
6136                self.interior_map = Some(map.clone());
6137            }
6138        } else {
6139            self.interior_map = None;
6140        }
6141        self.sync_interior_z_bands();
6142        // Always replace NPC AOI — empty means none in range (do not keep ghosts).
6143        self.npcs = delta.npcs.clone();
6144        if !delta.quest_log.is_empty() {
6145            self.quest_log = delta.quest_log.clone();
6146        }
6147        self.apply_hired_workers(delta.hired_workers.clone());
6148        if !delta.interactables.is_empty() {
6149            self.interactables = delta.interactables.clone();
6150        }
6151        if delta.ledger.is_some() {
6152            self.ledger = delta.ledger.clone();
6153        }
6154        if delta.career.is_some() {
6155            self.career = delta.career.clone();
6156        }
6157        self.combat_fx = delta.combat_fx.clone();
6158        self.ground_hazards = delta.ground_hazards.clone();
6159        // Empty = unchanged (server may stagger plot rebuilds); non-empty replaces.
6160        if !delta.property_plots.is_empty() {
6161            self.property_plots = delta.property_plots.clone();
6162        }
6163        self.apply_terrain_overlays(&delta.terrain_overlays);
6164        if let Some(combat) = &delta.combat {
6165            self.apply_combat_hud(combat);
6166            let stacks = self.inventory_stacks.clone();
6167            self.sync_inventory_from_stacks(&stacks);
6168        } else {
6169            self.refresh_inventory_ui();
6170        }
6171        self.refresh_whisper_range();
6172        self.sync_gameplay_audio();
6173    }
6174
6175    /// Merge runtime cultivate overlays into `terrain_zones` (ids prefixed `rt:`).
6176    /// Always replaces prior `rt:` entries with the server's near-set (empty = none nearby).
6177    fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
6178        self.terrain_zones.retain(|z| !z.id.starts_with("rt:"));
6179        self.terrain_zones.extend(overlays.iter().cloned());
6180    }
6181
6182    /// End directed whisper when the peer is farther than interact range (or missing).
6183    /// Each client runs this locally so both players drop whisper mode when either walks away.
6184    fn refresh_whisper_range(&mut self) {
6185        let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
6186            return;
6187        };
6188        let (px, py) = self.player_position();
6189        let in_range = self.entities.iter().any(|e| {
6190            e.id == peer
6191                && distance(px, py, e.transform.position.x, e.transform.position.y)
6192                    <= INTERACTION_RADIUS_M
6193        });
6194        if !in_range {
6195            self.social_chat.cancel_whisper_out_of_range();
6196        }
6197    }
6198
6199    /// Wildlife and other combat targets visible in AOI (`NpcView.entity_id`).
6200    pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
6201        let (px, py) = self.player_position();
6202        let mut out = Vec::new();
6203        for npc in &self.npcs {
6204            let Some(eid) = npc.entity_id else {
6205                continue;
6206            };
6207            let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
6208            let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
6209            if alive && has_hp {
6210                out.push((eid, npc.label.clone()));
6211            }
6212        }
6213        out.sort_by(|(a_id, a_label), (b_id, b_label)| {
6214            let dist = |id: EntityId| {
6215                self.entities
6216                    .iter()
6217                    .find(|e| e.id == id)
6218                    .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
6219                    .unwrap_or(f32::MAX)
6220            };
6221            dist(*a_id)
6222                .partial_cmp(&dist(*b_id))
6223                .unwrap_or(std::cmp::Ordering::Equal)
6224                .then_with(|| a_label.cmp(b_label))
6225                .then_with(|| a_id.cmp(b_id))
6226        });
6227        out
6228    }
6229
6230    pub fn refresh_combat_target_label(&mut self) {
6231        let Some(id) = self.combat_target else {
6232            return;
6233        };
6234        if let Some((_, label)) = self
6235            .combat_candidates()
6236            .into_iter()
6237            .find(|(eid, _)| *eid == id)
6238        {
6239            self.combat_target_label = Some(label);
6240        } else if let Some(label) = self
6241            .entities
6242            .iter()
6243            .find(|e| e.id == id)
6244            .map(|e| e.label.clone())
6245        {
6246            self.combat_target_label = Some(label);
6247        }
6248    }
6249
6250    pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
6251        self.quest_log
6252            .iter()
6253            .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
6254            .collect()
6255    }
6256
6257    /// True when the observer has at least one free lodging slot (max workers = beds).
6258    pub fn has_worker_lodging(&self) -> bool {
6259        self.free_worker_lodging_slots() > 0
6260    }
6261
6262    /// Owned lodging capacity minus currently hired workers.
6263    pub fn free_worker_lodging_slots(&self) -> i64 {
6264        let slots: u32 = self
6265            .placed_containers
6266            .iter()
6267            .filter(|c| match (self.character_id, c.owner_character_id) {
6268                (Some(me), Some(owner)) => me == owner,
6269                (Some(_), None) => false,
6270                (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
6271            })
6272            .map(|c| c.worker_lodging_capacity.unwrap_or(0))
6273            .sum();
6274        let used = self.hired_workers.len() as u32;
6275        slots as i64 - used as i64
6276    }
6277
6278    /// Display names of hired workers assigned to this lodging container.
6279    pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
6280        let mut names: Vec<String> = self
6281            .hired_workers
6282            .iter()
6283            .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
6284            .map(|w| w.label.clone())
6285            .collect();
6286        names.sort();
6287        names
6288    }
6289
6290    /// Compact lodging occupancy for labels: `"Elda, Ana"`, `"vacant"`, or `""` if not lodging.
6291    pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
6292        let is_lodging = self
6293            .placed_containers
6294            .iter()
6295            .find(|c| c.id == container_id)
6296            .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
6297        if !is_lodging {
6298            return None;
6299        }
6300        let names = self.lodging_occupant_labels(container_id);
6301        Some(if names.is_empty() {
6302            "vacant".into()
6303        } else {
6304            names.join(", ")
6305        })
6306    }
6307
6308    /// True when a lodging bed has at least one hired worker assigned.
6309    pub fn lodging_is_occupied(&self, container_id: &str) -> bool {
6310        matches!(
6311            self.lodging_occupancy_label(container_id),
6312            Some(label) if label != "vacant"
6313        )
6314    }
6315
6316    pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
6317        self.quest_log
6318            .iter()
6319            .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
6320            .or_else(|| {
6321                self.quest_log
6322                    .iter()
6323                    .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
6324            })
6325    }
6326
6327    /// True when a lockable exterior door is within interact range (prefer over loadout `l`).
6328    pub fn nearby_lockable_door(&self) -> bool {
6329        let (px, py) = self.player_position();
6330        self.doors
6331            .iter()
6332            .any(|d| d.lock_id.is_some() && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M)
6333    }
6334
6335    /// True when an open player-house door is in range for Enter-to-go-inside.
6336    pub fn nearby_open_player_door(&self) -> bool {
6337        if self.effective_inside_building().is_some() {
6338            return false;
6339        }
6340        let (px, py) = self.player_position();
6341        self.doors.iter().any(|d| {
6342            if !d.open || d.locked {
6343                return false;
6344            }
6345            let player_house = self
6346                .buildings
6347                .iter()
6348                .find(|b| b.id == d.building_id)
6349                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6350            player_house && (d.x - px).hypot(d.y - py) <= DOOR_INTERACTION_RADIUS_M
6351        })
6352    }
6353
6354    /// True when inside a player house near the exterior portal (Enter exits freely).
6355    pub fn nearby_player_exit_door(&self) -> bool {
6356        let Some(bid) = self.effective_inside_building() else {
6357            return false;
6358        };
6359        let (px, py) = self.player_position();
6360        self.doors.iter().any(|d| {
6361            if d.building_id != bid || d.portal.is_none() {
6362                return false;
6363            }
6364            let player_house = self
6365                .buildings
6366                .iter()
6367                .find(|b| b.id == d.building_id)
6368                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
6369            player_house && (d.x - px).hypot(d.y - py) <= 1.5
6370        })
6371    }
6372
6373    /// Nearest interactable target for `f` (doors, NPCs, quest boards).
6374    pub fn nearest_interact_target(&self) -> Option<String> {
6375        let (px, py) = self.player_position();
6376        let inside = self.effective_inside_building();
6377
6378        #[derive(Clone, Copy, PartialEq, Eq)]
6379        enum Kind {
6380            Player,
6381            Npc,
6382            HiredWorker,
6383            QuestBoard,
6384            ExitDoor,
6385            EnterDoor,
6386        }
6387
6388        fn kind_class(kind: Kind) -> u8 {
6389            match kind {
6390                Kind::EnterDoor => 0,
6391                Kind::QuestBoard => 1,
6392                Kind::Player | Kind::Npc => 2,
6393                Kind::ExitDoor => 3,
6394                Kind::HiredWorker => 4,
6395            }
6396        }
6397
6398        fn kind_priority(kind: Kind) -> u8 {
6399            match kind {
6400                Kind::EnterDoor => 0,
6401                Kind::QuestBoard => 1,
6402                Kind::Player | Kind::Npc => 2,
6403                Kind::ExitDoor => 3,
6404                Kind::HiredWorker => 4,
6405            }
6406        }
6407
6408        let mut best: Option<(f32, Kind, String)> = None;
6409
6410        let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
6411            if dist > max {
6412                return;
6413            }
6414            let replace = match best {
6415                None => true,
6416                Some((_, bk, _)) if kind_class(kind) < kind_class(bk) => true,
6417                Some((bd, bk, _))
6418                    if kind_class(kind) == kind_class(bk) && dist < bd - 0.05 =>
6419                {
6420                    true
6421                }
6422                Some((bd, bk, _))
6423                    if kind_class(kind) == kind_class(bk) && (dist - bd).abs() <= 0.05 =>
6424                {
6425                    kind_priority(kind) < kind_priority(bk)
6426                }
6427                _ => false,
6428            };
6429            if replace {
6430                best = Some((dist, kind, id));
6431            }
6432        };
6433
6434        for npc in &self.npcs {
6435            consider(
6436                distance(px, py, npc.x, npc.y),
6437                INTERACTION_RADIUS_M,
6438                Kind::Npc,
6439                npc.id.clone(),
6440            );
6441        }
6442
6443        for worker in &self.hired_workers {
6444            consider(
6445                distance(px, py, worker.x, worker.y),
6446                INTERACTION_RADIUS_M,
6447                Kind::HiredWorker,
6448                worker.instance_id.clone(),
6449            );
6450        }
6451
6452        for entity in &self.entities {
6453            if entity.id == self.entity_id
6454                || entity.vitals.is_none()
6455                || entity.label.trim().is_empty()
6456            {
6457                continue;
6458            }
6459            // Hired workers are NPCs with their own UI — not Whisper/Trade peers.
6460            if self.hired_workers.iter().any(|w| w.entity_id == entity.id) {
6461                continue;
6462            }
6463            consider(
6464                distance(
6465                    px,
6466                    py,
6467                    entity.transform.position.x,
6468                    entity.transform.position.y,
6469                ),
6470                INTERACTION_RADIUS_M,
6471                Kind::Player,
6472                entity.id.to_string(),
6473            );
6474        }
6475
6476        for door in &self.doors {
6477            if let Some(ref bid) = inside {
6478                if door.building_id != *bid {
6479                    continue;
6480                }
6481                let is_exit = door.portal.is_some();
6482                let max = if is_exit {
6483                    INTERACTION_RADIUS_M
6484                } else {
6485                    DOOR_INTERACTION_RADIUS_M
6486                };
6487                let kind = if is_exit {
6488                    Kind::ExitDoor
6489                } else {
6490                    Kind::EnterDoor
6491                };
6492                consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
6493                continue;
6494            }
6495            consider(
6496                distance(px, py, door.x, door.y),
6497                DOOR_INTERACTION_RADIUS_M,
6498                Kind::EnterDoor,
6499                door.id.clone(),
6500            );
6501        }
6502
6503        if inside.is_none() {
6504            for inter in &self.interactables {
6505                if inter.kind == "quest_board" {
6506                    consider(
6507                        distance(px, py, inter.x, inter.y),
6508                        QUEST_BOARD_INTERACTION_RADIUS_M,
6509                        Kind::QuestBoard,
6510                        inter.id.clone(),
6511                    );
6512                }
6513            }
6514        }
6515
6516        best.map(|(_, _, id)| id)
6517    }
6518
6519    /// Nearest quest board and distance (any distance), for out-of-range feedback.
6520    pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
6521        if self.effective_inside_building().is_some() {
6522            return None;
6523        }
6524        let (px, py) = self.player_position();
6525        self.interactables
6526            .iter()
6527            .filter(|i| i.kind == "quest_board")
6528            .map(|i| {
6529                let label = if i.label.is_empty() {
6530                    "Quest board".to_string()
6531                } else {
6532                    i.label.clone()
6533                };
6534                (label, distance(px, py, i.x, i.y))
6535            })
6536            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
6537    }
6538
6539    /// Human-readable template name from inventory hints, then the synced item catalog.
6540    pub fn template_display_name(&self, template_id: &str) -> String {
6541        if let Some(name) = self
6542            .inventory_hints
6543            .get(template_id)
6544            .map(|h| h.display_name.clone())
6545            .filter(|n| !n.is_empty())
6546        {
6547            return name;
6548        }
6549        if let Some(entry) = self.item_catalog.get(template_id) {
6550            if !entry.display_name.trim().is_empty() {
6551                return entry.display_name.clone();
6552            }
6553        }
6554        humanize_template_id(template_id)
6555    }
6556
6557    pub fn catalog_entry(&self, template_id: &str) -> Option<&ItemCatalogEntryView> {
6558        self.item_catalog.get(template_id)
6559    }
6560
6561    /// Prefer blueprint-view display name, then inventory hints / humanized id.
6562    pub fn blueprint_item_label(&self, template_id: &str, display_name: &str) -> String {
6563        if !display_name.is_empty() {
6564            display_name.to_string()
6565        } else {
6566            self.template_display_name(template_id)
6567        }
6568    }
6569
6570    pub fn blueprint_output_label(&self, blueprint: &BlueprintView) -> String {
6571        self.blueprint_item_label(&blueprint.output, &blueprint.output_display_name)
6572    }
6573
6574    pub fn blueprint_ingredient_label(
6575        &self,
6576        input: &flatland_protocol::BlueprintIngredientView,
6577    ) -> String {
6578        self.blueprint_item_label(&input.template_id, &input.display_name)
6579    }
6580
6581    pub fn blueprint_tool_label(&self, tool: &flatland_protocol::ToolRequirementView) -> String {
6582        self.blueprint_item_label(&tool.item, &tool.display_name)
6583    }
6584
6585    /// Harvest picker rows for the worker route editor — distance/sort from rest bed, not player.
6586    pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
6587        use crate::worker_route_editor::{
6588            node_candidates, node_candidates_stable, route_editor_lodging_anchor,
6589        };
6590        let nodes = if self.harvest_route_nodes.is_empty() {
6591            &self.resource_nodes
6592        } else {
6593            &self.harvest_route_nodes
6594        };
6595        let lodging = self
6596            .worker_route_editor
6597            .as_ref()
6598            .and_then(|ed| ed.lodging_container_id.as_deref());
6599        match route_editor_lodging_anchor(lodging, &self.placed_containers) {
6600            Some((ax, ay)) => node_candidates(nodes, ax, ay),
6601            None => node_candidates_stable(nodes),
6602        }
6603    }
6604
6605    pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
6606        if dist_m.is_nan() {
6607            return "—".into();
6608        }
6609        let from_bed = self
6610            .worker_route_editor
6611            .as_ref()
6612            .and_then(|ed| ed.lodging_container_id.as_deref())
6613            .and_then(|id| {
6614                self.placed_containers
6615                    .iter()
6616                    .find(|c| c.id == id)
6617                    .map(|c| c.display_name.clone())
6618            });
6619        match from_bed {
6620            Some(bed) => format!("{dist_m:.0}m from {bed}"),
6621            None => format!("{dist_m:.0}m"),
6622        }
6623    }
6624
6625    /// Chest label for the location panel — generic type unless this player owns it.
6626    pub fn placed_container_public_label(
6627        &self,
6628        c: &flatland_protocol::PlacedContainerView,
6629    ) -> String {
6630        let is_owner = match (self.character_id, c.owner_character_id) {
6631            (Some(me), Some(owner)) => me == owner,
6632            _ => false,
6633        };
6634        if is_owner {
6635            c.display_name.clone()
6636        } else {
6637            self.template_display_name(&c.template_id)
6638        }
6639    }
6640
6641    /// Keys on person and stowed on the keychain for the keychain overlay.
6642    pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
6643        let mut out = Vec::new();
6644        for stack in &self.inventory_stacks {
6645            if stack.template_id == KEY_TEMPLATE {
6646                out.push(KeychainEntry {
6647                    stack: stack.clone(),
6648                    stowed: false,
6649                });
6650            }
6651        }
6652        for stack in &self.keychain_stacks {
6653            if stack.template_id == KEY_TEMPLATE {
6654                out.push(KeychainEntry {
6655                    stack: stack.clone(),
6656                    stowed: true,
6657                });
6658            }
6659        }
6660        out
6661    }
6662
6663    /// Display name of the chest a `container_key` opens, when known on the client.
6664    pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
6665        if stack.template_id != KEY_TEMPLATE {
6666            return None;
6667        }
6668        if let Some(name) = stack
6669            .props
6670            .get(PROP_OPENS_CONTAINER_NAME)
6671            .filter(|n| !n.is_empty())
6672        {
6673            return Some(name.clone());
6674        }
6675        let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
6676        self.container_name_for_lock_id(opens)
6677    }
6678
6679    /// Keys always show the catalog name — never a chest rename or stray `custom_name`.
6680    pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
6681        if stack.template_id == KEY_TEMPLATE {
6682            self.template_display_name(KEY_TEMPLATE)
6683        } else {
6684            stack
6685                .display_name
6686                .clone()
6687                .unwrap_or_else(|| stack.template_id.clone())
6688        }
6689    }
6690
6691    /// Hint suffix for a key row (`[key for …]` or `[key — unpaired]`).
6692    pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
6693        if stack.template_id != KEY_TEMPLATE {
6694            return String::new();
6695        }
6696        match self.key_pair_chest_label(stack) {
6697            Some(chest) if self.key_drop_blocked(stack) => {
6698                format!(" [key for {chest} — can't drop while locked]")
6699            }
6700            Some(chest) => format!(" [key for {chest}]"),
6701            None => " [key — unpaired]".into(),
6702        }
6703    }
6704
6705    /// Resolve a lock id to a container label (placed chest or one still on your person).
6706    pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
6707        for c in &self.placed_containers {
6708            if c.lock_id.as_deref() == Some(lock) {
6709                return Some(c.display_name.clone());
6710            }
6711        }
6712        Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
6713            self.worn
6714                .values()
6715                .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
6716        })
6717    }
6718
6719    /// Keys cannot be dropped while their paired chest is locked.
6720    pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
6721        if stack.template_id != KEY_TEMPLATE {
6722            return false;
6723        }
6724        let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
6725            return false;
6726        };
6727        for c in &self.placed_containers {
6728            if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
6729                return true;
6730            }
6731        }
6732        if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
6733            return true;
6734        }
6735        self.worn
6736            .values()
6737            .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
6738    }
6739
6740    /// Property deeds cannot be dropped or destroyed (store or trade only).
6741    pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
6742        stack.template_id == PROPERTY_DEED_TEMPLATE
6743    }
6744
6745    pub fn is_property_deed_template(template_id: &str) -> bool {
6746        template_id == PROPERTY_DEED_TEMPLATE
6747    }
6748
6749    pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
6750        stack
6751            .props
6752            .get("plot_id")
6753            .and_then(|s| uuid::Uuid::parse_str(s).ok())
6754    }
6755
6756    /// World position (cell center) of the plot cell the player is standing on, if tillable.
6757    pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
6758        let (px, py) = self.player_position();
6759        let (cx, cy) = self.farm_plot_cell_under_player()?;
6760        let tx = cx as f32 + 0.5;
6761        let ty = cy as f32 + 0.5;
6762        if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
6763            return None;
6764        }
6765        let kind = self.terrain_at(tx, ty).or_else(|| self.terrain_at(px, py));
6766        if kind == Some(TerrainKindView::Tilled) {
6767            return None;
6768        }
6769        if matches!(
6770            kind,
6771            Some(TerrainKindView::ShallowWater)
6772                | Some(TerrainKindView::DeepWater)
6773                | Some(TerrainKindView::Rock)
6774        ) {
6775            return None;
6776        }
6777        Some((tx, ty))
6778    }
6779
6780    fn container_name_in_stacks(
6781        stacks: &[flatland_protocol::ItemStack],
6782        lock: &str,
6783    ) -> Option<String> {
6784        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
6785            for s in stacks {
6786                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
6787                    return Some(GameState::stack_container_label(s));
6788                }
6789                if let Some(name) = walk(&s.contents, lock) {
6790                    return Some(name);
6791                }
6792            }
6793            None
6794        }
6795        walk(stacks, lock)
6796    }
6797
6798    fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
6799        stack
6800            .props
6801            .get(PROP_CUSTOM_NAME)
6802            .cloned()
6803            .or_else(|| stack.display_name.clone())
6804            .unwrap_or_else(|| stack.template_id.clone())
6805    }
6806
6807    fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6808        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
6809            for s in stacks {
6810                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
6811                    return true;
6812                }
6813                if walk(&s.contents, lock) {
6814                    return true;
6815                }
6816            }
6817            false
6818        }
6819        walk(stacks, lock)
6820    }
6821
6822    fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
6823        if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
6824            return Some(stack.clone());
6825        }
6826        for worn in self.worn.values() {
6827            if worn.item_instance_id == Some(instance_id) {
6828                return Some(worn.clone());
6829            }
6830            if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
6831                return Some(stack.clone());
6832            }
6833        }
6834        None
6835    }
6836
6837    /// Highest-`z_order` property zone covering `(x, y)`.
6838    pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
6839        self.property_zones
6840            .iter()
6841            .enumerate()
6842            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
6843            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
6844            .map(|(_, z)| z)
6845    }
6846
6847    /// Highest-`z_order` tax zone covering `(x, y)`.
6848    pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
6849        self.tax_zones
6850            .iter()
6851            .enumerate()
6852            .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
6853            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
6854            .map(|(_, z)| z)
6855    }
6856
6857    /// Max tax `rate_bps` across 1 m cell centers of the claim rect (matches server).
6858    pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
6859        let mut max_bps = 0u32;
6860        let mut y = y0 + 0.5;
6861        while y < y1 {
6862            let mut x = x0 + 0.5;
6863            while x < x1 {
6864                if let Some(tz) = self.tax_zone_at(x, y) {
6865                    max_bps = max_bps.max(tz.rate_bps);
6866                }
6867                x += 1.0;
6868            }
6869            y += 1.0;
6870        }
6871        max_bps
6872    }
6873
6874    /// Claim footprint as half-open world AABB `(x0, y0, x1, y1)`.
6875    pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
6876        let mode = self.claim_mode.as_ref()?;
6877        let w = mode.width_m.max(1) as f32;
6878        let h = mode.height_m.max(1) as f32;
6879        Some((
6880            mode.anchor_x,
6881            mode.anchor_y,
6882            mode.anchor_x + w,
6883            mode.anchor_y + h,
6884        ))
6885    }
6886
6887    /// Relocate ghost as half-open 1×1 world AABB `(x0, y0, x1, y1)`.
6888    pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
6889        let mode = self.relocate_mode.as_ref()?;
6890        let x0 = mode.cursor_x.floor();
6891        let y0 = mode.cursor_y.floor();
6892        Some((x0, y0, x0 + 1.0, y0 + 1.0))
6893    }
6894
6895    /// Client quote matching server `quote_plot`:
6896    /// `(purchase, upkeep, area, premium, can_afford, valid, reason)`.
6897    pub fn claim_quote(&self) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
6898        let mode = self.claim_mode.as_ref()?;
6899        let zone = self.property_zones.iter().find(|z| z.id == mode.zone_id)?;
6900        let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
6901        let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
6902        let zone_area = zone_view_area_m2(zone).max(1.0);
6903        let area_frac = (area / zone_area).clamp(0.0, 1.0);
6904        let weight = self
6905            .property_plot_settings
6906            .as_ref()
6907            .map(|s| s.tax_premium_weight)
6908            .unwrap_or(0.5)
6909            .max(0.0);
6910        let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
6911        let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
6912        let purchase = ((zone.crown_price_copper as f64) * (area_frac as f64) * (premium as f64))
6913            .ceil()
6914            .max(0.0) as u64;
6915        let upkeep = if zone.upkeep_copper_per_day == 0 {
6916            0
6917        } else {
6918            ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
6919                .ceil()
6920                .max(1.0) as u64
6921        };
6922        let copper = crate::currency::copper_from_counts(&self.inventory);
6923        let can_afford = copper >= purchase;
6924        let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
6925        Some((purchase, upkeep, area, premium, can_afford, valid, reason))
6926    }
6927
6928    fn validate_claim_footprint(
6929        &self,
6930        zone: &flatland_protocol::PropertyZoneView,
6931        x0: f32,
6932        y0: f32,
6933        x1: f32,
6934        y1: f32,
6935        area: f32,
6936    ) -> (bool, String) {
6937        let min_area = self
6938            .property_plot_settings
6939            .as_ref()
6940            .map(|s| s.min_plot_area_m2)
6941            .unwrap_or(4.0);
6942        if area + f32::EPSILON < min_area {
6943            return (false, "plot too small".into());
6944        }
6945        if zone.max_area_m2.is_some_and(|m| area > m) {
6946            return (false, "plot exceeds max area".into());
6947        }
6948        if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
6949            return (false, "plot must lie inside the property zone".into());
6950        }
6951        if self
6952            .property_plots
6953            .iter()
6954            .any(|p| rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1))
6955        {
6956            return (false, "plot overlaps an existing claim".into());
6957        }
6958        (true, String::new())
6959    }
6960
6961    /// Standing in a property zone on a free cell (not already claimed).
6962    pub fn free_property_zone_under_player(&self) -> Option<&flatland_protocol::PropertyZoneView> {
6963        let (px, py) = self.player_position();
6964        let zone = self.property_zone_at(px, py)?;
6965        if self.property_plots.iter().any(|p| point_in_plot(px, py, p)) {
6966            return None;
6967        }
6968        Some(zone)
6969    }
6970
6971    /// Own plot covering the player (`is_mine`).
6972    pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
6973        let (px, py) = self.player_position();
6974        self.property_plots
6975            .iter()
6976            .find(|p| p.is_mine && point_in_plot(px, py, p))
6977    }
6978
6979    /// Plot under the player that they may farm (deed, grant, or public).
6980    pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
6981        let (px, py) = self.player_position();
6982        self.property_plots
6983            .iter()
6984            .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
6985    }
6986
6987    /// Grid cell under the player on a farmable plot (`[cx,cx+1)` × `[cy,cy+1)` contains feet).
6988    pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
6989        if self.farmable_plot_under_player().is_none() {
6990            return None;
6991        }
6992        let (px, py) = self.player_position();
6993        Some((px.floor() as i32, py.floor() as i32))
6994    }
6995
6996    fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
6997        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
6998        self.resource_nodes.iter().any(|n| {
6999            let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
7000            ncx == cx && ncy == cy || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
7001        })
7002    }
7003
7004    fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
7005        let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
7006        let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
7007            || self
7008                .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
7009                .is_some_and(|z| z.kind == TerrainKindView::Tilled);
7010        if !tilled {
7011            return false;
7012        }
7013        !self.resource_node_occupies_farm_cell(cx, cy)
7014    }
7015
7016    /// Empty tilled soil on the cell the player is standing on (no crop node).
7017    pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
7018        let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
7019            return false;
7020        };
7021        self.free_tilled_plant_slot_at(cx, cy)
7022    }
7023
7024    /// True when a free tilled cell (no resource node) is within interact range.
7025    pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
7026        let (px, py) = self.player_position();
7027        for dy in -2..=2 {
7028            for dx in -2..=2 {
7029                let cx = px.floor() as i32 + dx;
7030                let cy = py.floor() as i32 + dy;
7031                let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
7032                if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
7033                    continue;
7034                }
7035                if self.free_tilled_plant_slot_at(cx, cy) {
7036                    return true;
7037                }
7038            }
7039        }
7040        false
7041    }
7042
7043    fn stack_is_farm_seed(&self, stack: &flatland_protocol::ItemStack) -> bool {
7044        if stack.quantity == 0 {
7045            return false;
7046        }
7047        if stack.props.contains_key("seed_for") {
7048            return true;
7049        }
7050        if let Some(entry) = self.item_catalog.get(&stack.template_id) {
7051            if entry.is_farm_seed() {
7052                return true;
7053            }
7054        }
7055        stack.template_id.ends_with("_seed")
7056    }
7057
7058    /// Farm seed stacks in inventory (template_id, qty, display label), sorted by label.
7059    pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
7060        let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
7061        fn walk(
7062            stacks: &[flatland_protocol::ItemStack],
7063            state: &GameState,
7064            counts: &mut std::collections::HashMap<String, u32>,
7065        ) {
7066            for s in stacks {
7067                if state.stack_is_farm_seed(s) {
7068                    *counts.entry(s.template_id.clone()).or_default() += s.quantity;
7069                }
7070                walk(&s.contents, state, counts);
7071            }
7072        }
7073        walk(&self.inventory_stacks, self, &mut counts);
7074        for worn in self.worn.values() {
7075            walk(std::slice::from_ref(worn), self, &mut counts);
7076        }
7077        let mut out: Vec<_> = counts
7078            .into_iter()
7079            .map(|(template_id, quantity)| {
7080                let label = self.template_display_name(&template_id);
7081                (template_id, quantity, label)
7082            })
7083            .collect();
7084        out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
7085        out
7086    }
7087
7088    /// First farm seed template in inventory (root + nested bags).
7089    pub fn first_farm_seed_template(&self) -> Option<String> {
7090        self.farm_seed_entries()
7091            .into_iter()
7092            .next()
7093            .map(|(id, _, _)| id)
7094    }
7095
7096    pub fn clamp_plant_menu(&mut self) {
7097        let n = self.farm_seed_entries().len();
7098        if n == 0 {
7099            self.plant_menu_index = 0;
7100            self.plant_quantity = 1;
7101            return;
7102        }
7103        self.plant_menu_index = self.plant_menu_index.min(n - 1);
7104        let max_qty = self
7105            .farm_seed_entries()
7106            .get(self.plant_menu_index)
7107            .map(|(_, q, _)| *q)
7108            .unwrap_or(1)
7109            .max(1);
7110        self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
7111    }
7112
7113    pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
7114        let entries = self.farm_seed_entries();
7115        let (id, max, label) = entries.get(self.plant_menu_index)?;
7116        let qty = self.plant_quantity.min(*max).max(1);
7117        Some((id.clone(), qty, label.clone()))
7118    }
7119
7120    /// Terrain, interactables, and map objects near the player for the HUD location panel.
7121    pub fn location_context_lines(&self) -> Vec<ContextLine> {
7122        let (px, py) = self.player_position();
7123        let inside = self.effective_inside_building();
7124        let mut lines = Vec::new();
7125
7126        if let Some(kind) = self.terrain_at(px, py) {
7127            lines.push(ContextLine {
7128                on_top: true,
7129                text: format!("Terrain: {}", terrain_kind_label(kind)),
7130            });
7131        }
7132
7133        if let Some(id) = inside.as_ref() {
7134            if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
7135                lines.push(ContextLine {
7136                    on_top: true,
7137                    text: format!("Inside: {}", b.label),
7138                });
7139            }
7140        }
7141
7142        let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
7143
7144        for node in &self.resource_nodes {
7145            if node.id.starts_with("preview:") {
7146                continue;
7147            }
7148            let dist = distance(px, py, node.x, node.y);
7149            if dist > NEARBY_SCAN_M {
7150                continue;
7151            }
7152            let on_top = dist <= ON_TOP_RADIUS_M;
7153            let prefix = if on_top { "On" } else { "Near" };
7154            let name = resource_node_near_display_label(&node.label);
7155            let action = resource_node_near_action_suffix(node);
7156            nearby.push((
7157                dist,
7158                ContextLine {
7159                    on_top,
7160                    text: format!("{prefix}: {name} ({dist:.1}m){action}"),
7161                },
7162            ));
7163        }
7164
7165        for drop in &self.ground_drops {
7166            let dist = distance(px, py, drop.x, drop.y);
7167            if dist > INTERACTION_RADIUS_M {
7168                continue;
7169            }
7170            let on_top = dist <= ON_TOP_RADIUS_M;
7171            let name = self.template_display_name(&drop.template_id);
7172            let prefix = if on_top { "On" } else { "Near" };
7173            let qty = if drop.quantity > 1 {
7174                format!(" ×{}", drop.quantity)
7175            } else {
7176                String::new()
7177            };
7178            nearby.push((
7179                dist,
7180                ContextLine {
7181                    on_top,
7182                    text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
7183                },
7184            ));
7185        }
7186
7187        for c in &self.placed_containers {
7188            if !self.placed_container_in_current_space(c) {
7189                continue;
7190            }
7191            let dist = distance(px, py, c.x, c.y);
7192            if dist > CONTAINER_RANGE_M {
7193                continue;
7194            }
7195            let on_top = dist <= ON_TOP_RADIUS_M;
7196            let name = self.placed_container_public_label(c);
7197            let lock = if c.locked { " [locked]" } else { "" };
7198            let prefix = if on_top { "On" } else { "Near" };
7199            nearby.push((
7200                dist,
7201                ContextLine {
7202                    on_top,
7203                    text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
7204                },
7205            ));
7206        }
7207
7208        for npc in &self.npcs {
7209            let dist = distance(px, py, npc.x, npc.y);
7210            if dist > NEARBY_SCAN_M {
7211                continue;
7212            }
7213            let on_top = dist <= ON_TOP_RADIUS_M;
7214            let prefix = if on_top { "On" } else { "Near" };
7215            nearby.push((
7216                dist,
7217                ContextLine {
7218                    on_top,
7219                    text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
7220                },
7221            ));
7222        }
7223
7224        for door in &self.doors {
7225            let dist = distance(px, py, door.x, door.y);
7226            if dist > DOOR_INTERACTION_RADIUS_M {
7227                continue;
7228            }
7229            let building = self
7230                .buildings
7231                .iter()
7232                .find(|b| b.id == door.building_id)
7233                .map(|b| b.label.as_str())
7234                .unwrap_or(door.building_id.as_str());
7235            let player_house = self
7236                .buildings
7237                .iter()
7238                .find(|b| b.id == door.building_id)
7239                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
7240            let action = if inside.is_some() && door.portal.is_some() {
7241                if player_house {
7242                    if door.locked {
7243                        "locked — l unlock · Enter exit".to_string()
7244                    } else if door.open {
7245                        "close · Enter exit · l lock".to_string()
7246                    } else {
7247                        "open · Enter exit · l lock".to_string()
7248                    }
7249                } else {
7250                    "exit".to_string()
7251                }
7252            } else if player_house {
7253                if door.locked {
7254                    "locked — l unlock".to_string()
7255                } else if door.open {
7256                    "close · Enter go inside · l lock".to_string()
7257                } else {
7258                    "open · l lock".to_string()
7259                }
7260            } else {
7261                "enter".to_string()
7262            };
7263            nearby.push((
7264                dist,
7265                ContextLine {
7266                    on_top: dist <= ON_TOP_RADIUS_M,
7267                    text: format!("{building} door ({dist:.1}m) — f {action}"),
7268                },
7269            ));
7270        }
7271
7272        if inside.is_none() {
7273            for inter in &self.interactables {
7274                if inter.kind != "quest_board" {
7275                    continue;
7276                }
7277                let dist = distance(px, py, inter.x, inter.y);
7278                if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
7279                    continue;
7280                }
7281                let on_top = dist <= ON_TOP_RADIUS_M;
7282                let prefix = if on_top { "On" } else { "Near" };
7283                let label = if inter.label.is_empty() {
7284                    "Quest board".to_string()
7285                } else {
7286                    inter.label.clone()
7287                };
7288                nearby.push((
7289                    dist,
7290                    ContextLine {
7291                        on_top,
7292                        text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
7293                    },
7294                ));
7295            }
7296        }
7297
7298        if self.near_liquid_fill_source() {
7299            let on_water = matches!(
7300                self.terrain_at(px, py),
7301                Some(TerrainKindView::ShallowWater | TerrainKindView::DeepWater)
7302            );
7303            let well = self.buildings.iter().find(|b| {
7304                b.tags.iter().any(|t| t == "well") && {
7305                    let hw = b.width_m * 0.5;
7306                    let hd = b.depth_m * 0.5;
7307                    let nx = px.clamp(b.x - hw, b.x + hw);
7308                    let ny = py.clamp(b.y - hd, b.y + hd);
7309                    let dx = px - nx;
7310                    let dy = py - ny;
7311                    dx * dx + dy * dy <= INTERACTION_RADIUS_M * INTERACTION_RADIUS_M
7312                }
7313            });
7314            if let Some(well) = well {
7315                let name = if well.label.trim().is_empty() {
7316                    "Well"
7317                } else {
7318                    well.label.as_str()
7319                };
7320                nearby.push((
7321                    0.0,
7322                    ContextLine {
7323                        on_top: true,
7324                        text: format!("{name} — Use a vessel from inventory to fill"),
7325                    },
7326                ));
7327            } else if on_water {
7328                if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
7329                    line.text
7330                        .push_str(" — Use a vessel from inventory to fill");
7331                }
7332            } else {
7333                nearby.push((
7334                    0.0,
7335                    ContextLine {
7336                        on_top: true,
7337                        text: "Water nearby — Use a vessel from inventory to fill".into(),
7338                    },
7339                ));
7340            }
7341        }
7342
7343        if self.claim_mode.is_some() {
7344            nearby.push((
7345                0.0,
7346                ContextLine {
7347                    on_top: true,
7348                    text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
7349                        .into(),
7350                },
7351            ));
7352        } else if let Some(plot) = self.my_plot_under_player() {
7353            let name = plot_public_label(plot);
7354            let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
7355                format!("{name} — f again to sell to crown")
7356            } else {
7357                format!(
7358                    "{name} — Shift+c till · p plant · f harvest · B build · l door lock · o farm access · Shift+n rename"
7359                )
7360            };
7361            nearby.push((
7362                0.0,
7363                ContextLine {
7364                    on_top: true,
7365                    text: prompt,
7366                },
7367            ));
7368        } else if let Some(plot) = self.farmable_plot_under_player() {
7369            let name = plot_public_label(plot);
7370            let disc = if plot.farm_public {
7371                plot.public_tax_discount_bps / 100
7372            } else {
7373                plot.farm_allow
7374                    .iter()
7375                    .find(|g| Some(g.character_id) == self.character_id)
7376                    .map(|g| g.tax_discount_bps / 100)
7377                    .unwrap_or(0)
7378            };
7379            nearby.push((
7380                0.0,
7381                ContextLine {
7382                    on_top: true,
7383                    text: format!(
7384                        "{name} (farming · tax −{disc}%) — Shift+c till · p plant · f harvest"
7385                    ),
7386                },
7387            ));
7388        } else if let Some(zone) = self.free_property_zone_under_player() {
7389            let label = zone
7390                .label
7391                .as_deref()
7392                .filter(|s| !s.trim().is_empty())
7393                .unwrap_or(zone.id.as_str());
7394            nearby.push((
7395                0.0,
7396                ContextLine {
7397                    on_top: true,
7398                    text: format!("Claimable land: {label} — k buy plot"),
7399                },
7400            ));
7401        }
7402
7403        for entity in &self.entities {
7404            if entity.id == self.entity_id {
7405                continue;
7406            }
7407            let dist = distance(
7408                px,
7409                py,
7410                entity.transform.position.x,
7411                entity.transform.position.y,
7412            );
7413            if dist > NEARBY_SCAN_M {
7414                continue;
7415            }
7416            let label = if entity.label.is_empty() {
7417                format!("entity {}", entity.id)
7418            } else {
7419                entity.label.clone()
7420            };
7421            nearby.push((
7422                dist,
7423                ContextLine {
7424                    on_top: dist <= ON_TOP_RADIUS_M,
7425                    text: format!("Near: {label} ({dist:.1}m)"),
7426                },
7427            ));
7428        }
7429
7430        nearby.sort_by(|a, b| {
7431            a.0.partial_cmp(&b.0)
7432                .unwrap_or(std::cmp::Ordering::Equal)
7433                .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
7434        });
7435        lines.extend(nearby.into_iter().map(|(_, l)| l));
7436
7437        if lines.is_empty() {
7438            lines.push(ContextLine {
7439                on_top: false,
7440                text: "(nothing notable nearby)".into(),
7441            });
7442        }
7443
7444        lines
7445    }
7446}
7447
7448/// HUD line for the location / nearby panel.
7449#[derive(Debug, Clone)]
7450pub struct ContextLine {
7451    pub on_top: bool,
7452    pub text: String,
7453}
7454
7455const ON_TOP_RADIUS_M: f32 = 0.65;
7456const NEARBY_SCAN_M: f32 = 5.0;
7457
7458/// Display name for a resource node in location/nearby HUD (strips redundant growing tag).
7459pub fn resource_node_near_display_label(label: &str) -> String {
7460    label
7461        .strip_suffix(" (growing)")
7462        .unwrap_or(label)
7463        .to_string()
7464}
7465
7466fn resource_label_looks_like_raw_id(label: &str, id: &str) -> bool {
7467    let t = label.trim();
7468    if t.is_empty() || t == id {
7469        return true;
7470    }
7471    let lower = t.to_ascii_lowercase();
7472    if lower.contains("_copy") {
7473        return true;
7474    }
7475    false
7476}
7477
7478fn humanize_item_template_label(template: &str) -> String {
7479    let base = template.rsplit('/').next().unwrap_or(template).trim();
7480    if base.is_empty() {
7481        return "Resource".into();
7482    }
7483    let stripped = base
7484        .strip_prefix("crop-")
7485        .or_else(|| base.strip_prefix("crop_"))
7486        .unwrap_or(base);
7487    stripped
7488        .split(|c: char| c == '-' || c == '_')
7489        .filter(|p| !p.is_empty())
7490        .map(|p| {
7491            let mut chars = p.chars();
7492            match chars.next() {
7493                Some(c) => format!("{}{}", c.to_ascii_uppercase(), chars.as_str()),
7494                None => String::new(),
7495            }
7496        })
7497        .collect::<Vec<_>>()
7498        .join(" ")
7499}
7500
7501/// Last 4 alphanumeric chars of an id (for disambiguation in route UI).
7502pub fn resource_node_id_suffix(id: &str) -> String {
7503    let chars: Vec<char> = id
7504        .chars()
7505        .rev()
7506        .filter(|c| c.is_ascii_alphanumeric())
7507        .take(4)
7508        .collect();
7509    chars.into_iter().rev().collect()
7510}
7511
7512/// Friendly harvest/route label: prefer human label, else template; always append `(xxxx)`.
7513pub fn resource_node_route_label(node: &flatland_protocol::ResourceNodeView) -> String {
7514    resource_node_route_label_parts(&node.id, &node.label, &node.item_template)
7515}
7516
7517pub fn resource_node_route_label_parts(id: &str, label: &str, item_template: &str) -> String {
7518    let cleaned = resource_node_near_display_label(label);
7519    let friendly = if !resource_label_looks_like_raw_id(&cleaned, id) {
7520        cleaned
7521    } else if !item_template.trim().is_empty() {
7522        humanize_item_template_label(item_template)
7523    } else {
7524        id.to_string()
7525    };
7526    let suffix = resource_node_id_suffix(id);
7527    if suffix.is_empty() {
7528        friendly
7529    } else {
7530        format!("{friendly} ({suffix})")
7531    }
7532}
7533
7534/// Action / state suffix for a resource node line (`growth_progress` wins for farm crops).
7535pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
7536    use flatland_protocol::ResourceNodeState;
7537    if node.harvest_off {
7538        return " (decorative)".to_string();
7539    }
7540    if let Some(p) = node.growth_progress {
7541        if p < 1.0 - f32::EPSILON {
7542            let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
7543            return format!(" (growing, {pct}%)");
7544        }
7545        return " — f harvest".to_string();
7546    }
7547    match node.state {
7548        ResourceNodeState::Available => " — f harvest".to_string(),
7549        ResourceNodeState::Harvesting => " (being harvested)".to_string(),
7550        ResourceNodeState::Cooldown => " (depleted)".to_string(),
7551    }
7552}
7553
7554fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
7555    use flatland_protocol::TerrainKindView;
7556    match kind {
7557        TerrainKindView::Grass => "Grass",
7558        TerrainKindView::Dirt => "Dirt",
7559        TerrainKindView::Tilled => "Tilled",
7560        TerrainKindView::Desert => "Desert",
7561        TerrainKindView::Hill => "Hills",
7562        TerrainKindView::Bog => "Bog",
7563        TerrainKindView::Beach => "Beach",
7564        TerrainKindView::ShallowWater => "Shallow water",
7565        TerrainKindView::DeepWater => "Deep water",
7566        TerrainKindView::Trail => "Trail",
7567        TerrainKindView::Road => "Road",
7568        TerrainKindView::Rock => "Rock",
7569    }
7570}
7571
7572fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
7573    crate::world_zones::zone_rects_contain(rects, x, y)
7574}
7575
7576fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
7577    zone.rects
7578        .iter()
7579        .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
7580        .sum()
7581}
7582
7583fn claim_rect_fully_inside_zone(
7584    zone: &flatland_protocol::PropertyZoneView,
7585    x0: f32,
7586    y0: f32,
7587    x1: f32,
7588    y1: f32,
7589) -> bool {
7590    let mut y = y0 + 0.5;
7591    while y < y1 {
7592        let mut x = x0 + 0.5;
7593        while x < x1 {
7594            if !zone_rects_contain(&zone.rects, x, y) {
7595                return false;
7596            }
7597            x += 1.0;
7598        }
7599        y += 1.0;
7600    }
7601    true
7602}
7603
7604fn rects_overlap_half_open(
7605    ax0: f32,
7606    ay0: f32,
7607    ax1: f32,
7608    ay1: f32,
7609    bx0: f32,
7610    by0: f32,
7611    bx1: f32,
7612    by1: f32,
7613) -> bool {
7614    ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
7615}
7616
7617fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
7618    x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
7619}
7620
7621fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
7622    plot_public_label(p)
7623}
7624
7625fn plot_size_fallback_label(p: &flatland_protocol::PropertyPlotView) -> String {
7626    let w = (p.x1 - p.x0).abs();
7627    let d = (p.y1 - p.y0).abs();
7628    format!("Plot ({w:.0}×{d:.0} m)")
7629}
7630
7631/// Canonical plot name: `{owner} — {zone} — {label}`.
7632pub fn plot_public_label(p: &flatland_protocol::PropertyPlotView) -> String {
7633    let zone = p
7634        .zone_label
7635        .as_deref()
7636        .filter(|s| !s.trim().is_empty())
7637        .unwrap_or_else(|| {
7638            if p.property_zone_id.is_empty() {
7639                "Homestead"
7640            } else {
7641                p.property_zone_id.as_str()
7642            }
7643        });
7644    let label = if !p.label.trim().is_empty() {
7645        p.label.clone()
7646    } else if !p.plot_code.trim().is_empty() {
7647        p.plot_code.clone()
7648    } else {
7649        plot_size_fallback_label(p)
7650    };
7651    match p
7652        .owner_label
7653        .as_deref()
7654        .map(str::trim)
7655        .filter(|s| !s.is_empty())
7656    {
7657        Some(owner) => format!("{owner} — {zone} — {label}"),
7658        None => format!("{zone} — {label}"),
7659    }
7660}
7661
7662/// Resolve a farm-plot stop target for player-facing route UI.
7663///
7664/// Uses [`plot_public_label`] when the plot is in the observer's view; otherwise a
7665/// short constructed fallback (never the full UUID).
7666pub fn plot_stop_label(
7667    plots: &[flatland_protocol::PropertyPlotView],
7668    plot_id: uuid::Uuid,
7669) -> String {
7670    plots
7671        .iter()
7672        .find(|p| p.plot_id == plot_id)
7673        .map(plot_public_label)
7674        .unwrap_or_else(|| {
7675            let s = plot_id.to_string();
7676            format!("plot {}", s.get(..8).unwrap_or(s.as_str()))
7677        })
7678}
7679
7680/// Match `flatland_sim::economy_zones::snap_claim_rect`.
7681fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
7682    let a = x0.min(x1).floor();
7683    let b = y0.min(y1).floor();
7684    let mut c = x0.max(x1).ceil();
7685    let mut d = y0.max(y1).ceil();
7686    if (c - a) < 1.0 {
7687        c = a + 1.0;
7688    }
7689    if (d - b) < 1.0 {
7690        d = b + 1.0;
7691    }
7692    (a, b, c, d)
7693}
7694
7695fn humanize_template_id(template_id: &str) -> String {
7696    // UUID-style ids have no friendly words to title-case; avoid dumping them in UI.
7697    if looks_like_template_uuid(template_id) {
7698        return "Unknown item".into();
7699    }
7700    template_id
7701        .split('_')
7702        .map(|word| {
7703            let mut chars = word.chars();
7704            match chars.next() {
7705                None => String::new(),
7706                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
7707            }
7708        })
7709        .collect::<Vec<_>>()
7710        .join(" ")
7711}
7712
7713fn looks_like_template_uuid(template_id: &str) -> bool {
7714    let bytes = template_id.as_bytes();
7715    if bytes.len() != 36 {
7716        return false;
7717    }
7718    let is_hex = |b: u8| b.is_ascii_hexdigit();
7719    let groups = [8usize, 4, 4, 4, 12];
7720    let mut i = 0;
7721    for (gi, &len) in groups.iter().enumerate() {
7722        if gi > 0 {
7723            if bytes.get(i) != Some(&b'-') {
7724                return false;
7725            }
7726            i += 1;
7727        }
7728        for _ in 0..len {
7729            if !bytes.get(i).copied().is_some_and(is_hex) {
7730                return false;
7731            }
7732            i += 1;
7733        }
7734    }
7735    true
7736}
7737
7738/// Keep in sync with `flatland_sim::INTERACTION_RADIUS_M`.
7739const HARVEST_RANGE_M: f32 = 1.5;
7740
7741pub struct GameClient<S: PlayConnection> {
7742    session: S,
7743    seq: Seq,
7744    pub state: GameState,
7745    last_move_forward: f32,
7746    last_move_strafe: f32,
7747}
7748
7749impl<S: PlayConnection> GameClient<S> {
7750    pub fn new(session: S) -> Self {
7751        let session_id = session.session_id();
7752        let entity_id = session.entity_id();
7753        let mut client = Self {
7754            session,
7755            seq: 0,
7756            last_move_forward: 0.0,
7757            last_move_strafe: 0.0,
7758            state: GameState {
7759                session_id,
7760                entity_id,
7761                character_id: None,
7762                tick: 0,
7763                chunk_rev: 0,
7764                content_rev: 0,
7765                publish_rev: 0,
7766                entities: Vec::new(),
7767                player: None,
7768                resource_nodes: Vec::new(),
7769                harvest_route_nodes: Vec::new(),
7770                ground_drops: Vec::new(),
7771                placed_containers: Vec::new(),
7772                buildings: Vec::new(),
7773                doors: Vec::new(),
7774                interior_map: None,
7775                npcs: Vec::new(),
7776                blueprints: Vec::new(),
7777                building_materials: Vec::new(),
7778                world_x0: 0.0,
7779                world_y0: 0.0,
7780                world_width_m: 0.0,
7781                world_height_m: 0.0,
7782                terrain_zones: Vec::new(),
7783                z_platforms: Vec::new(),
7784                z_transitions: Vec::new(),
7785                z_bands_outdoor_backup: None,
7786                world_clock: flatland_protocol::WorldClock::default(),
7787                inventory: std::collections::HashMap::new(),
7788                inventory_hints: std::collections::HashMap::new(),
7789                item_catalog: std::collections::HashMap::new(),
7790                logs: VecDeque::new(),
7791                intents_sent: 0,
7792                ticks_received: 0,
7793                connected: false,
7794                disconnect_reason: None,
7795                show_stats: false,
7796                hud_log_hidden: false,
7797                show_equip_menu: false,
7798                equip_menu_index: 0,
7799                show_craft_menu: false,
7800                show_plot_build_menu: false,
7801                plot_build_focus_wall: true,
7802                plot_build_wall_index: 0,
7803                plot_build_roof_index: 0,
7804                craft_menu_index: 0,
7805                craft_batch_quantity: 1,
7806                craft_tab: CraftTab::Ready,
7807                craft_filter: String::new(),
7808                craft_filter_focused: false,
7809                craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
7810                show_shop_menu: false,
7811                shop_catalog: None,
7812                bank_panel: None,
7813                bank_menu_index: 0,
7814                bank_ui_mode: BankUiMode::Menu,
7815                storage_panel: None,
7816                market_panel: None,
7817                market_menu_index: 0,
7818                market_filter: String::new(),
7819                market_filter_focused: false,
7820                market_category_filter: None,
7821                market_buy_confirm: None,
7822                market_ui_mode: MarketUiMode::Browse,
7823                storage_menu_index: 0,
7824                storage_ui_mode: StorageUiMode::Menu,
7825                shop_tab: ShopTab::default(),
7826                shop_menu_index: 0,
7827                shop_quantity: 1,
7828                shop_trade_log: VecDeque::new(),
7829                show_npc_verb_menu: false,
7830                npc_verb_target: None,
7831                npc_verb_index: 0,
7832                npc_verb_notice: None,
7833                player_verbs: crate::social::PlayerVerbState::default(),
7834                social_chat: crate::social::SocialChatState::default(),
7835                trade_ui: crate::social::TradeUiState::default(),
7836                whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
7837                show_npc_chat: false,
7838                npc_chat: None,
7839                show_inventory_menu: false,
7840                inventory_menu_index: 0,
7841                inventory_tab: InventoryTab::OnPerson,
7842                inventory_filter: String::new(),
7843                inventory_filter_focused: false,
7844                show_move_picker: false,
7845                show_rename_prompt: false,
7846                rename_plot_id: None,
7847                highlighted_plot_id: None,
7848                show_worker_rename: false,
7849                rename_buffer: String::new(),
7850                move_picker_index: 0,
7851                move_picker: None,
7852                show_grant_picker: false,
7853                grant_picker_index: 0,
7854                grant_picker: None,
7855                show_destroy_picker: false,
7856                destroy_confirm_pending: false,
7857                destroy_picker: None,
7858                combat_target: None,
7859                combat_target_label: None,
7860                ground_target: None,
7861                combat_fx: Vec::new(),
7862                ground_hazards: Vec::new(),
7863                property_zones: Vec::new(),
7864                tax_zones: Vec::new(),
7865                growth_zones: Vec::new(),
7866                biome_zones: Vec::new(),
7867                terrain_kind_nav: Vec::new(),
7868                property_plots: Vec::new(),
7869                property_plot_settings: None,
7870                claim_mode: None,
7871                relocate_mode: None,
7872                sell_plot_confirm: None,
7873                sell_plot_armed_at: None,
7874                show_plant_menu: false,
7875                plant_menu_index: 0,
7876                show_farm_access: false,
7877                farm_access_name_draft: String::new(),
7878                farm_access_discount_bps: 0,
7879                farm_access_index: 0,
7880                plant_quantity: 1,
7881                in_combat: false,
7882                auto_attack: true,
7883                combat_has_los: false,
7884                attack_cd_ticks: 0,
7885                gcd_ticks: 0,
7886                weapon_ability_id: "unarmed".into(),
7887                mainhand_template_id: None,
7888                mainhand_label: None,
7889                mainhand_instance_id: None,
7890                offhand_template_id: None,
7891                offhand_label: None,
7892                offhand_instance_id: None,
7893                mainhand_hand_slots: 1,
7894                defense: None,
7895                worn: BTreeMap::new(),
7896                carry_mass: 0.0,
7897                carry_mass_max: 0.0,
7898                encumbrance: flatland_protocol::EncumbranceState::Light,
7899                move_speed_mps: 0.0,
7900                move_speed_mult: 0.0,
7901                inventory_stacks: Vec::new(),
7902                keychain_stacks: Vec::new(),
7903                whisper_pouch_stacks: Vec::new(),
7904                combat_target_detail: None,
7905                statuses: Vec::new(),
7906                cast_progress: None,
7907                timed_channel: None,
7908                plot_build_offer: None,
7909                ability_cooldowns: Vec::new(),
7910                blocking_active: false,
7911                max_target_slots: 1,
7912                combat_slots: Vec::new(),
7913                rotation_presets: Vec::new(),
7914                known_abilities: Vec::new(),
7915                ability_meta: std::collections::HashMap::new(),
7916                ability_mastery: std::collections::HashMap::new(),
7917                hotbar: vec![None; 9],
7918                max_abilities_per_rotation: 0,
7919                show_loadout_menu: false,
7920                show_keychain_menu: false,
7921                keychain_menu_index: 0,
7922                show_rotation_editor: false,
7923                loadout_menu_index: 0,
7924                loadout_hotbar_slot: 1,
7925                loadout_ability_index: 0,
7926                loadout_focus_presets: false,
7927                rotation_editor: RotationEditorState::default(),
7928                harvest_in_progress: false,
7929                harvest_started_at: None,
7930                pending_craft_ack: None,
7931                craft_channel_blueprint_id: None,
7932                pending_worker_job_ack: None,
7933                attending_worker_instance_id: None,
7934                quest_log: Vec::new(),
7935                interactables: Vec::new(),
7936                ledger: None,
7937                career: None,
7938                character_sheet_tab: CharacterSheetTab::Character,
7939                ledger_period: LedgerPeriod::Day,
7940                show_quest_offer: false,
7941                pending_quest_offers: Vec::new(),
7942                quest_offer_index: 0,
7943                show_quest_menu: false,
7944                quest_menu_index: 0,
7945                quest_withdraw_confirm: false,
7946                hired_workers: Vec::new(),
7947                show_workers_menu: false,
7948                workers_menu_index: 0,
7949                worker_dismiss_confirmation: None,
7950                workers_menu_compact: false,
7951                worker_step_display: BTreeMap::new(),
7952                worker_error_display: BTreeMap::new(),
7953                worker_health_ring_until: BTreeMap::new(),
7954                pending_worker_hire_since: None,
7955                show_worker_give_picker: false,
7956                worker_give_picker_index: 0,
7957                worker_give_picker: None,
7958                show_worker_give_target_picker: false,
7959                worker_give_target_picker_index: 0,
7960                worker_give_target_picker: None,
7961                show_worker_take_picker: false,
7962                worker_take_picker_index: 0,
7963                worker_take_picker: None,
7964                show_worker_teach_picker: false,
7965                worker_teach_picker_index: 0,
7966                worker_teach_picker: None,
7967                worker_route_editor: None,
7968                progression_curve: None,
7969            },
7970        };
7971        client.state.apply_client_ui_prefs();
7972        client
7973    }
7974
7975    pub fn entity_id(&self) -> EntityId {
7976        self.state.entity_id
7977    }
7978
7979    pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
7980        if self.state.connected {
7981            return Ok(());
7982        }
7983
7984        loop {
7985            match self.session.next_event().await {
7986                Some(SessionEvent::Welcome {
7987                    session_id,
7988                    entity_id,
7989                    snapshot,
7990                }) => {
7991                    self.state
7992                        .restore_from_welcome(session_id, entity_id, &snapshot);
7993                    self.state.apply_client_ui_prefs();
7994                    self.state.push_log(format!(
7995                        "Connected — session {session_id}, entity {entity_id}"
7996                    ));
7997                    return Ok(());
7998                }
7999                Some(SessionEvent::Disconnected { .. }) => {
8000                    anyhow::bail!("disconnected before welcome");
8001                }
8002                Some(_) => continue,
8003                None => anyhow::bail!("session closed before welcome"),
8004            }
8005        }
8006    }
8007
8008    /// Drain all pending server events (non-blocking).
8009    pub fn drain_events(&mut self) {
8010        while let Some(event) = self.session.try_next_event() {
8011            if self.handle_event_sync(event).is_err() {
8012                break;
8013            }
8014        }
8015    }
8016
8017    /// Wait for the next server event.
8018    pub async fn next_event(&mut self) -> Option<SessionEvent> {
8019        self.session.next_event().await
8020    }
8021
8022    pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
8023        self.handle_event_sync(event)
8024    }
8025
8026    fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
8027        match event {
8028            SessionEvent::Welcome {
8029                session_id,
8030                entity_id,
8031                snapshot,
8032            } => {
8033                let resumed = self.state.connected;
8034                self.state
8035                    .restore_from_welcome(session_id, entity_id, &snapshot);
8036                if resumed {
8037                    self.state.push_log(format!(
8038                        "Session restored — session {session_id}, entity {entity_id}"
8039                    ));
8040                }
8041            }
8042            SessionEvent::ContentUpdated { snapshot } => {
8043                self.state
8044                    .apply_snapshot_fields(&snapshot, self.state.entity_id);
8045                self.state.push_log(format!(
8046                    "World updated (content rev {})",
8047                    snapshot.content_rev
8048                ));
8049            }
8050            SessionEvent::QuestCatalogUpdated(update) => {
8051                self.state.push_log(format!(
8052                    "Quest board updated (revision {}, {} new, {} retired)",
8053                    update.revision,
8054                    update.accepted.len(),
8055                    update.retired.len()
8056                ));
8057            }
8058            SessionEvent::Tick(delta) => {
8059                self.state.apply_tick_fields(&delta, self.state.entity_id);
8060                self.state.ticks_received += 1;
8061            }
8062            SessionEvent::IntentAck {
8063                entity_id,
8064                seq,
8065                tick,
8066            } => {
8067                crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
8068                if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
8069                    if *craft_seq == seq {
8070                        let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
8071                        if batches > 1 {
8072                            self.state.push_log(format!("Crafting {label} ×{batches}…"));
8073                        } else {
8074                            self.state.push_log(format!("Crafting {label}…"));
8075                        }
8076                    }
8077                }
8078                if self
8079                    .state
8080                    .pending_worker_job_ack
8081                    .as_ref()
8082                    .is_some_and(|p| p.seq == seq)
8083                {
8084                    let pending = self.state.pending_worker_job_ack.take().unwrap();
8085                    if pending.idle {
8086                        self.state.push_log(format!(
8087                            "Route cleared for {} — worker idle",
8088                            pending.worker_label
8089                        ));
8090                    } else {
8091                        self.state.push_log(format!(
8092                            "Route saved for {} — {} stop(s), job loop active",
8093                            pending.worker_label, pending.stop_count
8094                        ));
8095                    }
8096                    if self
8097                        .state
8098                        .worker_route_editor
8099                        .as_ref()
8100                        .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
8101                    {
8102                        self.close_worker_route_editor();
8103                    }
8104                }
8105            }
8106            SessionEvent::Chat(msg) => {
8107                let label = match msg.channel {
8108                    flatland_protocol::ChatChannel::Nearby => "nearby",
8109                    flatland_protocol::ChatChannel::Direct => "speak",
8110                    flatland_protocol::ChatChannel::Whisper => "whisper",
8111                    flatland_protocol::ChatChannel::WhisperStone => "stone",
8112                };
8113                let clarity = match msg.clarity {
8114                    flatland_protocol::ChatClarity::Clear => "",
8115                    flatland_protocol::ChatClarity::Partial => "~",
8116                    flatland_protocol::ChatClarity::Heavy => "…",
8117                };
8118                self.state.push_log(format!(
8119                    "[{label}{clarity}] {}: {}",
8120                    msg.from_name, msg.text
8121                ));
8122                let now_ms = std::time::SystemTime::now()
8123                    .duration_since(std::time::UNIX_EPOCH)
8124                    .map(|d| d.as_millis() as u64)
8125                    .unwrap_or(0);
8126                self.state
8127                    .social_chat
8128                    .note_speech(&msg, self.state.entity_id, now_ms);
8129                self.state
8130                    .social_chat
8131                    .push(crate::social::ChatLogEntry::from_message(
8132                        msg,
8133                        self.state.entity_id,
8134                    ));
8135            }
8136            SessionEvent::TradeOpened(panel) => {
8137                self.state.social_chat.pending_trade = None;
8138                let peer = panel.peer_name.clone();
8139                self.state.trade_ui.open(panel);
8140                self.state.social_chat.push_system(format!(
8141                    "Trade open with {peer} — p present · r ready · Esc cancel"
8142                ));
8143                self.state
8144                    .social_chat
8145                    .push_cue(crate::social::AudioCue::TradeOpened);
8146            }
8147            SessionEvent::TradeClosed { reason } => {
8148                self.state.push_log(reason.clone());
8149                self.state.social_chat.push_system(reason);
8150                self.state.trade_ui.close();
8151            }
8152            SessionEvent::HarvestResult(result) => {
8153                self.state.clear_harvest_state();
8154                crate::harvest_trace!(
8155                    entity_id = self.state.entity_id,
8156                    node_id = %result.node_id,
8157                    template = %result.item_template,
8158                    quantity = result.quantity,
8159                    client_tick = self.state.tick,
8160                    "client applied harvest result"
8161                );
8162                let msg = if result.quantity == 0 {
8163                    format!(
8164                        "Harvested {} x0 — nothing dropped (loot table rolled empty)",
8165                        result.item_template
8166                    )
8167                } else {
8168                    format!(
8169                        "Harvested {} x{} (on the ground — press P to pick up)",
8170                        result.item_template, result.quantity
8171                    )
8172                };
8173                self.state.push_log(msg);
8174            }
8175            SessionEvent::CraftResult(result) => {
8176                for stack in &result.consumed {
8177                    if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
8178                        *qty = qty.saturating_sub(stack.quantity);
8179                        if *qty == 0 {
8180                            self.state.inventory.remove(&stack.template_id);
8181                        }
8182                    }
8183                }
8184                for stack in &result.outputs {
8185                    *self
8186                        .state
8187                        .inventory
8188                        .entry(stack.template_id.clone())
8189                        .or_insert(0) += stack.quantity;
8190                }
8191                self.state.craft_record_completed(&result.blueprint_id);
8192                if let Some(output) = result.outputs.first() {
8193                    if result.batch_total > 1 {
8194                        self.state.push_log(format!(
8195                            "Crafted {} x{} ({}/{})",
8196                            output.template_id,
8197                            output.quantity,
8198                            result.batch_index,
8199                            result.batch_total
8200                        ));
8201                    } else {
8202                        self.state.push_log(format!(
8203                            "Crafted {} x{}",
8204                            output.template_id, output.quantity
8205                        ));
8206                    }
8207                } else {
8208                    self.state
8209                        .push_log(format!("Craft finished: {}", result.blueprint_id));
8210                }
8211            }
8212            SessionEvent::Death(notice) => {
8213                self.state.clear_harvest_state();
8214                self.state.push_log(notice.message.clone());
8215                self.state.push_log(format!(
8216                    "Respawned at ({:.1}, {:.1})",
8217                    notice.respawn_x, notice.respawn_y
8218                ));
8219            }
8220            SessionEvent::Interaction(notice) => {
8221                if notice.message.starts_with("Harvest failed:") {
8222                    self.state.clear_harvest_state();
8223                }
8224                if notice.message.starts_with("Can't do that:") {
8225                    self.state.pending_worker_hire_since = None;
8226                    self.state.pending_craft_ack = None;
8227                    self.state.craft_channel_blueprint_id = None;
8228                    if let Some(pending) = self.state.pending_worker_job_ack.take() {
8229                        if let Some(w) = self
8230                            .state
8231                            .hired_workers
8232                            .iter_mut()
8233                            .find(|w| w.instance_id == pending.worker_instance_id)
8234                        {
8235                            w.route = pending.prev_route;
8236                            w.mode = pending.prev_mode;
8237                            w.step_label = pending.prev_step_label;
8238                            w.last_error = pending.prev_last_error;
8239                        }
8240                        let reason = notice
8241                            .message
8242                            .strip_prefix("Can't do that:")
8243                            .unwrap_or(&notice.message)
8244                            .trim();
8245                        self.state.push_log(format!(
8246                            "Route save failed for {}: {reason}",
8247                            pending.worker_label
8248                        ));
8249                    }
8250                    let reason = notice
8251                        .message
8252                        .strip_prefix("Can't do that:")
8253                        .unwrap_or(&notice.message)
8254                        .trim();
8255                    if reason.contains("already tilled") {
8256                        if let Some(plot) = self.state.my_plot_under_player() {
8257                            self.state.sell_plot_confirm = Some(plot.plot_id);
8258                            self.state.sell_plot_armed_at = Some(Instant::now());
8259                        }
8260                    }
8261                }
8262                if notice.message.starts_with("Cast failed:") {
8263                    self.state.cast_progress = None;
8264                }
8265                if notice.message.contains("slain the") {
8266                    self.state.combat_target = None;
8267                    self.state.combat_target_label = None;
8268                }
8269                // Inbound trade request → inline Y/N in the CHAT column (no popup).
8270                if notice.message.contains("wants to trade") {
8271                    if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
8272                        let from_name = notice
8273                            .message
8274                            .split(" wants to trade")
8275                            .next()
8276                            .unwrap_or("Player")
8277                            .to_string();
8278                        self.state.social_chat.pending_trade =
8279                            Some(crate::social::PendingTradeRequest {
8280                                from_entity,
8281                                from_name: from_name.clone(),
8282                            });
8283                        self.state.social_chat.push_system(format!(
8284                            "{from_name} wants to trade — [Y] accept · [N] decline"
8285                        ));
8286                        self.state
8287                            .social_chat
8288                            .push_cue(crate::social::AudioCue::TradeOffer);
8289                    }
8290                }
8291                if notice.message.starts_with("trade request declined") {
8292                    self.state.social_chat.push_system(notice.message.clone());
8293                    self.state
8294                        .social_chat
8295                        .push_cue(crate::social::AudioCue::TradeDeclined);
8296                }
8297                // Verb dock hides the floating LOG — keep refuse lines on the panel + toast.
8298                if self.state.show_npc_verb_menu && !notice.message.trim().is_empty() {
8299                    self.state.npc_verb_notice = Some(notice.message.clone());
8300                    self.state
8301                        .social_chat
8302                        .push_cue(crate::social::AudioCue::UiError);
8303                }
8304                self.state.apply_interaction_notice(&notice);
8305                self.state.push_log(notice.message.clone());
8306            }
8307            SessionEvent::ShopOpened(catalog) => {
8308                self.state.apply_shop_catalog(catalog);
8309            }
8310            SessionEvent::BankOpened(panel) => {
8311                self.state.apply_bank_panel(panel);
8312            }
8313            SessionEvent::StorageOpened(panel) => {
8314                self.state.apply_storage_panel(panel);
8315            }
8316            SessionEvent::MarketOpened(panel) => {
8317                self.state.apply_market_panel(panel);
8318            }
8319            SessionEvent::NpcTalkOpened(opened) => {
8320                self.state.show_npc_verb_menu = false;
8321                if self.state.npc_verb_target.is_none() {
8322                    self.state.npc_verb_target = Some(opened.npc_id.clone());
8323                }
8324                let label = opened.npc_label.clone();
8325                let banner = if !opened.trade_allowed {
8326                    Some("Trade is unavailable right now.".to_string())
8327                } else {
8328                    None
8329                };
8330                self.state.show_npc_chat = true;
8331                self.state.npc_chat = Some(NpcChatState {
8332                    npc_id: opened.npc_id,
8333                    npc_label: opened.npc_label,
8334                    lines: if opened.greeting.is_empty() {
8335                        vec![]
8336                    } else {
8337                        vec![format!("{label}: {}", opened.greeting)]
8338                    },
8339                    input: String::new(),
8340                    pending: opened.greeting.is_empty(),
8341                    talk_depth: opened.talk_depth,
8342                    trade_allowed: opened.trade_allowed,
8343                    banner,
8344                    suggested_topics: opened.suggested_topics,
8345                });
8346            }
8347            SessionEvent::NpcTalkPending(_) => {
8348                if let Some(chat) = self.state.npc_chat.as_mut() {
8349                    chat.pending = true;
8350                }
8351            }
8352            SessionEvent::NpcTalkReply(reply) => {
8353                if let Some(chat) = self.state.npc_chat.as_mut() {
8354                    if chat.npc_id == reply.npc_id {
8355                        chat.pending = false;
8356                        if reply.trade_disabled {
8357                            chat.trade_allowed = false;
8358                            chat.banner = Some("Trade is unavailable right now.".to_string());
8359                        }
8360                        if reply.wind_down {
8361                            chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
8362                            if chat.banner.is_none() {
8363                                chat.banner =
8364                                    Some("They're wrapping up — keep it brief.".to_string());
8365                            }
8366                        }
8367                        chat.lines
8368                            .push(format!("{}: {}", chat.npc_label, reply.line));
8369                    }
8370                }
8371            }
8372            SessionEvent::NpcTalkClosed(closed) => {
8373                if self
8374                    .state
8375                    .npc_chat
8376                    .as_ref()
8377                    .is_some_and(|c| c.npc_id == closed.npc_id)
8378                {
8379                    self.state.show_npc_chat = false;
8380                    self.state.npc_chat = None;
8381                }
8382            }
8383            SessionEvent::NpcTalkError(err) => {
8384                self.state.push_log(format!("Talk failed: {}", err.reason));
8385                if let Some(chat) = self.state.npc_chat.as_mut() {
8386                    chat.pending = false;
8387                }
8388            }
8389            SessionEvent::UseResult(result) => {
8390                // Inventory count hint only — the Interaction notice already logs
8391                // "Consumed …" (and drives the gfx toast). Logging again here doubled toasts.
8392                if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
8393                    *qty = qty.saturating_sub(1);
8394                    if *qty == 0 {
8395                        self.state.inventory.remove(&result.template_id);
8396                    }
8397                }
8398            }
8399            SessionEvent::QuestOffer(offer) => {
8400                let title = offer.title.clone();
8401                self.state.push_quest_offer(offer);
8402                self.state.push_log(format!("Quest offered: {title}"));
8403            }
8404            SessionEvent::QuestAccepted(notice) => {
8405                self.state.remove_quest_offer(&notice.quest_id);
8406                self.state.push_log(notice.message);
8407            }
8408            SessionEvent::QuestWithdrawn(notice) => {
8409                self.state.show_quest_menu = false;
8410                self.state.quest_withdraw_confirm = false;
8411                self.state.push_log(notice.message);
8412            }
8413            SessionEvent::QuestStepCompleted(notice) => {
8414                self.state.push_log(notice.message);
8415            }
8416            SessionEvent::QuestCompleted(notice) => {
8417                self.state.push_log(notice.message);
8418            }
8419            SessionEvent::Disconnected { reason } => {
8420                self.state.clear_harvest_state();
8421                self.state.connected = false;
8422                self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
8423                if let Some(r) = &self.state.disconnect_reason {
8424                    self.state.push_log(format!("Disconnected: {r}"));
8425                } else {
8426                    self.state.push_log("Disconnected from server");
8427                }
8428            }
8429        }
8430        Ok(())
8431    }
8432
8433    pub fn is_connected(&self) -> bool {
8434        self.state.connected
8435    }
8436
8437    pub fn close_overlays(&mut self) {
8438        self.state.show_stats = false;
8439        self.state.show_craft_menu = false;
8440        self.state.show_plot_build_menu = false;
8441        self.state.show_shop_menu = false;
8442        self.state.shop_catalog = None;
8443        self.state.show_npc_verb_menu = false;
8444        self.state.npc_verb_target = None;
8445        self.state.show_npc_chat = false;
8446        self.state.npc_chat = None;
8447        self.state.show_inventory_menu = false;
8448        self.state.show_loadout_menu = false;
8449        self.state.show_rotation_editor = false;
8450        self.state.rotation_editor.reset();
8451        self.state.show_rename_prompt = false;
8452        self.state.show_worker_rename = false;
8453        self.state.rename_buffer.clear();
8454        self.state.show_move_picker = false;
8455        self.state.move_picker = None;
8456        self.state.show_destroy_picker = false;
8457        self.state.destroy_confirm_pending = false;
8458        self.state.destroy_picker = None;
8459        self.state.show_quest_offer = false;
8460        self.state.clear_quest_offers();
8461        self.state.show_quest_menu = false;
8462        self.state.quest_withdraw_confirm = false;
8463        self.state.show_workers_menu = false;
8464        self.close_worker_give_picker();
8465        self.close_worker_give_target_picker();
8466        self.close_worker_take_picker();
8467        self.close_worker_teach_picker();
8468        self.state.worker_route_editor = None;
8469        self.state.claim_mode = None;
8470        self.state.relocate_mode = None;
8471        self.state.sell_plot_confirm = None;
8472        self.state.sell_plot_armed_at = None;
8473        self.close_farm_access_panel();
8474        if self.state.show_plant_menu {
8475            self.close_plant_menu();
8476        }
8477    }
8478
8479    /// Esc / back — pop one UI layer instead of closing every overlay at once.
8480    pub fn back_on_esc(&mut self) -> bool {
8481        if self.state.social_chat.composer_open() {
8482            self.state.social_chat.close_composer();
8483            return true;
8484        }
8485        if self.state.player_verbs.open {
8486            self.state.player_verbs.close();
8487            return true;
8488        }
8489        if self.state.whisper_pouch_ui.open {
8490            self.state.whisper_pouch_ui.open = false;
8491            return true;
8492        }
8493        if self.state.trade_ui.panel.is_some() {
8494            // async cancel — caller should prefer trade_cancel; close UI optimistically
8495            self.state.trade_ui.close();
8496            return true;
8497        }
8498        if self.state.show_rename_prompt {
8499            self.cancel_rename_prompt();
8500            return true;
8501        }
8502        if self.state.show_worker_rename {
8503            self.cancel_worker_rename();
8504            return true;
8505        }
8506        if self.state.show_destroy_picker {
8507            if self.state.destroy_confirm_pending {
8508                self.cancel_destroy_confirm();
8509            } else {
8510                self.close_destroy_picker();
8511            }
8512            return true;
8513        }
8514        if self.state.claim_mode.is_some() {
8515            self.cancel_claim_mode();
8516            return true;
8517        }
8518        if self.state.relocate_mode.is_some() {
8519            self.cancel_relocate_mode();
8520            return true;
8521        }
8522        if self.state.show_plant_menu {
8523            self.close_plant_menu();
8524            return true;
8525        }
8526        if self.state.show_farm_access {
8527            self.close_farm_access_panel();
8528            return true;
8529        }
8530        if self.state.sell_plot_confirm.is_some() {
8531            self.state.sell_plot_confirm = None;
8532            self.state.sell_plot_armed_at = None;
8533            self.state.push_log("Sell cancelled");
8534            return true;
8535        }
8536        if self.state.show_move_picker {
8537            self.close_move_picker();
8538            return true;
8539        }
8540        if self.state.show_rotation_editor {
8541            match self.state.rotation_editor.mode {
8542                RotationEditorMode::List => {
8543                    self.state.show_rotation_editor = false;
8544                    self.state.rotation_editor.reset();
8545                }
8546                RotationEditorMode::EditLabel => {
8547                    self.state.rotation_editor.label_buffer.clear();
8548                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8549                }
8550                RotationEditorMode::PickAbility => {
8551                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
8552                }
8553                RotationEditorMode::EditSequence => {
8554                    self.state.rotation_editor.draft = None;
8555                    self.state.rotation_editor.mode = RotationEditorMode::List;
8556                }
8557            }
8558            return true;
8559        }
8560        if self.state.show_inventory_menu {
8561            self.close_inventory_menu();
8562            return true;
8563        }
8564        if self.state.show_craft_menu {
8565            self.close_craft_menu();
8566            return true;
8567        }
8568        if self.state.show_plot_build_menu {
8569            self.close_plot_build_menu();
8570            return true;
8571        }
8572        if self.state.show_keychain_menu {
8573            self.close_keychain_menu();
8574            return true;
8575        }
8576        if self.state.show_quest_offer {
8577            self.quest_offer_decline();
8578            return true;
8579        }
8580        if self.state.show_shop_menu {
8581            // Caller must await shop close (npc_interaction_back / play-loop).
8582            return false;
8583        }
8584        if self.state.bank_panel.is_some() {
8585            return false;
8586        }
8587        if self.state.storage_panel.is_some() {
8588            return false;
8589        }
8590        if self.state.market_panel.is_some() {
8591            return false;
8592        }
8593        if self.state.show_npc_chat {
8594            // play.rs calls npc_interaction_back().await on Esc
8595            return false;
8596        }
8597        if self.state.show_npc_verb_menu {
8598            self.state.show_npc_verb_menu = false;
8599            self.state.npc_verb_target = None;
8600            self.state.npc_verb_notice = None;
8601            return true;
8602        }
8603        if self.state.show_quest_menu {
8604            if self.state.quest_withdraw_confirm {
8605                self.state.quest_withdraw_confirm = false;
8606            } else {
8607                self.state.show_quest_menu = false;
8608            }
8609            return true;
8610        }
8611        if self.state.worker_route_editor.is_some() {
8612            // Pop one sheet level; only close the editor at the root stop list.
8613            if self.re_at_root_sheet() {
8614                let reopen = self.state.attending_worker_instance_id.clone();
8615                self.close_worker_route_editor();
8616                if let Some(id) = reopen {
8617                    if let Some(idx) = self
8618                        .state
8619                        .hired_workers
8620                        .iter()
8621                        .position(|w| w.instance_id == id)
8622                    {
8623                        self.state.workers_menu_index = idx;
8624                        self.state.show_workers_menu = true;
8625                    }
8626                }
8627            } else {
8628                self.re_sheet_back();
8629            }
8630            return true;
8631        }
8632        if self.state.show_worker_give_picker {
8633            self.close_worker_give_picker();
8634            return true;
8635        }
8636        if self.state.show_worker_give_target_picker {
8637            self.close_worker_give_target_picker();
8638            return true;
8639        }
8640        if self.state.show_worker_take_picker {
8641            self.close_worker_take_picker();
8642            return true;
8643        }
8644        if self.state.show_worker_teach_picker {
8645            self.close_worker_teach_picker();
8646            return true;
8647        }
8648        if self.state.show_workers_menu {
8649            self.close_workers_menu_ui();
8650            return true;
8651        }
8652        if self.state.show_loadout_menu {
8653            self.state.show_loadout_menu = false;
8654            return true;
8655        }
8656        if self.state.show_stats {
8657            self.state.show_stats = false;
8658            return true;
8659        }
8660        if self.state.show_equip_menu {
8661            self.state.show_equip_menu = false;
8662            return true;
8663        }
8664        false
8665    }
8666
8667    pub fn toggle_stats(&mut self) {
8668        self.state.show_stats = !self.state.show_stats;
8669        if self.state.show_stats {
8670            self.state.character_sheet_tab = CharacterSheetTab::Character;
8671            self.state.show_craft_menu = false;
8672            self.state.show_shop_menu = false;
8673            self.state.shop_catalog = None;
8674            self.state.show_inventory_menu = false;
8675            self.state.show_equip_menu = false;
8676        }
8677    }
8678
8679    pub fn toggle_equip_menu(&mut self) {
8680        self.state.show_equip_menu = !self.state.show_equip_menu;
8681        if self.state.show_equip_menu {
8682            self.state.show_stats = false;
8683            self.state.show_craft_menu = false;
8684            self.state.show_shop_menu = false;
8685            self.state.shop_catalog = None;
8686            self.state.show_inventory_menu = false;
8687            self.state.show_loadout_menu = false;
8688        }
8689    }
8690
8691    pub fn cycle_character_sheet_tab(&mut self) {
8692        if self.state.show_stats {
8693            self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
8694        }
8695    }
8696
8697    pub fn set_ledger_period_digit(&mut self, c: char) {
8698        if self.state.show_stats {
8699            if let Some(p) = LedgerPeriod::from_digit(c) {
8700                self.state.ledger_period = p;
8701                self.state.character_sheet_tab = CharacterSheetTab::Ledger;
8702            }
8703        }
8704    }
8705
8706    pub fn cycle_ledger_period(&mut self) {
8707        if self.state.show_stats && self.state.character_sheet_tab == CharacterSheetTab::Ledger {
8708            self.state.ledger_period = self.state.ledger_period.cycle();
8709        }
8710    }
8711
8712    pub fn open_inventory_menu(&mut self) {
8713        self.state.show_inventory_menu = true;
8714        self.state.show_craft_menu = false;
8715        self.state.show_shop_menu = false;
8716        self.state.shop_catalog = None;
8717        self.state.show_stats = false;
8718        self.state.show_move_picker = false;
8719        self.state.move_picker = None;
8720        self.state.show_destroy_picker = false;
8721        self.state.destroy_confirm_pending = false;
8722        self.state.destroy_picker = None;
8723        self.state.show_rename_prompt = false;
8724        self.state.rename_plot_id = None;
8725        self.state.rename_buffer.clear();
8726        self.state.inventory_filter_focused = false;
8727        self.state.clamp_inventory_indices();
8728    }
8729
8730    pub fn close_inventory_menu(&mut self) {
8731        self.state.show_inventory_menu = false;
8732        self.state.show_move_picker = false;
8733        self.state.move_picker = None;
8734        self.close_grant_picker();
8735        self.state.show_destroy_picker = false;
8736        self.state.destroy_confirm_pending = false;
8737        self.state.destroy_picker = None;
8738        self.state.show_rename_prompt = false;
8739        self.state.rename_plot_id = None;
8740        self.state.rename_buffer.clear();
8741        self.state.inventory_filter_focused = false;
8742    }
8743
8744    pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
8745        let Some(row) = self.state.inventory_selected_row() else {
8746            anyhow::bail!("inventory empty");
8747        };
8748        if GameState::is_property_deed_template(&row.stack.template_id) {
8749            let Some(plot_id) = GameState::deed_plot_id(&row.stack) else {
8750                anyhow::bail!("deed has no plot id");
8751            };
8752            let label = self
8753                .state
8754                .property_plots
8755                .iter()
8756                .find(|p| p.plot_id == plot_id)
8757                .map(|p| {
8758                    if p.label.trim().is_empty() {
8759                        p.plot_code.clone()
8760                    } else {
8761                        p.label.clone()
8762                    }
8763                })
8764                .unwrap_or_else(|| {
8765                    row.stack
8766                        .display_name
8767                        .clone()
8768                        .unwrap_or_else(|| "plot".into())
8769                });
8770            self.state.rename_buffer = label;
8771            self.state.rename_plot_id = Some(plot_id);
8772            self.state.highlighted_plot_id = Some(plot_id);
8773            self.state.show_rename_prompt = true;
8774            self.state.show_worker_rename = false;
8775            self.state.show_move_picker = false;
8776            self.state.show_destroy_picker = false;
8777            self.state.destroy_confirm_pending = false;
8778            return Ok(());
8779        }
8780        if !self.state.row_is_renameable_container(&row) {
8781            anyhow::bail!("only storage containers or deeds can be renamed");
8782        }
8783        let current = row
8784            .stack
8785            .display_name
8786            .clone()
8787            .unwrap_or_else(|| row.stack.template_id.clone());
8788        self.state.rename_buffer = current;
8789        self.state.rename_plot_id = None;
8790        self.state.show_rename_prompt = true;
8791        self.state.show_worker_rename = false;
8792        self.state.show_move_picker = false;
8793        self.state.show_destroy_picker = false;
8794        self.state.destroy_confirm_pending = false;
8795        Ok(())
8796    }
8797
8798    /// Rename the owned plot under the player's feet (`n` in world).
8799    pub fn open_plot_rename_under_player(&mut self) -> anyhow::Result<()> {
8800        let Some(plot) = self.state.my_plot_under_player().cloned() else {
8801            anyhow::bail!("stand on your plot to rename it");
8802        };
8803        let label = if plot.label.trim().is_empty() {
8804            plot.plot_code.clone()
8805        } else {
8806            plot.label.clone()
8807        };
8808        self.state.rename_buffer = label;
8809        self.state.rename_plot_id = Some(plot.plot_id);
8810        self.state.highlighted_plot_id = Some(plot.plot_id);
8811        self.state.show_rename_prompt = true;
8812        self.state.show_worker_rename = false;
8813        Ok(())
8814    }
8815
8816    pub fn cancel_rename_prompt(&mut self) {
8817        self.state.show_rename_prompt = false;
8818        self.state.rename_plot_id = None;
8819        self.state.rename_buffer.clear();
8820    }
8821
8822    pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
8823        let name = self.state.rename_buffer.trim().to_string();
8824        if name.is_empty() {
8825            anyhow::bail!("name cannot be empty");
8826        }
8827        if let Some(plot_id) = self.state.rename_plot_id {
8828            if name.chars().count() > 48 {
8829                anyhow::bail!("label must be 1–48 characters");
8830            }
8831            self.seq += 1;
8832            self.session
8833                .submit_intent(Intent::RenamePropertyPlot {
8834                    entity_id: self.state.entity_id,
8835                    plot_id,
8836                    label: name,
8837                    seq: self.seq,
8838                })
8839                .await?;
8840            self.state.intents_sent += 1;
8841            self.state.show_rename_prompt = false;
8842            self.state.rename_plot_id = None;
8843            self.state.rename_buffer.clear();
8844            return Ok(());
8845        }
8846        if name.chars().count() > 32 {
8847            anyhow::bail!("name must be 1–32 characters");
8848        }
8849        let Some(row) = self.state.inventory_selected_row() else {
8850            anyhow::bail!("inventory empty");
8851        };
8852        let Some(instance_id) = row.stack.item_instance_id else {
8853            anyhow::bail!("item has no instance id");
8854        };
8855        self.seq += 1;
8856        self.session
8857            .submit_intent(Intent::RenameContainer {
8858                entity_id: self.state.entity_id,
8859                item_instance_id: instance_id,
8860                location: row.from.clone(),
8861                name,
8862                seq: self.seq,
8863            })
8864            .await?;
8865        self.state.intents_sent += 1;
8866        self.state.show_rename_prompt = false;
8867        self.state.rename_buffer.clear();
8868        Ok(())
8869    }
8870
8871    pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
8872        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
8873            anyhow::bail!("no worker selected");
8874        };
8875        self.state.rename_buffer = worker.label.clone();
8876        self.state.show_worker_rename = true;
8877        self.state.show_rename_prompt = false;
8878        Ok(())
8879    }
8880
8881    pub fn cancel_worker_rename(&mut self) {
8882        self.state.show_worker_rename = false;
8883        self.state.rename_buffer.clear();
8884    }
8885
8886    pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
8887        let name = self.state.rename_buffer.trim().to_string();
8888        if name.is_empty() {
8889            anyhow::bail!("name cannot be empty");
8890        }
8891        if name.chars().count() > 32 {
8892            anyhow::bail!("name must be 1–32 characters");
8893        }
8894        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
8895            anyhow::bail!("no worker selected");
8896        };
8897        let worker_instance_id = worker.instance_id.clone();
8898        self.seq += 1;
8899        self.session
8900            .submit_intent(Intent::RenameHiredWorker {
8901                entity_id: self.state.entity_id,
8902                worker_instance_id: worker_instance_id.clone(),
8903                name: name.clone(),
8904                seq: self.seq,
8905            })
8906            .await?;
8907        self.state.intents_sent += 1;
8908        if let Some(w) = self
8909            .state
8910            .hired_workers
8911            .iter_mut()
8912            .find(|w| w.instance_id == worker_instance_id)
8913        {
8914            w.label = name.clone();
8915        }
8916        if let Some(ed) = self.state.worker_route_editor.as_mut() {
8917            if ed.worker_instance_id == worker_instance_id {
8918                ed.worker_label = name.clone();
8919            }
8920        }
8921        self.state.show_worker_rename = false;
8922        self.state.rename_buffer.clear();
8923        self.state.push_log(format!("Renamed worker to \"{name}\""));
8924        Ok(())
8925    }
8926
8927    pub fn toggle_inventory_menu(&mut self) {
8928        if self.state.show_inventory_menu {
8929            self.close_inventory_menu();
8930        } else {
8931            self.open_inventory_menu();
8932        }
8933    }
8934
8935    /// ↑/↓ in the inventory browser, or within the "move to…" / grant picker when open.
8936    pub fn inventory_menu_move(&mut self, delta: i32) {
8937        if self.state.show_grant_picker {
8938            let Some(picker) = self.state.grant_picker.as_ref() else {
8939                return;
8940            };
8941            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8942            let filter = picker.filter.clone();
8943            let n = labels.len();
8944            if n == 0 {
8945                return;
8946            }
8947            self.state.grant_picker_index =
8948                step_filtered_index(self.state.grant_picker_index, delta, n, |i| {
8949                    list_label_matches(&labels[i], &filter)
8950                });
8951            return;
8952        }
8953        if self.state.show_move_picker {
8954            let Some(picker) = self.state.move_picker.as_ref() else {
8955                return;
8956            };
8957            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8958            let filter = picker.filter.clone();
8959            let n = labels.len();
8960            if n == 0 {
8961                return;
8962            }
8963            self.state.move_picker_index =
8964                step_filtered_index(self.state.move_picker_index, delta, n, |i| {
8965                    list_label_matches(&labels[i], &filter)
8966                });
8967            self.state.clamp_move_picker_quantity();
8968            return;
8969        }
8970        let n = self.state.inventory_selectable_rows().len();
8971        if n == 0 {
8972            return;
8973        }
8974        let idx = self.state.inventory_menu_index as i32;
8975        self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8976    }
8977
8978    /// PageUp / PageDown (±1 page) for inventory / move / grant lists.
8979    pub fn inventory_menu_page(&mut self, pages: i32) {
8980        if self.state.show_grant_picker {
8981            let Some(picker) = self.state.grant_picker.as_ref() else {
8982                return;
8983            };
8984            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8985            let filter = picker.filter.clone();
8986            let n = labels.len();
8987            self.state.grant_picker_index =
8988                page_filtered_index(self.state.grant_picker_index, pages, n, |i| {
8989                    list_label_matches(&labels[i], &filter)
8990                });
8991            return;
8992        }
8993        if self.state.show_move_picker {
8994            let Some(picker) = self.state.move_picker.as_ref() else {
8995                return;
8996            };
8997            let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
8998            let filter = picker.filter.clone();
8999            let n = labels.len();
9000            self.state.move_picker_index =
9001                page_filtered_index(self.state.move_picker_index, pages, n, |i| {
9002                    list_label_matches(&labels[i], &filter)
9003                });
9004            self.state.clamp_move_picker_quantity();
9005            return;
9006        }
9007        let n = self.state.inventory_selectable_rows().len();
9008        self.state.inventory_menu_index =
9009            page_list_index(self.state.inventory_menu_index, pages, n);
9010    }
9011
9012    pub fn cycle_inventory_tab(&mut self, forward: bool) {
9013        if self.state.show_move_picker
9014            || self.state.show_grant_picker
9015            || self.state.show_destroy_picker
9016            || self.state.show_rename_prompt
9017            || self.state.inventory_filter_focused
9018        {
9019            return;
9020        }
9021        self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
9022        self.state.inventory_menu_index = 0;
9023        self.state.clamp_inventory_indices();
9024    }
9025
9026    pub fn focus_inventory_filter(&mut self) {
9027        if self.state.show_grant_picker {
9028            if let Some(p) = self.state.grant_picker.as_mut() {
9029                p.filter_focused = true;
9030            }
9031            return;
9032        }
9033        if self.state.show_move_picker {
9034            if let Some(p) = self.state.move_picker.as_mut() {
9035                p.filter_focused = true;
9036            }
9037            return;
9038        }
9039        self.state.inventory_filter_focused = true;
9040    }
9041
9042    pub fn set_inventory_filter(&mut self, filter: String) {
9043        self.state.inventory_filter = filter;
9044        self.state.inventory_menu_index = 0;
9045        self.state.clamp_inventory_indices();
9046    }
9047
9048    pub fn append_inventory_filter_char(&mut self, ch: char) {
9049        if !is_list_filter_char(ch) {
9050            return;
9051        }
9052        if self.state.show_grant_picker {
9053            if let Some(p) = self.state.grant_picker.as_mut() {
9054                if p.filter_focused {
9055                    p.filter.push(ch);
9056                    self.state.grant_picker_index = 0;
9057                }
9058            }
9059            return;
9060        }
9061        if self.state.show_move_picker {
9062            if let Some(p) = self.state.move_picker.as_mut() {
9063                if p.filter_focused {
9064                    p.filter.push(ch);
9065                    self.state.move_picker_index = 0;
9066                    self.state.clamp_move_picker_quantity();
9067                }
9068            }
9069            return;
9070        }
9071        if !self.state.inventory_filter_focused {
9072            return;
9073        }
9074        self.state.inventory_filter.push(ch);
9075        self.state.inventory_menu_index = 0;
9076        self.state.clamp_inventory_indices();
9077    }
9078
9079    pub fn inventory_filter_backspace(&mut self) {
9080        if self.state.show_grant_picker {
9081            if let Some(p) = self.state.grant_picker.as_mut() {
9082                if p.filter_focused {
9083                    p.filter.pop();
9084                    self.state.grant_picker_index = 0;
9085                }
9086            }
9087            return;
9088        }
9089        if self.state.show_move_picker {
9090            if let Some(p) = self.state.move_picker.as_mut() {
9091                if p.filter_focused {
9092                    p.filter.pop();
9093                    self.state.move_picker_index = 0;
9094                    self.state.clamp_move_picker_quantity();
9095                }
9096            }
9097            return;
9098        }
9099        if !self.state.inventory_filter_focused {
9100            return;
9101        }
9102        self.state.inventory_filter.pop();
9103        self.state.inventory_menu_index = 0;
9104        self.state.clamp_inventory_indices();
9105    }
9106
9107    /// Esc while filter focused: clear filter then blur. Returns true if handled.
9108    pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
9109        if self.state.show_grant_picker {
9110            if let Some(p) = self.state.grant_picker.as_mut() {
9111                if p.filter_focused {
9112                    if !p.filter.is_empty() {
9113                        p.filter.clear();
9114                        self.state.grant_picker_index = 0;
9115                    } else {
9116                        p.filter_focused = false;
9117                    }
9118                    return true;
9119                }
9120                if !p.filter.is_empty() {
9121                    p.filter.clear();
9122                    self.state.grant_picker_index = 0;
9123                    return true;
9124                }
9125            }
9126            return false;
9127        }
9128        if self.state.show_move_picker {
9129            if let Some(p) = self.state.move_picker.as_mut() {
9130                if p.filter_focused {
9131                    if !p.filter.is_empty() {
9132                        p.filter.clear();
9133                        self.state.move_picker_index = 0;
9134                        self.state.clamp_move_picker_quantity();
9135                    } else {
9136                        p.filter_focused = false;
9137                    }
9138                    return true;
9139                }
9140                if !p.filter.is_empty() {
9141                    p.filter.clear();
9142                    self.state.move_picker_index = 0;
9143                    self.state.clamp_move_picker_quantity();
9144                    return true;
9145                }
9146            }
9147            return false;
9148        }
9149        if self.state.inventory_filter_focused {
9150            if !self.state.inventory_filter.is_empty() {
9151                self.state.inventory_filter.clear();
9152                self.state.inventory_menu_index = 0;
9153                self.state.clamp_inventory_indices();
9154            } else {
9155                self.state.inventory_filter_focused = false;
9156            }
9157            return true;
9158        }
9159        if !self.state.inventory_filter.is_empty() {
9160            self.state.inventory_filter.clear();
9161            self.state.inventory_menu_index = 0;
9162            self.state.clamp_inventory_indices();
9163            return true;
9164        }
9165        false
9166    }
9167
9168    pub fn craft_menu_page(&mut self, pages: i32) {
9169        let n = self.state.craft_filtered_indices().len();
9170        self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
9171        self.state.clamp_craft_batch_quantity();
9172    }
9173
9174    pub fn shop_menu_page(&mut self, pages: i32) {
9175        let n = self.state.shop_list_len();
9176        self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
9177        self.state.clamp_shop_quantity();
9178    }
9179
9180    pub fn workers_menu_page(&mut self, pages: i32) {
9181        let n = self.state.hired_workers.len();
9182        self.state.workers_menu_index = page_list_index(self.state.workers_menu_index, pages, n);
9183    }
9184
9185    /// Enter: unequip a worn bag, equip a weapon, wear/place a loose bag or
9186    /// chest — or fall back to the "move to…" destination picker (including
9187    /// loose consumables; pick "Use" in that list or press `e` to eat/drink).
9188    /// Placed chest shells open a pick-up destination picker (`l` still locks).
9189    pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
9190        if self.state.show_destroy_picker {
9191            if self.state.destroy_confirm_pending {
9192                return self.confirm_destroy_item().await;
9193            }
9194            return self.request_destroy_confirm();
9195        }
9196        if self.state.show_grant_picker {
9197            return self.confirm_grant_picker().await;
9198        }
9199        if self.state.show_move_picker {
9200            return self.confirm_move_picker().await;
9201        }
9202        let Some(row) = self.state.inventory_selected_row() else {
9203            anyhow::bail!("inventory empty");
9204        };
9205        if row.is_equip_shell {
9206            let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
9207                anyhow::bail!("not a worn item");
9208            };
9209            return self.equip_worn(slot, None).await;
9210        }
9211        if row.is_chest_shell {
9212            return self.open_chest_pickup_picker();
9213        }
9214        let template_id = row.stack.template_id.clone();
9215        let instance_id = row.stack.item_instance_id;
9216        let category = self.state.inventory_item_category(&template_id);
9217        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9218
9219        if category == Some("weapon") {
9220            return self.equip_mainhand(Some(template_id)).await;
9221        }
9222        if category == Some("lodging") && on_person {
9223            if let Some(inst) = instance_id {
9224                return self.place_container(inst).await;
9225            }
9226        }
9227        // Placeable workshop tools (kiln, furnace) — Enter places them like chests.
9228        if on_person {
9229            if let Some(inst) = instance_id {
9230                if row.stack.world_placeable == Some(true) {
9231                    return self.place_container(inst).await;
9232                }
9233            }
9234        }
9235        if (category == Some("container") || category == Some("armor")) && on_person {
9236            if let Some(inst) = instance_id {
9237                let world_placeable =
9238                    row.stack.world_placeable == Some(true) || template_id.contains("chest");
9239                if world_placeable {
9240                    return self.place_container(inst).await;
9241                }
9242                // Pouches no longer equip directly — they clip onto a worn belt's loops
9243                // instead, so fall through to the move picker (offers "belt loop" when a
9244                // belt is worn). Backpacks/belts/armor equip straight to their body slot.
9245                if let Some(slot) = guess_body_slot(&template_id) {
9246                    return self.equip_worn(slot, Some(inst)).await;
9247                }
9248            }
9249        }
9250        // Anything else (materials, consumables, pouches, items nested in a bag/chest,
9251        // weapons you'd rather stash than wield, ...) — offer explicit places to move it
9252        // instead of guessing.
9253        self.open_move_picker()
9254    }
9255
9256    /// `e`: eat/drink a consumable, or open grant-target picker for grant oils/scrolls.
9257    pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
9258        let Some(row) = self.state.inventory_selected_row() else {
9259            anyhow::bail!("inventory empty");
9260        };
9261        if row.from != flatland_protocol::InventoryLocation::Root {
9262            anyhow::bail!("select a consumable on your person");
9263        }
9264        if GameState::stack_is_item_grant(&row.stack) {
9265            return self.open_grant_target_picker();
9266        }
9267        if GameState::is_property_deed_template(&row.stack.template_id) {
9268            return self.open_move_picker();
9269        }
9270        let category = self.state.inventory_item_category(&row.stack.template_id);
9271        if category != Some("consumable") && !GameState::stack_is_serving(&row.stack) {
9272            anyhow::bail!("selected item is not usable");
9273        }
9274        self.use_item(&row.stack.template_id).await
9275    }
9276
9277    /// Open picker: apply selected grant item onto inventory / worn gear.
9278    pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
9279        let Some(row) = self.state.inventory_selected_row() else {
9280            anyhow::bail!("inventory empty");
9281        };
9282        if row.from != flatland_protocol::InventoryLocation::Root {
9283            anyhow::bail!("select a grant item on your person");
9284        }
9285        if !GameState::stack_is_item_grant(&row.stack) {
9286            anyhow::bail!("selected item does not grant onto gear");
9287        }
9288        let Some(grant_instance_id) = row.stack.item_instance_id else {
9289            anyhow::bail!("grant has no instance id");
9290        };
9291        let effect_id = GameState::grant_effect_id(&row.stack)
9292            .unwrap_or("?")
9293            .to_string();
9294        let mode = GameState::grant_mode(&row.stack).to_string();
9295        let options = self.state.grant_target_options(&row.stack);
9296        if options.is_empty() {
9297            anyhow::bail!("no valid gear to apply {effect_id} to");
9298        }
9299        let grant_label = row
9300            .stack
9301            .display_name
9302            .clone()
9303            .unwrap_or_else(|| row.stack.template_id.clone());
9304        self.state.show_grant_picker = true;
9305        self.state.grant_picker_index = 0;
9306        self.state.grant_picker = Some(GrantTargetPicker {
9307            grant_instance_id,
9308            grant_label,
9309            effect_id,
9310            mode,
9311            options,
9312            filter: String::new(),
9313            filter_focused: false,
9314        });
9315        Ok(())
9316    }
9317
9318    pub fn close_grant_picker(&mut self) {
9319        self.state.show_grant_picker = false;
9320        self.state.grant_picker = None;
9321        self.state.grant_picker_index = 0;
9322    }
9323
9324    pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
9325        let Some(picker) = self.state.grant_picker.clone() else {
9326            self.close_grant_picker();
9327            return Ok(());
9328        };
9329        let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
9330            self.close_grant_picker();
9331            return Ok(());
9332        };
9333        self.close_grant_picker();
9334        self.use_grant(picker.grant_instance_id, opt.target_instance_id)
9335            .await?;
9336        self.state
9337            .push_log(format!("Applying {} onto {}…", picker.effect_id, opt.label));
9338        Ok(())
9339    }
9340
9341    /// `m`: always open the "move to…" picker for the selected item, even for
9342    /// weapons/wearables that Enter would otherwise equip/wear directly.
9343    /// Placed chests open the pick-up destination picker instead.
9344    pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
9345        let Some(row) = self.state.inventory_selected_row() else {
9346            anyhow::bail!("inventory empty");
9347        };
9348        if row.is_equip_shell {
9349            anyhow::bail!("this is a worn bag — press Enter to unequip it");
9350        }
9351        if row.is_chest_shell {
9352            return self.open_chest_pickup_picker();
9353        }
9354        let Some(instance_id) = row.stack.item_instance_id else {
9355            anyhow::bail!("item has no instance id");
9356        };
9357        let mut options = self.state.move_destinations_for(
9358            &row.from,
9359            row.from_parent_instance_id,
9360            row.stack.item_instance_id,
9361            &row.stack.template_id,
9362        );
9363        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
9364        let category = self.state.inventory_item_category(&row.stack.template_id);
9365        if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
9366            if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
9367                options.insert(
9368                    0,
9369                    MoveOption {
9370                        label: "Sell plot to crown…".into(),
9371                        kind: MoveOptionKind::SellPlotToCrown { plot_id },
9372                    },
9373                );
9374            }
9375        }
9376        if on_person && category == Some("consumable") {
9377            if GameState::stack_is_item_grant(&row.stack) {
9378                options.insert(
9379                    0,
9380                    MoveOption {
9381                        label: "Apply onto gear…".into(),
9382                        kind: MoveOptionKind::GrantApply,
9383                    },
9384                );
9385            } else {
9386                let study = GameState::stack_is_blueprint_scroll(&row.stack);
9387                options.insert(
9388                    0,
9389                    MoveOption {
9390                        label: if study {
9391                            "Study".into()
9392                        } else {
9393                            "Use (eat / drink)".into()
9394                        },
9395                        kind: MoveOptionKind::Use,
9396                    },
9397                );
9398            }
9399        } else if on_person && GameState::stack_is_serving(&row.stack) {
9400            let label = if GameState::stack_is_food_serving(&row.stack) {
9401                "Use (eat)"
9402            } else {
9403                "Use (fill / drink)"
9404            };
9405            options.insert(
9406                0,
9407                MoveOption {
9408                    label: label.into(),
9409                    kind: MoveOptionKind::Use,
9410                },
9411            );
9412        }
9413        let item_label = row
9414            .stack
9415            .display_name
9416            .clone()
9417            .unwrap_or_else(|| row.stack.template_id.clone());
9418        // Default to 1 so withdrawing from storage is partial unless the player
9419        // presses `a` for max fit (full stack when the destination allows it).
9420        let initial_qty = if row.stack.quantity > 1 {
9421            1
9422        } else {
9423            row.stack.quantity
9424        };
9425        self.state.move_picker = Some(MovePicker {
9426            item_instance_id: instance_id,
9427            from: row.from,
9428            item_label,
9429            template_id: row.stack.template_id.clone(),
9430            stack_quantity: row.stack.quantity,
9431            quantity: initial_qty.max(1),
9432            options,
9433            filter: String::new(),
9434            filter_focused: false,
9435        });
9436        self.state.move_picker_index = 0;
9437        self.state.show_move_picker = true;
9438        self.state.show_destroy_picker = false;
9439        self.state.destroy_confirm_pending = false;
9440        self.state.destroy_picker = None;
9441        self.state.clamp_move_picker_quantity();
9442        Ok(())
9443    }
9444
9445    /// Enter/`m` on a placed chest shell: pick destinations to take it into inventory.
9446    pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
9447        let Some(row) = self.state.inventory_selected_row() else {
9448            anyhow::bail!("inventory empty");
9449        };
9450        if !row.is_chest_shell {
9451            anyhow::bail!("not a placed chest");
9452        }
9453        let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
9454            anyhow::bail!("not a placed chest");
9455        };
9456        let Some(instance_id) = row.stack.item_instance_id else {
9457            anyhow::bail!("chest has no instance id");
9458        };
9459        let chest = self
9460            .state
9461            .placed_containers
9462            .iter()
9463            .find(|c| c.id == *container_id)
9464            .cloned()
9465            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
9466        let (px, py) = self.state.player_position();
9467        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
9468            anyhow::bail!("too far from {}", chest.display_name);
9469        }
9470        if chest.locked && !chest.accessible {
9471            anyhow::bail!(
9472                "need the matching key for {} before picking it up",
9473                chest.display_name
9474            );
9475        }
9476        let options = self.state.chest_pickup_destinations(container_id);
9477        let item_label = row
9478            .stack
9479            .display_name
9480            .clone()
9481            .unwrap_or_else(|| row.stack.template_id.clone());
9482        self.state.move_picker = Some(MovePicker {
9483            item_instance_id: instance_id,
9484            from: row.from.clone(),
9485            item_label,
9486            template_id: row.stack.template_id.clone(),
9487            stack_quantity: 1,
9488            quantity: 1,
9489            options,
9490            filter: String::new(),
9491            filter_focused: false,
9492        });
9493        self.state.move_picker_index = 0;
9494        self.state.show_move_picker = true;
9495        self.state.show_destroy_picker = false;
9496        self.state.destroy_confirm_pending = false;
9497        self.state.destroy_picker = None;
9498        Ok(())
9499    }
9500
9501    pub fn close_move_picker(&mut self) {
9502        self.state.show_move_picker = false;
9503        self.state.move_picker = None;
9504    }
9505
9506    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
9507        self.state.move_picker_adjust_quantity(delta);
9508    }
9509
9510    pub fn move_picker_set_quantity_max(&mut self) {
9511        self.state.move_picker_set_quantity_max();
9512    }
9513
9514    pub fn move_picker_set_quantity_min(&mut self) {
9515        self.state.move_picker_set_quantity_min();
9516    }
9517
9518    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
9519        self.state.destroy_picker_adjust_quantity(delta);
9520    }
9521
9522    pub fn destroy_picker_set_quantity_max(&mut self) {
9523        self.state.destroy_picker_set_quantity_max();
9524    }
9525
9526    pub fn destroy_picker_set_quantity_min(&mut self) {
9527        self.state.destroy_picker_set_quantity_min();
9528    }
9529
9530    async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
9531        let Some(picker) = self.state.move_picker.clone() else {
9532            self.close_move_picker();
9533            return Ok(());
9534        };
9535        let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
9536            self.close_move_picker();
9537            return Ok(());
9538        };
9539        match option.kind {
9540            MoveOptionKind::Cancel => {
9541                self.close_move_picker();
9542            }
9543            MoveOptionKind::Use => {
9544                self.close_move_picker();
9545                self.use_item(&picker.template_id).await?;
9546            }
9547            MoveOptionKind::GrantApply => {
9548                self.close_move_picker();
9549                self.open_grant_target_picker()?;
9550            }
9551            MoveOptionKind::SellPlotToCrown { plot_id } => {
9552                self.close_move_picker();
9553                self.confirm_sell_plot_to_crown(plot_id).await?;
9554            }
9555            MoveOptionKind::RelocatePlaced { container_id } => {
9556                self.close_move_picker();
9557                self.state.show_inventory_menu = false;
9558                self.begin_relocate_container(&container_id)?;
9559            }
9560            MoveOptionKind::Drop => {
9561                self.close_move_picker();
9562                if self
9563                    .state
9564                    .hand_equipped_instance_ids()
9565                    .contains(&picker.item_instance_id)
9566                {
9567                    anyhow::bail!("unequip that item first");
9568                }
9569                if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
9570                    if self.state.deed_bound(&stack) {
9571                        anyhow::bail!(
9572                            "cannot drop a property deed — store it or trade it to another player"
9573                        );
9574                    }
9575                    if self.state.key_drop_blocked(&stack) {
9576                        anyhow::bail!("cannot drop the key while its chest is locked");
9577                    }
9578                }
9579                self.drop_item(picker.item_instance_id, picker.from).await?;
9580                self.state
9581                    .push_log(format!("Dropped {}", picker.item_label));
9582            }
9583            MoveOptionKind::PickupPlaced {
9584                container_id,
9585                nest_location,
9586                nest_parent_instance_id,
9587            } => {
9588                self.close_move_picker();
9589                self.pickup_container(container_id.clone()).await?;
9590                let nest_into_bag = nest_parent_instance_id.is_some()
9591                    || !matches!(nest_location, flatland_protocol::InventoryLocation::Root);
9592                if nest_into_bag {
9593                    self.move_item(
9594                        picker.item_instance_id,
9595                        flatland_protocol::InventoryLocation::Root,
9596                        nest_location,
9597                        nest_parent_instance_id,
9598                        None,
9599                    )
9600                    .await?;
9601                    self.state
9602                        .push_log(format!("Picked up {} into bag", picker.item_label));
9603                } else {
9604                    self.state
9605                        .push_log(format!("Picked up {}", picker.item_label));
9606                }
9607            }
9608            MoveOptionKind::Move {
9609                location,
9610                parent_instance_id,
9611            } => {
9612                self.close_move_picker();
9613                let qty = if picker.quantity >= picker.stack_quantity {
9614                    None
9615                } else {
9616                    Some(picker.quantity)
9617                };
9618                self.move_item(
9619                    picker.item_instance_id,
9620                    picker.from,
9621                    location,
9622                    parent_instance_id,
9623                    qty,
9624                )
9625                .await?;
9626                let moved = qty.unwrap_or(picker.stack_quantity);
9627                if moved >= picker.stack_quantity {
9628                    self.state.push_log(format!("Moved {}", picker.item_label));
9629                } else {
9630                    self.state.push_log(format!(
9631                        "Moved {} ×{} of {}",
9632                        picker.item_label, moved, picker.stack_quantity
9633                    ));
9634                }
9635            }
9636        }
9637        Ok(())
9638    }
9639
9640    /// `d`: place furniture (kiln, chest, bed) at your feet, or drop other items
9641    /// as ground loot. Placeable tools must not become loot piles — companions
9642    /// would vacuum them and they'd look "gone".
9643    pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
9644        let Some(row) = self.state.inventory_selected_row() else {
9645            anyhow::bail!("inventory empty");
9646        };
9647        if row.is_equip_shell {
9648            anyhow::bail!("unequip the bag first (Enter), then drop from your person");
9649        }
9650        if row.is_chest_shell {
9651            anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
9652        }
9653        let Some(inst) = row.stack.item_instance_id else {
9654            anyhow::bail!("item has no instance id");
9655        };
9656        if self.state.hand_equipped_instance_ids().contains(&inst) {
9657            anyhow::bail!("unequip that item first");
9658        }
9659        if self.state.deed_bound(&row.stack) {
9660            anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
9661        }
9662        if self.state.key_drop_blocked(&row.stack) {
9663            anyhow::bail!("cannot drop the key while its chest is locked");
9664        }
9665        let label = row
9666            .stack
9667            .display_name
9668            .clone()
9669            .unwrap_or_else(|| row.stack.template_id.clone());
9670        let placeable = row.stack.world_placeable == Some(true)
9671            || row.from == flatland_protocol::InventoryLocation::Root
9672                && matches!(
9673                    self.state.inventory_item_category(&row.stack.template_id).as_deref(),
9674                    Some("lodging")
9675                );
9676        if placeable && row.from == flatland_protocol::InventoryLocation::Root {
9677            self.place_container(inst).await?;
9678            self.state.push_log(format!("Placed {label}"));
9679            return Ok(());
9680        }
9681        self.drop_item(inst, row.from).await?;
9682        self.state.push_log(format!("Dropped {label}"));
9683        Ok(())
9684    }
9685
9686    pub async fn drop_item(
9687        &mut self,
9688        item_instance_id: uuid::Uuid,
9689        from: flatland_protocol::InventoryLocation,
9690    ) -> anyhow::Result<()> {
9691        self.seq += 1;
9692        self.session
9693            .submit_intent(Intent::DropItem {
9694                entity_id: self.state.entity_id,
9695                item_instance_id,
9696                from,
9697                seq: self.seq,
9698            })
9699            .await?;
9700        self.state.intents_sent += 1;
9701        Ok(())
9702    }
9703
9704    /// `x`: open permanent-delete picker for the selected item (quantity + confirm).
9705    pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
9706        let Some(row) = self.state.inventory_selected_row() else {
9707            anyhow::bail!("inventory empty");
9708        };
9709        if row.is_equip_shell {
9710            anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
9711        }
9712        if row.is_chest_shell {
9713            anyhow::bail!("can't destroy a placed chest from the inventory list");
9714        }
9715        let Some(instance_id) = row.stack.item_instance_id else {
9716            anyhow::bail!("item has no instance id");
9717        };
9718        if self
9719            .state
9720            .hand_equipped_instance_ids()
9721            .contains(&instance_id)
9722        {
9723            anyhow::bail!("unequip that item first");
9724        }
9725        if self.state.deed_bound(&row.stack) {
9726            anyhow::bail!(
9727                "cannot destroy a property deed — store it or trade it to another player"
9728            );
9729        }
9730        if self.state.key_drop_blocked(&row.stack) {
9731            anyhow::bail!("cannot destroy the key while its chest is locked");
9732        }
9733        let item_label = row
9734            .stack
9735            .display_name
9736            .clone()
9737            .unwrap_or_else(|| row.stack.template_id.clone());
9738        self.state.destroy_picker = Some(DestroyPicker {
9739            item_instance_id: instance_id,
9740            from: row.from,
9741            item_label,
9742            stack_quantity: row.stack.quantity,
9743            quantity: row.stack.quantity,
9744        });
9745        self.state.destroy_confirm_pending = false;
9746        self.state.show_destroy_picker = true;
9747        self.state.show_move_picker = false;
9748        self.state.move_picker = None;
9749        Ok(())
9750    }
9751
9752    pub fn close_destroy_picker(&mut self) {
9753        self.state.show_destroy_picker = false;
9754        self.state.destroy_confirm_pending = false;
9755        self.state.destroy_picker = None;
9756    }
9757
9758    pub fn cancel_destroy_confirm(&mut self) {
9759        self.state.destroy_confirm_pending = false;
9760    }
9761
9762    pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
9763        if self.state.destroy_picker.is_none() {
9764            self.close_destroy_picker();
9765            return Ok(());
9766        }
9767        self.state.destroy_confirm_pending = true;
9768        Ok(())
9769    }
9770
9771    pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
9772        let Some(picker) = self.state.destroy_picker.clone() else {
9773            self.close_destroy_picker();
9774            return Ok(());
9775        };
9776        let qty = if picker.quantity >= picker.stack_quantity {
9777            None
9778        } else {
9779            Some(picker.quantity)
9780        };
9781        self.destroy_item(picker.item_instance_id, picker.from, qty)
9782            .await?;
9783        let destroyed = qty.unwrap_or(picker.stack_quantity);
9784        if destroyed >= picker.stack_quantity {
9785            self.state
9786                .push_log(format!("Destroyed {}", picker.item_label));
9787        } else {
9788            self.state.push_log(format!(
9789                "Destroyed {} ×{} of {}",
9790                picker.item_label, destroyed, picker.stack_quantity
9791            ));
9792        }
9793        self.close_destroy_picker();
9794        Ok(())
9795    }
9796
9797    pub async fn destroy_item(
9798        &mut self,
9799        item_instance_id: uuid::Uuid,
9800        from: flatland_protocol::InventoryLocation,
9801        quantity: Option<u32>,
9802    ) -> anyhow::Result<()> {
9803        self.seq += 1;
9804        self.session
9805            .submit_intent(Intent::DestroyItem {
9806                entity_id: self.state.entity_id,
9807                item_instance_id,
9808                from,
9809                quantity,
9810                seq: self.seq,
9811            })
9812            .await?;
9813        self.state.intents_sent += 1;
9814        Ok(())
9815    }
9816
9817    /// `l`: lock/unlock a placed chest — selected chest in inventory UI, else nearest.
9818    pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
9819        if let Some(row) = self.state.inventory_selected_row() {
9820            if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
9821                return self.toggle_placed_chest_lock(container_id).await;
9822            }
9823        }
9824        self.toggle_nearby_chest_lock().await
9825    }
9826
9827    pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
9828        let chest = self
9829            .state
9830            .placed_containers
9831            .iter()
9832            .find(|c| c.id == container_id)
9833            .cloned()
9834            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
9835        let (px, py) = self.state.player_position();
9836        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
9837            anyhow::bail!("too far from {}", chest.display_name);
9838        }
9839        if !chest.accessible && chest.locked {
9840            anyhow::bail!(
9841                "need the matching key for {} (each crafted chest has its own key)",
9842                chest.display_name
9843            );
9844        }
9845        let lock = !chest.locked;
9846        self.set_container_locked(
9847            flatland_protocol::InventoryLocation::Placed {
9848                container_id: chest.id.clone(),
9849            },
9850            lock,
9851        )
9852        .await?;
9853        self.state.push_log(if lock {
9854            format!("Locked {}", chest.display_name)
9855        } else {
9856            format!("Unlocked {}", chest.display_name)
9857        });
9858        Ok(())
9859    }
9860
9861    /// `l` outside inventory: lock/unlock the nearest placed chest (within `CONTAINER_RANGE_M`).
9862    pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
9863        let chest = self
9864            .state
9865            .nearest_placed_container(CONTAINER_RANGE_M)
9866            .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
9867        self.toggle_placed_chest_lock(&chest.id).await
9868    }
9869
9870    pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
9871        self.equip_mainhand(None).await
9872    }
9873
9874    pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
9875        if !self.state.is_alive() {
9876            anyhow::bail!("you are dead");
9877        }
9878        self.seq += 1;
9879        self.session
9880            .submit_intent(Intent::EquipOffhand {
9881                entity_id: self.state.entity_id,
9882                template_id,
9883                instance_id: None,
9884                seq: self.seq,
9885            })
9886            .await?;
9887        self.state.intents_sent += 1;
9888        Ok(())
9889    }
9890
9891    pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
9892        self.equip_offhand(None).await
9893    }
9894
9895    pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
9896        let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
9897        for slot in slots {
9898            self.equip_worn(slot, None).await?;
9899        }
9900        Ok(())
9901    }
9902
9903    pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
9904        let (px, py) = self.state.player_position();
9905        let in_range: Vec<_> = self
9906            .state
9907            .placed_containers
9908            .iter()
9909            .filter(|c| self.state.placed_container_in_current_space(c))
9910            .filter(|c| (c.x - px).hypot(c.y - py) <= 2.0)
9911            .collect();
9912        let nearest_free = in_range
9913            .iter()
9914            .copied()
9915            .filter(|c| !self.state.lodging_is_occupied(&c.id))
9916            .min_by(|a, b| {
9917                let da = (a.x - px).hypot(a.y - py);
9918                let db = (b.x - px).hypot(b.y - py);
9919                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
9920            })
9921            .cloned();
9922        if let Some(chest) = nearest_free {
9923            return self.pickup_container(chest.id).await;
9924        }
9925        if in_range
9926            .iter()
9927            .any(|c| self.state.lodging_is_occupied(&c.id))
9928        {
9929            anyhow::bail!("dismiss or reassign workers before picking up lodging");
9930        }
9931        if in_range.is_empty() {
9932            anyhow::bail!("no chest nearby");
9933        }
9934        anyhow::bail!("too far from chest");
9935    }
9936
9937    pub async fn equip_worn(
9938        &mut self,
9939        slot: BodySlot,
9940        instance_id: Option<uuid::Uuid>,
9941    ) -> anyhow::Result<()> {
9942        self.seq += 1;
9943        self.session
9944            .submit_intent(Intent::EquipWorn {
9945                entity_id: self.state.entity_id,
9946                slot,
9947                instance_id,
9948                seq: self.seq,
9949            })
9950            .await?;
9951        self.state.intents_sent += 1;
9952        Ok(())
9953    }
9954
9955    pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
9956        self.seq += 1;
9957        self.session
9958            .submit_intent(Intent::PlaceContainer {
9959                entity_id: self.state.entity_id,
9960                item_instance_id,
9961                seq: self.seq,
9962            })
9963            .await?;
9964        self.state.intents_sent += 1;
9965        Ok(())
9966    }
9967
9968    pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
9969        self.seq += 1;
9970        self.session
9971            .submit_intent(Intent::PickupContainer {
9972                entity_id: self.state.entity_id,
9973                container_id,
9974                seq: self.seq,
9975            })
9976            .await?;
9977        self.state.intents_sent += 1;
9978        Ok(())
9979    }
9980
9981    pub async fn move_item(
9982        &mut self,
9983        item_instance_id: uuid::Uuid,
9984        from: flatland_protocol::InventoryLocation,
9985        to: flatland_protocol::InventoryLocation,
9986        to_parent_instance_id: Option<uuid::Uuid>,
9987        quantity: Option<u32>,
9988    ) -> anyhow::Result<()> {
9989        self.seq += 1;
9990        self.session
9991            .submit_intent(Intent::MoveItem {
9992                entity_id: self.state.entity_id,
9993                item_instance_id,
9994                from,
9995                to,
9996                to_parent_instance_id,
9997                quantity,
9998                seq: self.seq,
9999            })
10000            .await?;
10001        self.state.intents_sent += 1;
10002        Ok(())
10003    }
10004
10005    pub async fn set_container_locked(
10006        &mut self,
10007        location: flatland_protocol::InventoryLocation,
10008        locked: bool,
10009    ) -> anyhow::Result<()> {
10010        self.seq += 1;
10011        self.session
10012            .submit_intent(Intent::SetContainerLocked {
10013                entity_id: self.state.entity_id,
10014                location,
10015                locked,
10016                seq: self.seq,
10017            })
10018            .await?;
10019        self.state.intents_sent += 1;
10020        Ok(())
10021    }
10022
10023    pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
10024        if !self.state.is_alive() {
10025            anyhow::bail!("you are dead");
10026        }
10027        self.seq += 1;
10028        self.session
10029            .submit_intent(Intent::Use {
10030                entity_id: self.state.entity_id,
10031                template_id: template_id.to_string(),
10032                seq: self.seq,
10033            })
10034            .await?;
10035        self.state.intents_sent += 1;
10036        Ok(())
10037    }
10038
10039    /// Bind a grant consumable onto a specific item instance (unique gear status).
10040    pub async fn use_grant(
10041        &mut self,
10042        grant_instance_id: uuid::Uuid,
10043        target_instance_id: uuid::Uuid,
10044    ) -> anyhow::Result<()> {
10045        if !self.state.is_alive() {
10046            anyhow::bail!("you are dead");
10047        }
10048        self.seq += 1;
10049        self.session
10050            .submit_intent(Intent::UseGrant {
10051                entity_id: self.state.entity_id,
10052                grant_instance_id,
10053                target_instance_id,
10054                seq: self.seq,
10055            })
10056            .await?;
10057        self.state.intents_sent += 1;
10058        Ok(())
10059    }
10060
10061    pub fn open_craft_menu(&mut self) {
10062        self.state.show_craft_menu = true;
10063        self.state.show_shop_menu = false;
10064        self.state.shop_catalog = None;
10065        self.state.show_stats = false;
10066        self.state.show_inventory_menu = false;
10067        self.state.reload_craft_prefs();
10068        self.state.craft_tab = CraftTab::Ready;
10069        self.state.craft_filter.clear();
10070        self.state.craft_filter_focused = false;
10071        self.state.craft_menu_index = 0;
10072        self.state.clamp_craft_menu_index();
10073        self.state.craft_batch_quantity = 1;
10074        self.state.clamp_craft_batch_quantity();
10075    }
10076
10077    pub fn close_craft_menu(&mut self) {
10078        self.state.show_craft_menu = false;
10079        self.state.craft_filter_focused = false;
10080    }
10081
10082    pub fn toggle_keychain_menu(&mut self) {
10083        if self.state.show_keychain_menu {
10084            self.close_keychain_menu();
10085        } else {
10086            self.state.show_keychain_menu = true;
10087            self.state.show_craft_menu = false;
10088            self.state.show_shop_menu = false;
10089            self.state.show_inventory_menu = false;
10090            let n = self.state.keychain_entries().len();
10091            if n == 0 {
10092                self.state.keychain_menu_index = 0;
10093            } else {
10094                self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
10095            }
10096        }
10097    }
10098
10099    pub fn close_keychain_menu(&mut self) {
10100        self.state.show_keychain_menu = false;
10101    }
10102
10103    pub fn keychain_menu_move(&mut self, delta: i32) {
10104        let n = self.state.keychain_entries().len();
10105        if n == 0 {
10106            self.state.keychain_menu_index = 0;
10107            return;
10108        }
10109        let idx = self.state.keychain_menu_index as i32 + delta;
10110        self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
10111    }
10112
10113    pub fn keychain_menu_page(&mut self, pages: i32) {
10114        let n = self.state.keychain_entries().len();
10115        self.state.keychain_menu_index = page_list_index(self.state.keychain_menu_index, pages, n);
10116    }
10117
10118    pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
10119        if !self.state.is_alive() {
10120            anyhow::bail!("you are dead");
10121        }
10122        let entries = self.state.keychain_entries();
10123        let Some(entry) = entries.get(self.state.keychain_menu_index) else {
10124            anyhow::bail!("nothing selected");
10125        };
10126        let Some(instance_id) = entry.stack.item_instance_id else {
10127            anyhow::bail!("key has no instance id");
10128        };
10129        if entry.stowed {
10130            self.move_item(
10131                instance_id,
10132                flatland_protocol::InventoryLocation::Keychain,
10133                flatland_protocol::InventoryLocation::Root,
10134                None,
10135                Some(1),
10136            )
10137            .await
10138        } else {
10139            self.move_item(
10140                instance_id,
10141                flatland_protocol::InventoryLocation::Root,
10142                flatland_protocol::InventoryLocation::Keychain,
10143                None,
10144                Some(1),
10145            )
10146            .await
10147        }
10148    }
10149
10150    pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
10151        let npc_id = self.state.shop_catalog.as_ref().map(|c| c.npc_id.clone());
10152        self.state.show_shop_menu = false;
10153        self.state.shop_catalog = None;
10154        self.state.clear_shop_trade_log();
10155        if let Some(npc_id) = npc_id {
10156            self.seq += 1;
10157            self.session
10158                .submit_intent(Intent::ShopClose {
10159                    entity_id: self.state.entity_id,
10160                    npc_id,
10161                    seq: self.seq,
10162                })
10163                .await?;
10164            self.state.intents_sent += 1;
10165        }
10166        Ok(())
10167    }
10168
10169    pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
10170        let Some(panel) = self.state.bank_panel.clone() else {
10171            return Ok(());
10172        };
10173        self.seq += 1;
10174        self.session
10175            .submit_intent(Intent::BankDeposit {
10176                entity_id: self.state.entity_id,
10177                npc_id: panel.npc_id,
10178                amount_copper,
10179                seq: self.seq,
10180            })
10181            .await?;
10182        self.state.intents_sent += 1;
10183        Ok(())
10184    }
10185
10186    pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
10187        let Some(panel) = self.state.bank_panel.clone() else {
10188            return Ok(());
10189        };
10190        self.seq += 1;
10191        self.session
10192            .submit_intent(Intent::BankWithdraw {
10193                entity_id: self.state.entity_id,
10194                npc_id: panel.npc_id,
10195                amount_copper,
10196                seq: self.seq,
10197            })
10198            .await?;
10199        self.state.intents_sent += 1;
10200        Ok(())
10201    }
10202
10203    pub async fn bank_transfer(
10204        &mut self,
10205        to_character_id: Option<uuid::Uuid>,
10206        to_name: String,
10207        amount_copper: u64,
10208    ) -> anyhow::Result<()> {
10209        let Some(panel) = self.state.bank_panel.clone() else {
10210            return Ok(());
10211        };
10212        self.seq += 1;
10213        self.session
10214            .submit_intent(Intent::BankTransfer {
10215                entity_id: self.state.entity_id,
10216                npc_id: panel.npc_id,
10217                to_character_id,
10218                to_name,
10219                amount_copper,
10220                seq: self.seq,
10221            })
10222            .await?;
10223        self.state.intents_sent += 1;
10224        Ok(())
10225    }
10226
10227    pub fn bank_menu_move(&mut self, delta: i32) {
10228        let n = self.state.bank_menu_options().len();
10229        if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
10230            return;
10231        }
10232        let idx = self.state.bank_menu_index as i32;
10233        self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10234    }
10235
10236    pub fn storage_menu_move(&mut self, delta: i32) {
10237        let n = self.state.storage_menu_options().len();
10238        if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
10239            return;
10240        }
10241        let idx = self.state.storage_menu_index as i32;
10242        self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
10243    }
10244
10245    pub fn storage_pick_move(&mut self, delta: i32) {
10246        let n = match &self.state.storage_ui_mode {
10247            StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
10248            StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
10249                self.state.storage_vault_options().len()
10250            }
10251            StorageUiMode::Menu
10252            | StorageUiMode::StoreAmount { .. }
10253            | StorageUiMode::TakeAmount { .. }
10254            | StorageUiMode::ShipAmount { .. } => 0,
10255        };
10256        if n == 0 {
10257            return;
10258        }
10259        match &mut self.state.storage_ui_mode {
10260            StorageUiMode::StorePick { index }
10261            | StorageUiMode::TakePick { index }
10262            | StorageUiMode::ShipPick { index, .. } => {
10263                *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
10264            }
10265            StorageUiMode::Menu
10266            | StorageUiMode::StoreAmount { .. }
10267            | StorageUiMode::TakeAmount { .. }
10268            | StorageUiMode::ShipAmount { .. } => {}
10269        }
10270    }
10271
10272    pub fn storage_ui_back(&mut self) {
10273        self.state.storage_ui_mode = match &self.state.storage_ui_mode {
10274            StorageUiMode::StoreAmount { pick_index, .. } => {
10275                StorageUiMode::StorePick { index: *pick_index }
10276            }
10277            StorageUiMode::TakeAmount { pick_index, .. } => {
10278                StorageUiMode::TakePick { index: *pick_index }
10279            }
10280            StorageUiMode::ShipAmount {
10281                dest_building_id,
10282                dest_label,
10283                pick_index,
10284                ..
10285            } => StorageUiMode::ShipPick {
10286                dest_building_id: dest_building_id.clone(),
10287                dest_label: dest_label.clone(),
10288                index: *pick_index,
10289            },
10290            StorageUiMode::StorePick { .. }
10291            | StorageUiMode::TakePick { .. }
10292            | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
10293            StorageUiMode::Menu => StorageUiMode::Menu,
10294        };
10295    }
10296
10297    pub fn storage_amount_append_char(&mut self, c: char) {
10298        match &mut self.state.storage_ui_mode {
10299            StorageUiMode::StoreAmount { input, .. }
10300            | StorageUiMode::TakeAmount { input, .. }
10301            | StorageUiMode::ShipAmount { input, .. } => {
10302                if c.is_ascii_digit() && input.len() < 8 {
10303                    input.push(c);
10304                }
10305            }
10306            _ => {}
10307        }
10308    }
10309
10310    pub fn storage_amount_backspace(&mut self) {
10311        match &mut self.state.storage_ui_mode {
10312            StorageUiMode::StoreAmount { input, .. }
10313            | StorageUiMode::TakeAmount { input, .. }
10314            | StorageUiMode::ShipAmount { input, .. } => {
10315                input.pop();
10316            }
10317            _ => {}
10318        }
10319    }
10320
10321    pub fn storage_ui_typing(&self) -> bool {
10322        matches!(
10323            self.state.storage_ui_mode,
10324            StorageUiMode::StoreAmount { .. }
10325                | StorageUiMode::TakeAmount { .. }
10326                | StorageUiMode::ShipAmount { .. }
10327        )
10328    }
10329
10330    pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
10331        match self.state.storage_ui_mode.clone() {
10332            StorageUiMode::Menu => {
10333                let index = self.state.storage_menu_index;
10334                match index {
10335                    0 => {
10336                        let opts = self.state.storage_store_options();
10337                        if opts.is_empty() {
10338                            self.state.push_log("Nothing loose to store.");
10339                            return Ok(());
10340                        }
10341                        self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
10342                    }
10343                    1 => {
10344                        let opts = self.state.storage_vault_options();
10345                        if opts.is_empty() {
10346                            self.state.push_log("Vault is empty.");
10347                            return Ok(());
10348                        }
10349                        self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
10350                    }
10351                    n => {
10352                        let dest = self
10353                            .state
10354                            .storage_panel
10355                            .as_ref()
10356                            .and_then(|p| p.ship_destinations.get(n - 2))
10357                            .cloned();
10358                        let Some(dest) = dest else {
10359                            return Ok(());
10360                        };
10361                        let opts = self.state.storage_vault_options();
10362                        if opts.is_empty() {
10363                            self.state.push_log("Vault is empty — nothing to ship.");
10364                            return Ok(());
10365                        }
10366                        self.state.storage_ui_mode = StorageUiMode::ShipPick {
10367                            dest_building_id: dest.building_id,
10368                            dest_label: dest.label,
10369                            index: 0,
10370                        };
10371                    }
10372                }
10373            }
10374            StorageUiMode::StorePick { index } => {
10375                let opts = self.state.storage_store_options();
10376                let Some(opt) = opts.get(index) else {
10377                    self.state.push_log("Nothing loose to store.");
10378                    self.state.storage_ui_mode = StorageUiMode::Menu;
10379                    return Ok(());
10380                };
10381                self.state.storage_ui_mode = StorageUiMode::StoreAmount {
10382                    pick_index: index,
10383                    item_instance_id: opt.item_instance_id,
10384                    label: opt.label.clone(),
10385                    max_qty: opt.quantity.max(1),
10386                    input: String::new(),
10387                };
10388            }
10389            StorageUiMode::TakePick { index } => {
10390                let opts = self.state.storage_vault_options();
10391                let Some(opt) = opts.get(index) else {
10392                    self.state.push_log("Vault is empty.");
10393                    self.state.storage_ui_mode = StorageUiMode::Menu;
10394                    return Ok(());
10395                };
10396                self.state.storage_ui_mode = StorageUiMode::TakeAmount {
10397                    pick_index: index,
10398                    item_instance_id: opt.item_instance_id,
10399                    label: opt.label.clone(),
10400                    max_qty: opt.quantity.max(1),
10401                    input: String::new(),
10402                };
10403            }
10404            StorageUiMode::ShipPick {
10405                dest_building_id,
10406                dest_label,
10407                index,
10408            } => {
10409                let opts = self.state.storage_vault_options();
10410                let Some(opt) = opts.get(index) else {
10411                    self.state.push_log("Vault is empty — nothing to ship.");
10412                    self.state.storage_ui_mode = StorageUiMode::Menu;
10413                    return Ok(());
10414                };
10415                self.state.storage_ui_mode = StorageUiMode::ShipAmount {
10416                    dest_building_id,
10417                    dest_label,
10418                    pick_index: index,
10419                    item_instance_id: opt.item_instance_id,
10420                    label: opt.label.clone(),
10421                    max_qty: opt.quantity.max(1),
10422                    input: String::new(),
10423                };
10424            }
10425            StorageUiMode::StoreAmount {
10426                item_instance_id,
10427                max_qty,
10428                input,
10429                ..
10430            } => {
10431                let Some(qty) = parse_storage_quantity(&input) else {
10432                    self.state.push_log("Enter a quantity (blank or 0 = all).");
10433                    return Ok(());
10434                };
10435                let qty = qty.map(|n| n.min(max_qty).max(1));
10436                self.storage_store(item_instance_id, qty).await?;
10437                self.state.storage_ui_mode = StorageUiMode::Menu;
10438            }
10439            StorageUiMode::TakeAmount {
10440                item_instance_id,
10441                max_qty,
10442                input,
10443                ..
10444            } => {
10445                let Some(qty) = parse_storage_quantity(&input) else {
10446                    self.state.push_log("Enter a quantity (blank or 0 = all).");
10447                    return Ok(());
10448                };
10449                let qty = qty.map(|n| n.min(max_qty).max(1));
10450                self.storage_take(item_instance_id, qty).await?;
10451                self.state.storage_ui_mode = StorageUiMode::Menu;
10452            }
10453            StorageUiMode::ShipAmount {
10454                dest_building_id,
10455                item_instance_id,
10456                max_qty,
10457                input,
10458                ..
10459            } => {
10460                let Some(qty) = parse_storage_quantity(&input) else {
10461                    self.state.push_log("Enter a quantity (blank or 0 = all).");
10462                    return Ok(());
10463                };
10464                let qty = qty.map(|n| n.min(max_qty).max(1));
10465                self.storage_ship(dest_building_id, item_instance_id, qty)
10466                    .await?;
10467                self.state.storage_ui_mode = StorageUiMode::Menu;
10468            }
10469        }
10470        Ok(())
10471    }
10472
10473    pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
10474        match self.state.bank_ui_mode.clone() {
10475            BankUiMode::Menu => {
10476                let choice = self
10477                    .state
10478                    .bank_menu_options()
10479                    .get(self.state.bank_menu_index)
10480                    .copied()
10481                    .unwrap_or("Deposit…");
10482                match choice {
10483                    "Withdraw…" => {
10484                        self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
10485                            input: String::new(),
10486                        };
10487                    }
10488                    "Deposit all" => self.bank_deposit(0).await?,
10489                    "Withdraw all" => self.bank_withdraw(0).await?,
10490                    "Transfer…" => {
10491                        self.state.bank_ui_mode = BankUiMode::TransferName {
10492                            input: String::new(),
10493                        };
10494                    }
10495                    _ => {
10496                        self.state.bank_ui_mode = BankUiMode::DepositAmount {
10497                            input: String::new(),
10498                        };
10499                    }
10500                }
10501            }
10502            BankUiMode::DepositAmount { input } => {
10503                let Some(amount) = parse_bank_copper_amount(&input) else {
10504                    self.state
10505                        .push_log("Enter a copper amount (blank or 0 = everything on person).");
10506                    return Ok(());
10507                };
10508                self.bank_deposit(amount).await?;
10509                self.state.bank_ui_mode = BankUiMode::Menu;
10510            }
10511            BankUiMode::WithdrawAmount { input } => {
10512                let Some(amount) = parse_bank_copper_amount(&input) else {
10513                    self.state
10514                        .push_log("Enter a copper amount (blank or 0 = full ledger).");
10515                    return Ok(());
10516                };
10517                self.bank_withdraw(amount).await?;
10518                self.state.bank_ui_mode = BankUiMode::Menu;
10519            }
10520            BankUiMode::TransferName { input } => {
10521                let name = input.trim().to_string();
10522                if name.is_empty() {
10523                    self.state.push_log("Enter the recipient character name.");
10524                    return Ok(());
10525                }
10526                self.state.bank_ui_mode = BankUiMode::TransferAmount {
10527                    to_name: name,
10528                    input: String::new(),
10529                };
10530            }
10531            BankUiMode::TransferAmount { to_name, input } => {
10532                let amount: u64 = match input.trim().parse() {
10533                    Ok(v) if v > 0 => v,
10534                    _ => {
10535                        self.state
10536                            .push_log("Enter a positive copper amount to transfer.");
10537                        return Ok(());
10538                    }
10539                };
10540                self.bank_transfer(None, to_name, amount).await?;
10541                self.state.bank_ui_mode = BankUiMode::Menu;
10542            }
10543        }
10544        Ok(())
10545    }
10546
10547    pub fn bank_transfer_back(&mut self) {
10548        match &self.state.bank_ui_mode {
10549            BankUiMode::TransferAmount { to_name, .. } => {
10550                self.state.bank_ui_mode = BankUiMode::TransferName {
10551                    input: to_name.clone(),
10552                };
10553            }
10554            BankUiMode::TransferName { .. }
10555            | BankUiMode::DepositAmount { .. }
10556            | BankUiMode::WithdrawAmount { .. } => {
10557                self.state.bank_ui_mode = BankUiMode::Menu;
10558            }
10559            BankUiMode::Menu => {}
10560        }
10561    }
10562
10563    pub fn bank_transfer_append_char(&mut self, c: char) {
10564        match &mut self.state.bank_ui_mode {
10565            BankUiMode::TransferName { input } => {
10566                if input.len() < 32 && !c.is_control() {
10567                    input.push(c);
10568                }
10569            }
10570            BankUiMode::DepositAmount { input }
10571            | BankUiMode::WithdrawAmount { input }
10572            | BankUiMode::TransferAmount { input, .. } => {
10573                if c.is_ascii_digit() && input.len() < 12 {
10574                    input.push(c);
10575                }
10576            }
10577            BankUiMode::Menu => {}
10578        }
10579    }
10580
10581    pub fn bank_transfer_backspace(&mut self) {
10582        match &mut self.state.bank_ui_mode {
10583            BankUiMode::TransferName { input }
10584            | BankUiMode::DepositAmount { input }
10585            | BankUiMode::WithdrawAmount { input }
10586            | BankUiMode::TransferAmount { input, .. } => {
10587                input.pop();
10588            }
10589            BankUiMode::Menu => {}
10590        }
10591    }
10592
10593    pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
10594        let npc_id = self.state.bank_panel.as_ref().map(|p| p.npc_id.clone());
10595        self.state.clear_bank_panel();
10596        if let Some(npc_id) = npc_id {
10597            self.seq += 1;
10598            self.session
10599                .submit_intent(Intent::BankClose {
10600                    entity_id: self.state.entity_id,
10601                    npc_id,
10602                    seq: self.seq,
10603                })
10604                .await?;
10605            self.state.intents_sent += 1;
10606        }
10607        Ok(())
10608    }
10609
10610    pub async fn storage_store(
10611        &mut self,
10612        item_instance_id: uuid::Uuid,
10613        quantity: Option<u32>,
10614    ) -> anyhow::Result<()> {
10615        let Some(panel) = self.state.storage_panel.clone() else {
10616            return Ok(());
10617        };
10618        self.seq += 1;
10619        self.session
10620            .submit_intent(Intent::StorageStore {
10621                entity_id: self.state.entity_id,
10622                npc_id: panel.npc_id,
10623                item_instance_id,
10624                quantity,
10625                seq: self.seq,
10626            })
10627            .await?;
10628        self.state.intents_sent += 1;
10629        Ok(())
10630    }
10631
10632    pub async fn storage_take(
10633        &mut self,
10634        item_instance_id: uuid::Uuid,
10635        quantity: Option<u32>,
10636    ) -> anyhow::Result<()> {
10637        let Some(panel) = self.state.storage_panel.clone() else {
10638            return Ok(());
10639        };
10640        self.seq += 1;
10641        self.session
10642            .submit_intent(Intent::StorageTake {
10643                entity_id: self.state.entity_id,
10644                npc_id: panel.npc_id,
10645                item_instance_id,
10646                quantity,
10647                seq: self.seq,
10648            })
10649            .await?;
10650        self.state.intents_sent += 1;
10651        Ok(())
10652    }
10653
10654    pub async fn storage_ship(
10655        &mut self,
10656        dest_building_id: String,
10657        item_instance_id: uuid::Uuid,
10658        quantity: Option<u32>,
10659    ) -> anyhow::Result<()> {
10660        let Some(panel) = self.state.storage_panel.clone() else {
10661            return Ok(());
10662        };
10663        self.seq += 1;
10664        self.session
10665            .submit_intent(Intent::StorageShip {
10666                entity_id: self.state.entity_id,
10667                npc_id: panel.npc_id,
10668                dest_building_id,
10669                item_instance_id,
10670                quantity,
10671                seq: self.seq,
10672            })
10673            .await?;
10674        self.state.intents_sent += 1;
10675        Ok(())
10676    }
10677
10678    pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
10679        let npc_id = self.state.storage_panel.as_ref().map(|p| p.npc_id.clone());
10680        self.state.clear_storage_panel();
10681        if let Some(npc_id) = npc_id {
10682            self.seq += 1;
10683            self.session
10684                .submit_intent(Intent::StorageClose {
10685                    entity_id: self.state.entity_id,
10686                    npc_id,
10687                    seq: self.seq,
10688                })
10689                .await?;
10690            self.state.intents_sent += 1;
10691        }
10692        Ok(())
10693    }
10694
10695    pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
10696        let npc_id = self.state.market_panel.as_ref().map(|p| p.npc_id.clone());
10697        self.state.clear_market_panel();
10698        if let Some(npc_id) = npc_id {
10699            self.seq += 1;
10700            self.session
10701                .submit_intent(Intent::MarketClose {
10702                    entity_id: self.state.entity_id,
10703                    npc_id,
10704                    seq: self.seq,
10705                })
10706                .await?;
10707            self.state.intents_sent += 1;
10708        }
10709        Ok(())
10710    }
10711
10712    pub fn market_move_selection(&mut self, delta: i32) {
10713        let indices = self.state.market_filtered_listing_indices();
10714        let n = indices.len();
10715        if n == 0 {
10716            self.state.market_menu_index = 0;
10717            return;
10718        }
10719        let cur = self.state.market_menu_index as i32;
10720        self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
10721    }
10722
10723    pub fn market_page_selection(&mut self, pages: i32) {
10724        let indices = self.state.market_filtered_listing_indices();
10725        let n = indices.len();
10726        if n == 0 {
10727            self.state.market_menu_index = 0;
10728            return;
10729        }
10730        self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
10731    }
10732
10733    pub fn market_list_page(&mut self, pages: i32) {
10734        match &self.state.market_ui_mode {
10735            MarketUiMode::ListSource { index } => {
10736                let n = self.state.market_list_source_options().len();
10737                if n == 0 {
10738                    return;
10739                }
10740                let next = page_list_index(*index, pages, n);
10741                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
10742            }
10743            MarketUiMode::ListPricingMode { index, .. } => {
10744                let next = page_list_index(*index, pages, 2);
10745                if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
10746                {
10747                    *index = next;
10748                }
10749            }
10750            MarketUiMode::ListPick { source, index } => {
10751                let opts = self.state.market_list_item_options(source);
10752                let n = opts.len();
10753                if n == 0 {
10754                    return;
10755                }
10756                let next = page_list_index(*index, pages, n);
10757                self.state.market_ui_mode = MarketUiMode::ListPick {
10758                    source: source.clone(),
10759                    index: next,
10760                };
10761            }
10762            _ => {}
10763        }
10764    }
10765
10766    pub fn market_cycle_category(&mut self, delta: i32) {
10767        let groups = self.state.market_available_category_groups();
10768        // All + groups
10769        let mut labels: Vec<Option<&'static str>> = vec![None];
10770        labels.extend(groups.into_iter().map(Some));
10771        let n = labels.len() as i32;
10772        let cur = labels
10773            .iter()
10774            .position(|g| *g == self.state.market_category_filter)
10775            .unwrap_or(0) as i32;
10776        let next = (cur + delta).rem_euclid(n) as usize;
10777        self.state.market_category_filter = labels[next];
10778        self.state.market_menu_index = 0;
10779        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10780            let source = source.clone();
10781            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10782        }
10783    }
10784
10785    pub fn focus_market_filter(&mut self) {
10786        self.state.market_filter_focused = true;
10787    }
10788
10789    pub fn append_market_filter_char(&mut self, ch: char) {
10790        if !self.state.market_filter_focused {
10791            return;
10792        }
10793        if !is_list_filter_char(ch) {
10794            return;
10795        }
10796        if self.state.market_filter.len() < 48 {
10797            self.state.market_filter.push(ch);
10798            self.state.market_menu_index = 0;
10799            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10800                let source = source.clone();
10801                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10802            }
10803        }
10804    }
10805
10806    pub fn market_filter_backspace(&mut self) {
10807        if !self.state.market_filter_focused {
10808            return;
10809        }
10810        self.state.market_filter.pop();
10811        self.state.market_menu_index = 0;
10812        if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10813            let source = source.clone();
10814            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10815        }
10816    }
10817
10818    /// Clears filter text, blurs search, or returns false if already idle.
10819    pub fn clear_or_blur_market_filter(&mut self) -> bool {
10820        if self.state.market_filter_focused {
10821            if !self.state.market_filter.is_empty() {
10822                self.state.market_filter.clear();
10823                self.state.market_menu_index = 0;
10824                if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10825                    let source = source.clone();
10826                    self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10827                }
10828                return true;
10829            }
10830            self.state.market_filter_focused = false;
10831            return true;
10832        }
10833        if !self.state.market_filter.is_empty() {
10834            self.state.market_filter.clear();
10835            self.state.market_menu_index = 0;
10836            if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
10837                let source = source.clone();
10838                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10839            }
10840            return true;
10841        }
10842        false
10843    }
10844
10845    pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
10846        if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
10847            return self.market_confirm_buy(listing_id, qty).await;
10848        }
10849        let Some(panel) = self.state.market_panel.clone() else {
10850            return Ok(());
10851        };
10852        let indices = self.state.market_filtered_listing_indices();
10853        let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
10854            return Ok(());
10855        };
10856        let Some(listing) = panel.listings.get(raw_idx) else {
10857            return Ok(());
10858        };
10859        if listing.mine {
10860            self.seq += 1;
10861            self.session
10862                .submit_intent(Intent::MarketDelist {
10863                    entity_id: self.state.entity_id,
10864                    npc_id: panel.npc_id.clone(),
10865                    listing_id: listing.listing_id,
10866                    dest: flatland_protocol::GoodsLocation::Person,
10867                    seq: self.seq,
10868                })
10869                .await?;
10870            self.state.intents_sent += 1;
10871            return Ok(());
10872        }
10873        if listing.npc_price {
10874            self.state
10875                .push_log("NPC-price listings are bought by merchants only.");
10876            return Ok(());
10877        }
10878        let qty = 1u32.min(listing.quantity).max(1);
10879        let line = listing.unit_price_copper.saturating_mul(qty as u64);
10880        self.state.market_buy_confirm = Some((
10881            listing.listing_id,
10882            qty,
10883            listing.unit_price_copper,
10884            line,
10885            listing.display_name.clone(),
10886        ));
10887        Ok(())
10888    }
10889
10890    pub async fn market_confirm_buy(
10891        &mut self,
10892        listing_id: uuid::Uuid,
10893        quantity: u32,
10894    ) -> anyhow::Result<()> {
10895        let Some(panel) = self.state.market_panel.clone() else {
10896            self.state.market_buy_confirm = None;
10897            return Ok(());
10898        };
10899        self.state.market_buy_confirm = None;
10900        self.seq += 1;
10901        self.session
10902            .submit_intent(Intent::MarketBuy {
10903                entity_id: self.state.entity_id,
10904                npc_id: panel.npc_id,
10905                listing_id,
10906                quantity,
10907                dest: flatland_protocol::GoodsLocation::Person,
10908                seq: self.seq,
10909            })
10910            .await?;
10911        self.state.intents_sent += 1;
10912        Ok(())
10913    }
10914
10915    /// Begin the list wizard (`l` in market browse).
10916    pub fn market_begin_list(&mut self) {
10917        if self.state.market_panel.is_none() {
10918            return;
10919        }
10920        let sources = self.state.market_list_source_options();
10921        if sources.is_empty() {
10922            self.state.push_log("Nothing to list from.");
10923            return;
10924        }
10925        // One source (person only) → skip straight to item pick when it has stacks.
10926        if sources.len() == 1 {
10927            let (source, _) = sources[0].clone();
10928            let opts = self.state.market_list_item_options(&source);
10929            if opts.is_empty() {
10930                self.state.push_log("Nothing loose to list.");
10931                return;
10932            }
10933            self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
10934            self.state.market_buy_confirm = None;
10935            return;
10936        }
10937        self.state.market_buy_confirm = None;
10938        self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
10939    }
10940
10941    pub fn market_ui_back(&mut self) {
10942        self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
10943            MarketUiMode::Browse => MarketUiMode::Browse,
10944            MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
10945            MarketUiMode::ListPick { .. } => {
10946                if self.state.market_list_source_options().len() <= 1 {
10947                    MarketUiMode::Browse
10948                } else {
10949                    MarketUiMode::ListSource { index: 0 }
10950                }
10951            }
10952            MarketUiMode::ListAmount {
10953                source, pick_index, ..
10954            } => MarketUiMode::ListPick {
10955                source,
10956                index: pick_index,
10957            },
10958            MarketUiMode::ListPricingMode {
10959                source,
10960                item_instance_id,
10961                template_id,
10962                label,
10963                max_qty,
10964                quantity,
10965                pick_index,
10966                ..
10967            } => {
10968                let input = quantity.map(|q| q.to_string()).unwrap_or_default();
10969                MarketUiMode::ListAmount {
10970                    source,
10971                    pick_index,
10972                    item_instance_id,
10973                    template_id,
10974                    label,
10975                    max_qty,
10976                    input,
10977                }
10978            }
10979            MarketUiMode::ListPrice {
10980                source,
10981                pick_index,
10982                item_instance_id,
10983                template_id,
10984                label,
10985                max_qty,
10986                quantity,
10987                ..
10988            } => MarketUiMode::ListPricingMode {
10989                source,
10990                pick_index,
10991                item_instance_id,
10992                template_id,
10993                label,
10994                quantity,
10995                max_qty,
10996                index: 1,
10997            },
10998        };
10999    }
11000
11001    pub fn market_list_move(&mut self, delta: i32) {
11002        match &self.state.market_ui_mode {
11003            MarketUiMode::ListSource { index } => {
11004                let n = self.state.market_list_source_options().len();
11005                if n == 0 {
11006                    return;
11007                }
11008                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
11009                self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
11010            }
11011            MarketUiMode::ListPricingMode { index, .. } => {
11012                let next = (*index as i32 + delta).rem_euclid(2) as usize;
11013                if let MarketUiMode::ListPricingMode { index, .. } = &mut self.state.market_ui_mode
11014                {
11015                    *index = next;
11016                }
11017            }
11018            MarketUiMode::ListPick { source, index } => {
11019                let opts = self.state.market_list_item_options(source);
11020                let n = opts.len();
11021                if n == 0 {
11022                    return;
11023                }
11024                let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
11025                self.state.market_ui_mode = MarketUiMode::ListPick {
11026                    source: source.clone(),
11027                    index: next,
11028                };
11029            }
11030            _ => {}
11031        }
11032    }
11033
11034    pub fn market_list_amount_append_char(&mut self, c: char) {
11035        if !c.is_ascii_digit() {
11036            return;
11037        }
11038        match &mut self.state.market_ui_mode {
11039            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
11040                if input.len() < 12 {
11041                    input.push(c);
11042                }
11043            }
11044            _ => {}
11045        }
11046    }
11047
11048    pub fn market_list_amount_backspace(&mut self) {
11049        match &mut self.state.market_ui_mode {
11050            MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
11051                input.pop();
11052            }
11053            _ => {}
11054        }
11055    }
11056
11057    pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
11058        match self.state.market_ui_mode.clone() {
11059            MarketUiMode::Browse => Ok(()),
11060            MarketUiMode::ListSource { index } => {
11061                let sources = self.state.market_list_source_options();
11062                let Some((source, _)) = sources.get(index).cloned() else {
11063                    return Ok(());
11064                };
11065                let opts = self.state.market_list_item_options(&source);
11066                if opts.is_empty() {
11067                    self.state.push_log("Nothing to list from that source.");
11068                    return Ok(());
11069                }
11070                self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
11071                Ok(())
11072            }
11073            MarketUiMode::ListPick { source, index } => {
11074                let opts = self.state.market_list_item_options(&source);
11075                let Some(opt) = opts.get(index) else {
11076                    self.state.push_log("Nothing to list.");
11077                    self.state.market_ui_mode = MarketUiMode::Browse;
11078                    return Ok(());
11079                };
11080                self.state.market_ui_mode = MarketUiMode::ListAmount {
11081                    source,
11082                    pick_index: index,
11083                    item_instance_id: opt.item_instance_id,
11084                    template_id: opt.template_id.clone(),
11085                    label: opt.label.clone(),
11086                    max_qty: opt.quantity.max(1),
11087                    input: String::new(),
11088                };
11089                Ok(())
11090            }
11091            MarketUiMode::ListAmount {
11092                source,
11093                pick_index,
11094                item_instance_id,
11095                template_id,
11096                label,
11097                max_qty,
11098                input,
11099                ..
11100            } => {
11101                let Some(qty_opt) = parse_storage_quantity(&input) else {
11102                    self.state.push_log("Enter a quantity (blank = all).");
11103                    return Ok(());
11104                };
11105                if let Some(q) = qty_opt {
11106                    if q > max_qty {
11107                        self.state.push_log(format!("Only {max_qty} available."));
11108                        return Ok(());
11109                    }
11110                }
11111                self.state.market_ui_mode = MarketUiMode::ListPricingMode {
11112                    source,
11113                    pick_index,
11114                    item_instance_id,
11115                    template_id,
11116                    label,
11117                    quantity: qty_opt,
11118                    max_qty,
11119                    index: 0,
11120                };
11121                Ok(())
11122            }
11123            MarketUiMode::ListPricingMode {
11124                source,
11125                pick_index,
11126                item_instance_id,
11127                template_id,
11128                label,
11129                quantity,
11130                max_qty,
11131                index,
11132            } => {
11133                if index == 0 {
11134                    if self
11135                        .state
11136                        .npc_market_dump_unit_estimate(&template_id)
11137                        .is_none()
11138                    {
11139                        self.state
11140                            .push_log("That item has no NPC value — use a fixed price instead.");
11141                        return Ok(());
11142                    }
11143                    return self
11144                        .submit_market_list_intent(
11145                            source,
11146                            item_instance_id,
11147                            quantity,
11148                            0,
11149                            true,
11150                            &label,
11151                        )
11152                        .await;
11153                }
11154                self.state.market_ui_mode = MarketUiMode::ListPrice {
11155                    source,
11156                    pick_index,
11157                    item_instance_id,
11158                    template_id,
11159                    label,
11160                    quantity,
11161                    max_qty,
11162                    input: String::new(),
11163                };
11164                Ok(())
11165            }
11166            MarketUiMode::ListPrice {
11167                source,
11168                item_instance_id,
11169                label,
11170                quantity,
11171                input,
11172                ..
11173            } => {
11174                let price = input.trim().parse::<u64>().unwrap_or(0);
11175                if price == 0 {
11176                    self.state
11177                        .push_log("Enter a unit price of at least 1 copper.");
11178                    return Ok(());
11179                }
11180                self.submit_market_list_intent(
11181                    source,
11182                    item_instance_id,
11183                    quantity,
11184                    price,
11185                    false,
11186                    &label,
11187                )
11188                .await
11189            }
11190        }
11191    }
11192
11193    async fn submit_market_list_intent(
11194        &mut self,
11195        source: MarketListSourceKind,
11196        item_instance_id: uuid::Uuid,
11197        quantity: Option<u32>,
11198        unit_price_copper: u64,
11199        npc_price: bool,
11200        label: &str,
11201    ) -> anyhow::Result<()> {
11202        let Some(panel) = self.state.market_panel.clone() else {
11203            self.state.market_ui_mode = MarketUiMode::Browse;
11204            return Ok(());
11205        };
11206        let goods = match source {
11207            MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
11208            MarketListSourceKind::TownStorage { building_id } => {
11209                flatland_protocol::GoodsLocation::TownStorage { building_id }
11210            }
11211        };
11212        self.seq += 1;
11213        self.session
11214            .submit_intent(Intent::MarketList {
11215                entity_id: self.state.entity_id,
11216                npc_id: panel.npc_id,
11217                source: goods,
11218                item_instance_id,
11219                quantity,
11220                unit_price_copper,
11221                npc_price,
11222                seq: self.seq,
11223            })
11224            .await?;
11225        self.state.intents_sent += 1;
11226        if npc_price {
11227            self.state
11228                .push_log(format!("Listing {label} at NPC price…"));
11229        } else {
11230            self.state
11231                .push_log(format!("Listing {label} @ {unit_price_copper} cp…"));
11232        }
11233        self.state.market_ui_mode = MarketUiMode::Browse;
11234        Ok(())
11235    }
11236
11237    /// Close shop and return to the Talk/Trade verb menu when inside an NPC session.
11238    pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
11239        let return_to_verbs = self.state.npc_verb_target.is_some();
11240        self.close_shop_menu().await?;
11241        if return_to_verbs {
11242            self.state.show_npc_verb_menu = true;
11243            self.state.npc_verb_notice = None;
11244        }
11245        Ok(())
11246    }
11247
11248    pub fn shop_tab_toggle(&mut self) {
11249        self.state.shop_tab = match self.state.shop_tab {
11250            ShopTab::Buy => ShopTab::Sell,
11251            ShopTab::Sell => ShopTab::Buy,
11252        };
11253        self.state.shop_menu_index = 0;
11254        if self.state.shop_tab == ShopTab::Sell {
11255            self.state.shop_quantity_set_max();
11256        }
11257        self.state.clamp_shop_selection();
11258    }
11259
11260    pub fn shop_menu_move(&mut self, delta: i32) {
11261        self.state.shop_menu_move(delta);
11262    }
11263
11264    pub fn shop_quantity_adjust(&mut self, delta: i32) {
11265        self.state.shop_quantity_adjust(delta);
11266    }
11267
11268    pub fn shop_quantity_set_max(&mut self) {
11269        self.state.shop_quantity_set_max();
11270    }
11271
11272    pub fn shop_quantity_set_min(&mut self) {
11273        self.state.shop_quantity_set_min();
11274    }
11275
11276    pub fn toggle_quest_menu(&mut self) {
11277        self.state.show_quest_menu = !self.state.show_quest_menu;
11278        if self.state.show_quest_menu {
11279            self.state.quest_menu_index = 0;
11280            self.state.quest_withdraw_confirm = false;
11281            self.state.show_workers_menu = false;
11282        }
11283    }
11284
11285    pub fn toggle_workers_menu(&mut self) {
11286        if self.state.show_workers_menu {
11287            self.close_workers_menu_ui();
11288        } else {
11289            self.state.show_workers_menu = true;
11290            // Keep prior selection/scroll focus; only clamp if the roster shrank.
11291            if self.state.workers_menu_index >= self.state.hired_workers.len() {
11292                self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11293            }
11294            self.state.show_quest_menu = false;
11295            self.close_worker_give_picker();
11296            self.close_worker_give_target_picker();
11297            self.close_worker_take_picker();
11298            self.close_worker_teach_picker();
11299            self.cancel_worker_rename();
11300        }
11301    }
11302
11303    /// Close workers UI layers without network (caller should release attend).
11304    pub fn close_workers_menu_ui(&mut self) {
11305        self.state.show_workers_menu = false;
11306        self.cancel_worker_dismissal();
11307        self.close_worker_give_picker();
11308        self.close_worker_give_target_picker();
11309        self.close_worker_take_picker();
11310        self.close_worker_teach_picker();
11311        self.cancel_worker_rename();
11312    }
11313
11314    /// Open the workers menu focused on `instance_id` and pause that worker's job.
11315    pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
11316        let Some(idx) = self
11317            .state
11318            .hired_workers
11319            .iter()
11320            .position(|w| w.instance_id == instance_id)
11321        else {
11322            anyhow::bail!("worker not found");
11323        };
11324        let label = self.state.hired_workers[idx].label.clone();
11325        self.state.show_workers_menu = true;
11326        self.state.workers_menu_index = idx;
11327        self.state.show_quest_menu = false;
11328        self.close_worker_give_picker();
11329        self.close_worker_give_target_picker();
11330        self.close_worker_take_picker();
11331        self.close_worker_teach_picker();
11332        self.cancel_worker_rename();
11333        self.set_worker_attending(instance_id, true).await?;
11334        self.state
11335            .push_log(format!("Managing {label} — job paused while menu is open"));
11336        Ok(())
11337    }
11338
11339    /// Close workers menu and release any attend pause.
11340    pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
11341        self.close_workers_menu_ui();
11342        self.release_worker_attend().await
11343    }
11344
11345    async fn set_worker_attending(
11346        &mut self,
11347        instance_id: &str,
11348        attending: bool,
11349    ) -> anyhow::Result<()> {
11350        if attending {
11351            if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11352                return Ok(());
11353            }
11354            // Switch target: release previous first.
11355            if let Some(prev) = self.state.attending_worker_instance_id.clone() {
11356                if prev != instance_id {
11357                    self.send_attend_hired_worker(&prev, false).await?;
11358                }
11359            }
11360            self.send_attend_hired_worker(instance_id, true).await?;
11361            self.state.attending_worker_instance_id = Some(instance_id.to_string());
11362        } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
11363            self.send_attend_hired_worker(instance_id, false).await?;
11364            self.state.attending_worker_instance_id = None;
11365        }
11366        Ok(())
11367    }
11368
11369    pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
11370        let Some(id) = self.state.attending_worker_instance_id.take() else {
11371            return Ok(());
11372        };
11373        self.send_attend_hired_worker(&id, false).await
11374    }
11375
11376    async fn send_attend_hired_worker(
11377        &mut self,
11378        worker_instance_id: &str,
11379        attending: bool,
11380    ) -> anyhow::Result<()> {
11381        self.seq += 1;
11382        self.session
11383            .submit_intent(Intent::AttendHiredWorker {
11384                entity_id: self.state.entity_id,
11385                worker_instance_id: worker_instance_id.to_string(),
11386                attending,
11387                seq: self.seq,
11388            })
11389            .await?;
11390        self.state.intents_sent += 1;
11391        Ok(())
11392    }
11393
11394    pub fn workers_menu_move(&mut self, delta: i32) {
11395        let n = self.state.hired_workers.len();
11396        if n == 0 {
11397            return;
11398        }
11399        let idx = self.state.workers_menu_index as i32;
11400        self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
11401    }
11402
11403    pub fn toggle_workers_menu_compact(&mut self) {
11404        self.state.workers_menu_compact = !self.state.workers_menu_compact;
11405        let mut cfg = crate::client_config::ClientConfig::load();
11406        let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
11407    }
11408
11409    pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
11410        let Some(worker) = self
11411            .state
11412            .hired_workers
11413            .get(self.state.workers_menu_index)
11414            .cloned()
11415        else {
11416            anyhow::bail!("no worker selected");
11417        };
11418        self.dismiss_worker_by_id(&worker.instance_id, &worker.label)
11419            .await
11420    }
11421
11422    /// Open a confirmation dialog before permanently dismissing the selected worker.
11423    pub fn request_worker_dismissal(&mut self) -> anyhow::Result<()> {
11424        let Some(worker) = self
11425            .state
11426            .hired_workers
11427            .get(self.state.workers_menu_index)
11428            .cloned()
11429        else {
11430            anyhow::bail!("no worker selected");
11431        };
11432        self.state.worker_dismiss_confirmation = Some(WorkerDismissConfirmation {
11433            worker_instance_id: worker.instance_id,
11434            worker_label: worker.label,
11435        });
11436        Ok(())
11437    }
11438
11439    pub fn cancel_worker_dismissal(&mut self) {
11440        self.state.worker_dismiss_confirmation = None;
11441    }
11442
11443    pub async fn confirm_worker_dismissal(&mut self) -> anyhow::Result<()> {
11444        let Some(confirm) = self.state.worker_dismiss_confirmation.clone() else {
11445            return Ok(());
11446        };
11447        self.dismiss_worker_by_id(&confirm.worker_instance_id, &confirm.worker_label)
11448            .await?;
11449        self.cancel_worker_dismissal();
11450        Ok(())
11451    }
11452
11453    async fn dismiss_worker_by_id(
11454        &mut self,
11455        worker_instance_id: &str,
11456        worker_label: &str,
11457    ) -> anyhow::Result<()> {
11458        self.seq += 1;
11459        self.session
11460            .submit_intent(Intent::DismissWorker {
11461                entity_id: self.state.entity_id,
11462                worker_instance_id: worker_instance_id.to_string(),
11463                seq: self.seq,
11464            })
11465            .await?;
11466        self.state.intents_sent += 1;
11467        self.state
11468            .hired_workers
11469            .retain(|w| w.instance_id != worker_instance_id);
11470        if self.state.workers_menu_index >= self.state.hired_workers.len() {
11471            self.state.workers_menu_index = self.state.hired_workers.len().saturating_sub(1);
11472        }
11473        self.state.push_log(format!("Dismissed {worker_label}"));
11474        Ok(())
11475    }
11476
11477    pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
11478        let Some(worker) = self
11479            .state
11480            .hired_workers
11481            .get(self.state.workers_menu_index)
11482            .cloned()
11483        else {
11484            anyhow::bail!("no worker selected");
11485        };
11486        let mode = match worker.mode {
11487            flatland_protocol::WorkerModeView::Companion => "defender",
11488            flatland_protocol::WorkerModeView::Defender => "job_loop",
11489            flatland_protocol::WorkerModeView::JobLoop => "idle",
11490            flatland_protocol::WorkerModeView::Idle => "companion",
11491        };
11492        self.seq += 1;
11493        self.session
11494            .submit_intent(Intent::SetWorkerMode {
11495                entity_id: self.state.entity_id,
11496                worker_instance_id: worker.instance_id,
11497                mode: mode.into(),
11498                seq: self.seq,
11499            })
11500            .await?;
11501        self.state.intents_sent += 1;
11502        Ok(())
11503    }
11504
11505    pub async fn workers_deliver_selected_to_storage(&mut self) -> anyhow::Result<()> {
11506        let Some(worker) = self
11507            .state
11508            .hired_workers
11509            .get(self.state.workers_menu_index)
11510            .cloned()
11511        else {
11512            anyhow::bail!("no worker selected");
11513        };
11514        if !matches!(worker.mode, flatland_protocol::WorkerModeView::Companion) {
11515            anyhow::bail!("switch the worker to companion mode first");
11516        }
11517        if worker.step_label.starts_with("delivering to ")
11518            || worker.step_label == "returning to you"
11519        {
11520            anyhow::bail!("worker is already delivering to storage");
11521        }
11522        self.seq += 1;
11523        self.session
11524            .submit_intent(Intent::DeliverWorkerToNearestStorage {
11525                entity_id: self.state.entity_id,
11526                worker_instance_id: worker.instance_id.clone(),
11527                seq: self.seq,
11528            })
11529            .await?;
11530        self.state.intents_sent += 1;
11531        self.state.push_log(format!(
11532            "{} is delivering carried items to storage",
11533            worker.label
11534        ));
11535        Ok(())
11536    }
11537
11538    pub async fn workers_cancel_delivery_selected(&mut self) -> anyhow::Result<()> {
11539        let Some(worker) = self
11540            .state
11541            .hired_workers
11542            .get(self.state.workers_menu_index)
11543            .cloned()
11544        else {
11545            anyhow::bail!("no worker selected");
11546        };
11547        if !(worker.step_label.starts_with("delivering to ")
11548            || worker.step_label == "returning to you")
11549        {
11550            anyhow::bail!("worker has no active delivery");
11551        }
11552        self.seq += 1;
11553        self.session
11554            .submit_intent(Intent::CancelWorkerDelivery {
11555                entity_id: self.state.entity_id,
11556                worker_instance_id: worker.instance_id,
11557                seq: self.seq,
11558            })
11559            .await?;
11560        self.state.intents_sent += 1;
11561        self.state
11562            .push_log(format!("Canceled delivery for {}", worker.label));
11563        Ok(())
11564    }
11565
11566    pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
11567        if self.state.hired_workers.is_empty() {
11568            return self.hire_worker_laborer().await;
11569        }
11570        self.workers_toggle_mode_selected().await
11571    }
11572
11573    /// Open a nearby-worker picker for the selected inventory stack (inventory `g`).
11574    /// Always shows a chooser so you can pick Bruce vs Cookie when several are close.
11575    pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11576        let row = self
11577            .state
11578            .inventory_selected_row()
11579            .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
11580            .clone();
11581        if row.from != flatland_protocol::InventoryLocation::Root {
11582            anyhow::bail!("select a carried item to give");
11583        }
11584        let Some(instance_id) = row.stack.item_instance_id else {
11585            anyhow::bail!("that stack can't be given");
11586        };
11587        let options = self.nearby_worker_give_targets();
11588        if options.is_empty() {
11589            anyhow::bail!(
11590                "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
11591            );
11592        }
11593        let item_label = row
11594            .stack
11595            .display_name
11596            .as_deref()
11597            .unwrap_or(&row.stack.template_id)
11598            .to_string();
11599        self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
11600            item_instance_id: instance_id,
11601            item_label,
11602            quantity: None,
11603            options,
11604        });
11605        self.state.worker_give_target_picker_index = 0;
11606        self.state.show_worker_give_target_picker = true;
11607        // Let the target picker own keys (inventory would otherwise swallow ↑↓/Enter).
11608        self.state.show_inventory_menu = false;
11609        Ok(())
11610    }
11611
11612    /// Hired workers within give/take range, nearest first.
11613    pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
11614        let (px, py, _) = self.state.player_position_with_z();
11615        let mut options: Vec<WorkerGiveTargetOption> = self
11616            .state
11617            .hired_workers
11618            .iter()
11619            .filter_map(|w| {
11620                let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
11621                if dist > WORKER_GIVE_RANGE_M {
11622                    return None;
11623                }
11624                Some(WorkerGiveTargetOption {
11625                    instance_id: w.instance_id.clone(),
11626                    label: w.label.clone(),
11627                    distance_m: dist,
11628                })
11629            })
11630            .collect();
11631        options.sort_by(|a, b| {
11632            a.distance_m
11633                .partial_cmp(&b.distance_m)
11634                .unwrap_or(std::cmp::Ordering::Equal)
11635        });
11636        options
11637    }
11638
11639    pub fn close_worker_give_target_picker(&mut self) {
11640        self.state.show_worker_give_target_picker = false;
11641        self.state.worker_give_target_picker = None;
11642        self.state.worker_give_target_picker_index = 0;
11643    }
11644
11645    pub fn worker_give_target_picker_move(&mut self, delta: i32) {
11646        let Some(picker) = &self.state.worker_give_target_picker else {
11647            return;
11648        };
11649        let n = picker.options.len();
11650        if n == 0 {
11651            return;
11652        }
11653        let idx = self.state.worker_give_target_picker_index as i32;
11654        self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11655    }
11656
11657    pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
11658        let Some(picker) = self.state.worker_give_target_picker.clone() else {
11659            anyhow::bail!("give target picker not open");
11660        };
11661        let Some(opt) = picker
11662            .options
11663            .get(self.state.worker_give_target_picker_index)
11664            .cloned()
11665        else {
11666            anyhow::bail!("no worker selected");
11667        };
11668        let Some(worker) = self
11669            .state
11670            .hired_workers
11671            .iter()
11672            .find(|w| w.instance_id == opt.instance_id)
11673            .cloned()
11674        else {
11675            self.close_worker_give_target_picker();
11676            anyhow::bail!("worker no longer hired");
11677        };
11678        self.give_item_to_worker(
11679            &worker.instance_id,
11680            &worker.label,
11681            worker.x,
11682            worker.y,
11683            picker.item_instance_id,
11684            &picker.item_label,
11685            picker.quantity,
11686        )
11687        .await?;
11688        self.close_worker_give_target_picker();
11689        Ok(())
11690    }
11691
11692    /// Give the selected inventory stack to a hired worker (opens nearby-worker picker).
11693    pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
11694        self.open_worker_give_target_picker()
11695    }
11696
11697    /// Open the workers-menu give picker for the selected worker (must be in range).
11698    pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
11699        let Some(worker) = self
11700            .state
11701            .hired_workers
11702            .get(self.state.workers_menu_index)
11703            .cloned()
11704        else {
11705            anyhow::bail!("select a hired worker first");
11706        };
11707        let (px, py, _) = self.state.player_position_with_z();
11708        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11709        if dist > WORKER_GIVE_RANGE_M {
11710            anyhow::bail!(
11711                "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
11712                worker.label
11713            );
11714        }
11715        let options = self.state.giveable_inventory_options();
11716        if options.is_empty() {
11717            anyhow::bail!("nothing in inventory to give");
11718        }
11719        self.state.worker_give_picker = Some(WorkerGivePicker {
11720            worker_instance_id: worker.instance_id,
11721            worker_label: worker.label,
11722            options,
11723        });
11724        self.state.worker_give_picker_index = 0;
11725        self.state.show_worker_give_picker = true;
11726        Ok(())
11727    }
11728
11729    pub fn close_worker_give_picker(&mut self) {
11730        self.state.show_worker_give_picker = false;
11731        self.state.worker_give_picker = None;
11732        self.state.worker_give_picker_index = 0;
11733    }
11734
11735    pub fn worker_give_picker_move(&mut self, delta: i32) {
11736        let Some(picker) = &self.state.worker_give_picker else {
11737            return;
11738        };
11739        let n = picker.options.len();
11740        if n == 0 {
11741            return;
11742        }
11743        let idx = self.state.worker_give_picker_index as i32;
11744        self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11745    }
11746
11747    /// Confirm the selected row in the workers-menu give picker.
11748    pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
11749        let Some(picker) = self.state.worker_give_picker.clone() else {
11750            anyhow::bail!("give picker not open");
11751        };
11752        let Some(opt) = picker
11753            .options
11754            .get(self.state.worker_give_picker_index)
11755            .cloned()
11756        else {
11757            anyhow::bail!("no item selected");
11758        };
11759        let Some(worker) = self
11760            .state
11761            .hired_workers
11762            .iter()
11763            .find(|w| w.instance_id == picker.worker_instance_id)
11764            .cloned()
11765        else {
11766            self.close_worker_give_picker();
11767            anyhow::bail!("worker no longer hired");
11768        };
11769        self.give_item_to_worker(
11770            &worker.instance_id,
11771            &worker.label,
11772            worker.x,
11773            worker.y,
11774            opt.item_instance_id,
11775            &opt.label,
11776            None,
11777        )
11778        .await?;
11779        // Refresh options (stack may be gone / reduced) or close when empty.
11780        let options = self.state.giveable_inventory_options();
11781        if options.is_empty() {
11782            self.close_worker_give_picker();
11783        } else {
11784            self.state.worker_give_picker = Some(WorkerGivePicker {
11785                worker_instance_id: picker.worker_instance_id,
11786                worker_label: picker.worker_label,
11787                options,
11788            });
11789            if self.state.worker_give_picker_index
11790                >= self
11791                    .state
11792                    .worker_give_picker
11793                    .as_ref()
11794                    .map(|p| p.options.len())
11795                    .unwrap_or(0)
11796            {
11797                self.state.worker_give_picker_index = self
11798                    .state
11799                    .worker_give_picker
11800                    .as_ref()
11801                    .map(|p| p.options.len().saturating_sub(1))
11802                    .unwrap_or(0);
11803            }
11804        }
11805        Ok(())
11806    }
11807
11808    /// Open the workers-menu teach picker for the selected worker (must be in range).
11809    pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
11810        let Some(worker) = self
11811            .state
11812            .hired_workers
11813            .get(self.state.workers_menu_index)
11814            .cloned()
11815        else {
11816            anyhow::bail!("select a hired worker first");
11817        };
11818        let (px, py, _) = self.state.player_position_with_z();
11819        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11820        if dist > WORKER_GIVE_RANGE_M {
11821            anyhow::bail!(
11822                "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
11823                worker.label
11824            );
11825        }
11826        let options = self.state.teachable_blueprint_options(&worker);
11827        if options.is_empty() {
11828            anyhow::bail!("no recipes you know that {} still needs", worker.label);
11829        }
11830        self.state.worker_teach_picker = Some(WorkerTeachPicker {
11831            worker_instance_id: worker.instance_id,
11832            worker_label: worker.label,
11833            worker_level: worker.level,
11834            options,
11835        });
11836        self.state.worker_teach_picker_index = 0;
11837        self.state.show_worker_teach_picker = true;
11838        Ok(())
11839    }
11840
11841    pub fn close_worker_teach_picker(&mut self) {
11842        self.state.show_worker_teach_picker = false;
11843        self.state.worker_teach_picker = None;
11844        self.state.worker_teach_picker_index = 0;
11845    }
11846
11847    pub fn worker_teach_picker_move(&mut self, delta: i32) {
11848        let Some(picker) = &self.state.worker_teach_picker else {
11849            return;
11850        };
11851        let n = picker.options.len();
11852        if n == 0 {
11853            return;
11854        }
11855        let idx = self.state.worker_teach_picker_index as i32;
11856        self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
11857    }
11858
11859    pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
11860        let Some(picker) = self.state.worker_teach_picker.clone() else {
11861            anyhow::bail!("teach picker not open");
11862        };
11863        let Some(opt) = picker
11864            .options
11865            .get(self.state.worker_teach_picker_index)
11866            .cloned()
11867        else {
11868            anyhow::bail!("nothing selected");
11869        };
11870        if !opt.level_ok {
11871            anyhow::bail!(
11872                "{} needs level {} (is level {})",
11873                picker.worker_label,
11874                opt.min_level,
11875                opt.worker_level
11876            );
11877        }
11878        if !opt.can_afford {
11879            anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
11880        }
11881        let Some(worker) = self
11882            .state
11883            .hired_workers
11884            .iter()
11885            .find(|w| w.instance_id == picker.worker_instance_id)
11886            .cloned()
11887        else {
11888            anyhow::bail!("worker gone");
11889        };
11890        let (px, py, _) = self.state.player_position_with_z();
11891        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
11892        if dist > WORKER_GIVE_RANGE_M {
11893            anyhow::bail!("worker {} too far — stand next to them", worker.label);
11894        }
11895        self.seq += 1;
11896        self.session
11897            .submit_intent(Intent::TeachWorkerBlueprint {
11898                entity_id: self.state.entity_id,
11899                worker_instance_id: picker.worker_instance_id.clone(),
11900                blueprint_id: opt.blueprint_id.clone(),
11901                seq: self.seq,
11902            })
11903            .await?;
11904        self.state.intents_sent += 1;
11905        self.state.push_log(format!(
11906            "Teaching {} to {} ({} cp)",
11907            opt.label, picker.worker_label, opt.cost_copper
11908        ));
11909        self.close_worker_teach_picker();
11910        Ok(())
11911    }
11912
11913    async fn give_item_to_worker(
11914        &mut self,
11915        worker_instance_id: &str,
11916        worker_label: &str,
11917        worker_x: f32,
11918        worker_y: f32,
11919        item_instance_id: uuid::Uuid,
11920        item_label: &str,
11921        quantity: Option<u32>,
11922    ) -> anyhow::Result<()> {
11923        let (px, py, _) = self.state.player_position_with_z();
11924        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
11925        if dist > WORKER_GIVE_RANGE_M {
11926            anyhow::bail!("worker {worker_label} too far — stand next to them");
11927        }
11928        self.seq += 1;
11929        self.session
11930            .submit_intent(Intent::GiveWorkerItem {
11931                entity_id: self.state.entity_id,
11932                worker_instance_id: worker_instance_id.to_string(),
11933                item_instance_id,
11934                quantity,
11935                seq: self.seq,
11936            })
11937            .await?;
11938        self.state.intents_sent += 1;
11939        self.state
11940            .remove_carried_instance(item_instance_id, quantity);
11941        self.state
11942            .push_log(format!("Gave {item_label} to {worker_label}"));
11943        Ok(())
11944    }
11945
11946    /// Equip one item from the player's inventory on the selected worker.
11947    /// The server validates the requested hand/body slot and replaces the
11948    /// previous item into the worker's pack.
11949    pub async fn equip_item_on_worker(
11950        &mut self,
11951        worker_instance_id: &str,
11952        item_instance_id: uuid::Uuid,
11953        slot: &str,
11954    ) -> anyhow::Result<()> {
11955        let Some(worker) = self
11956            .state
11957            .hired_workers
11958            .iter()
11959            .find(|worker| worker.instance_id == worker_instance_id)
11960            .cloned()
11961        else {
11962            anyhow::bail!("worker not found");
11963        };
11964        let (px, py, _) = self.state.player_position_with_z();
11965        if (worker.x - px).hypot(worker.y - py) > WORKER_GIVE_RANGE_M {
11966            anyhow::bail!("worker {} too far — stand next to them", worker.label);
11967        }
11968        self.seq += 1;
11969        self.session
11970            .submit_intent(Intent::EquipWorkerItem {
11971                entity_id: self.state.entity_id,
11972                worker_instance_id: worker.instance_id.clone(),
11973                item_instance_id,
11974                slot: slot.to_string(),
11975                seq: self.seq,
11976            })
11977            .await?;
11978        self.state.intents_sent += 1;
11979        self.state
11980            .push_log(format!("Equipped {slot} on {}", worker.label));
11981        Ok(())
11982    }
11983
11984    /// Open the workers-menu take picker for the selected worker (must be in range).
11985    pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
11986        let Some(worker) = self
11987            .state
11988            .hired_workers
11989            .get(self.state.workers_menu_index)
11990            .cloned()
11991        else {
11992            anyhow::bail!("select a hired worker first");
11993        };
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!(
11998                "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
11999                worker.label
12000            );
12001        }
12002        let options = Self::worker_inventory_options(&worker);
12003        if options.is_empty() {
12004            anyhow::bail!("{} isn't carrying anything", worker.label);
12005        }
12006        let initial_qty = options
12007            .first()
12008            .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
12009            .unwrap_or(1);
12010        self.state.worker_take_picker = Some(WorkerTakePicker {
12011            worker_instance_id: worker.instance_id,
12012            worker_label: worker.label,
12013            options,
12014            quantity: initial_qty,
12015        });
12016        self.state.worker_take_picker_index = 0;
12017        self.state.show_worker_take_picker = true;
12018        Ok(())
12019    }
12020
12021    fn worker_inventory_options(
12022        worker: &flatland_protocol::HiredWorkerView,
12023    ) -> Vec<WorkerGiveOption> {
12024        worker
12025            .inventory
12026            .iter()
12027            .filter_map(|stack| {
12028                let item_instance_id = stack.item_instance_id?;
12029                let label = stack
12030                    .display_name
12031                    .clone()
12032                    .unwrap_or_else(|| stack.template_id.clone());
12033                let label = if stack.quantity > 1 {
12034                    format!("{label} ×{}", stack.quantity)
12035                } else {
12036                    label
12037                };
12038                Some(WorkerGiveOption {
12039                    item_instance_id,
12040                    label,
12041                    quantity: stack.quantity,
12042                    template_id: stack.template_id.clone(),
12043                })
12044            })
12045            .collect()
12046    }
12047
12048    pub fn close_worker_take_picker(&mut self) {
12049        self.state.show_worker_take_picker = false;
12050        self.state.worker_take_picker = None;
12051        self.state.worker_take_picker_index = 0;
12052    }
12053
12054    pub fn worker_take_picker_move(&mut self, delta: i32) {
12055        let Some(picker) = &self.state.worker_take_picker else {
12056            return;
12057        };
12058        let n = picker.options.len();
12059        if n == 0 {
12060            return;
12061        }
12062        let idx = self.state.worker_take_picker_index as i32;
12063        self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
12064        self.clamp_worker_take_quantity();
12065    }
12066
12067    pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
12068        let Some(picker) = &mut self.state.worker_take_picker else {
12069            return;
12070        };
12071        let max = picker
12072            .options
12073            .get(self.state.worker_take_picker_index)
12074            .map(|o| o.quantity.max(1))
12075            .unwrap_or(1);
12076        let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
12077        picker.quantity = next as u32;
12078    }
12079
12080    pub fn worker_take_picker_set_quantity_max(&mut self) {
12081        let Some(picker) = &mut self.state.worker_take_picker else {
12082            return;
12083        };
12084        let max = picker
12085            .options
12086            .get(self.state.worker_take_picker_index)
12087            .map(|o| o.quantity.max(1))
12088            .unwrap_or(1);
12089        picker.quantity = max;
12090    }
12091
12092    pub fn worker_take_picker_set_quantity_min(&mut self) {
12093        let Some(picker) = &mut self.state.worker_take_picker else {
12094            return;
12095        };
12096        picker.quantity = 1;
12097        self.clamp_worker_take_quantity();
12098    }
12099
12100    fn clamp_worker_take_quantity(&mut self) {
12101        let Some(picker) = &mut self.state.worker_take_picker else {
12102            return;
12103        };
12104        let max = picker
12105            .options
12106            .get(self.state.worker_take_picker_index)
12107            .map(|o| o.quantity.max(1))
12108            .unwrap_or(1);
12109        if picker.quantity == 0 || picker.quantity > max {
12110            picker.quantity = if max > 1 { 1 } else { max };
12111        }
12112    }
12113
12114    pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
12115        let Some(picker) = self.state.worker_take_picker.clone() else {
12116            anyhow::bail!("take picker not open");
12117        };
12118        let Some(opt) = picker
12119            .options
12120            .get(self.state.worker_take_picker_index)
12121            .cloned()
12122        else {
12123            anyhow::bail!("no item selected");
12124        };
12125        let Some(worker) = self
12126            .state
12127            .hired_workers
12128            .iter()
12129            .find(|w| w.instance_id == picker.worker_instance_id)
12130            .cloned()
12131        else {
12132            self.close_worker_take_picker();
12133            anyhow::bail!("worker no longer hired");
12134        };
12135        let qty = picker.quantity.clamp(1, opt.quantity.max(1));
12136        let intent_qty = if qty >= opt.quantity { None } else { Some(qty) };
12137        self.take_item_from_worker(
12138            &worker.instance_id,
12139            &worker.label,
12140            worker.x,
12141            worker.y,
12142            opt.item_instance_id,
12143            &opt.label,
12144            intent_qty,
12145        )
12146        .await?;
12147        // Leave the picker open — server Interaction / hired-workers sync refresh
12148        // options. Do not optimistic-strip stacks (rejects used to look like success).
12149        Ok(())
12150    }
12151
12152    async fn take_item_from_worker(
12153        &mut self,
12154        worker_instance_id: &str,
12155        worker_label: &str,
12156        worker_x: f32,
12157        worker_y: f32,
12158        item_instance_id: uuid::Uuid,
12159        item_label: &str,
12160        quantity: Option<u32>,
12161    ) -> anyhow::Result<()> {
12162        let (px, py, _) = self.state.player_position_with_z();
12163        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
12164        if dist > WORKER_GIVE_RANGE_M {
12165            anyhow::bail!("worker {worker_label} too far — stand next to them");
12166        }
12167        self.seq += 1;
12168        self.session
12169            .submit_intent(Intent::TakeWorkerItem {
12170                entity_id: self.state.entity_id,
12171                worker_instance_id: worker_instance_id.to_string(),
12172                item_instance_id,
12173                quantity,
12174                seq: self.seq,
12175            })
12176            .await?;
12177        self.state.intents_sent += 1;
12178        let qty_note = quantity.map(|q| format!(" ×{q}")).unwrap_or_default();
12179        self.state.push_log(format!(
12180            "Taking {item_label}{qty_note} from {worker_label}…"
12181        ));
12182        Ok(())
12183    }
12184
12185    pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
12186        if let Some(since) = self.state.pending_worker_hire_since {
12187            if since.elapsed() < WORKER_HIRE_PENDING_TIMEOUT {
12188                anyhow::bail!("hire request still pending — wait for the worker roster update");
12189            }
12190            self.state.pending_worker_hire_since = None;
12191        }
12192        if !self.state.has_worker_lodging() {
12193            anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
12194        }
12195        self.seq += 1;
12196        self.session
12197            .submit_intent(Intent::HireWorker {
12198                entity_id: self.state.entity_id,
12199                def_id: "worker_laborer".into(),
12200                wage_copper_per_interval: 8,
12201                lodging_container_id: None,
12202                job_yaml: None,
12203                seq: self.seq,
12204            })
12205            .await?;
12206        self.state.intents_sent += 1;
12207        self.state.pending_worker_hire_since = Some(Instant::now());
12208        Ok(())
12209    }
12210
12211    pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
12212        let Some(worker) = self
12213            .state
12214            .hired_workers
12215            .get(self.state.workers_menu_index)
12216            .cloned()
12217        else {
12218            anyhow::bail!("select a hired worker first");
12219        };
12220        let lodging = worker.lodging_container_id.clone().or_else(|| {
12221            crate::worker_route_editor::owned_lodging_container_ids(
12222                &self.state.placed_containers,
12223                self.state.character_id,
12224            )
12225            .into_iter()
12226            .next()
12227            .map(|(id, _)| id)
12228        });
12229        let label = worker.label.clone();
12230        let editor = if let Some(route) = &worker.route {
12231            crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
12232                worker.instance_id,
12233                worker.label,
12234                route,
12235                lodging,
12236            )
12237        } else {
12238            crate::worker_route_editor::WorkerRouteEditorState::new(
12239                worker.instance_id,
12240                worker.label,
12241                lodging,
12242            )
12243        };
12244        self.state.worker_route_editor = Some(editor);
12245        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12246            if let Some(collapsed) =
12247                crate::client_config::ClientConfig::load().worker_route_panel_collapsed
12248            {
12249                ed.panel_collapsed = collapsed;
12250            }
12251        }
12252        self.state.show_workers_menu = false;
12253        self.state.push_log(format!(
12254            "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
12255        ));
12256        Ok(())
12257    }
12258
12259    pub fn close_worker_route_editor(&mut self) {
12260        self.state.worker_route_editor = None;
12261    }
12262
12263    pub fn worker_route_editor_toggle_panel(&mut self) {
12264        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12265            ed.toggle_panel_collapsed();
12266            let collapsed = ed.panel_collapsed;
12267            let mut cfg = crate::client_config::ClientConfig::load();
12268            let _ = cfg.save_worker_route_panel_collapsed(collapsed);
12269        }
12270    }
12271
12272    pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
12273        let n = {
12274            let Some(ed) = self.state.worker_route_editor.as_mut() else {
12275                return;
12276            };
12277            ed.append_waypoint(x, y, z);
12278            ed.stop_count()
12279        };
12280        self.state
12281            .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
12282    }
12283
12284    // ---- picker candidate snapshots ----------------------------------
12285
12286    fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
12287        let (px, py, _) = self.state.player_position_with_z();
12288        let inside = self.state.effective_inside_building();
12289        crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
12290            &self.state.placed_containers,
12291            &self.state.buildings,
12292            self.state.character_id,
12293            px,
12294            py,
12295            &self.state.hired_workers,
12296            inside.as_deref(),
12297        )
12298    }
12299
12300    fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
12301        self.state.route_editor_node_candidates()
12302    }
12303
12304    fn re_open_harvest_picker(&mut self, index: usize, picked: std::collections::BTreeSet<String>) {
12305        use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
12306        let nodes = self.state.route_editor_node_candidates();
12307        let index = if nodes.is_empty() {
12308            ROUTE_PICKER_DONE_ROW
12309        } else {
12310            index.max(1).min(nodes.len())
12311        };
12312        self.re_open_sheet(S::HarvestPicker {
12313            index,
12314            picked,
12315            nodes,
12316        });
12317    }
12318
12319    fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
12320        let (px, py, _) = self.state.player_position_with_z();
12321        let templates = self.re_template_candidates();
12322        crate::worker_route_editor::trade_npc_candidates(
12323            &self.state.npcs,
12324            px,
12325            py,
12326            &templates,
12327        )
12328    }
12329
12330    fn re_template_candidates(&self) -> Vec<String> {
12331        let mut extra = Vec::new();
12332        if let Some(ed) = self.state.worker_route_editor.as_ref() {
12333            for stop in &ed.stops {
12334                match stop {
12335                    crate::worker_route_editor::WorkerRouteStop::DepositAt {
12336                        filter: Some(filter),
12337                        ..
12338                    } => extra.extend(filter.iter().cloned()),
12339                    crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. }
12340                    | crate::worker_route_editor::WorkerRouteStop::ListOnMarket {
12341                        template,
12342                        ..
12343                    } => {
12344                        extra.push(template.clone());
12345                    }
12346                    crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
12347                        if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint)
12348                        {
12349                            extra.push(bp.output.clone());
12350                            for input in &bp.inputs {
12351                                extra.push(input.template_id.clone());
12352                            }
12353                        }
12354                    }
12355                    crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
12356                        for it in items {
12357                            extra.push(it.template.clone());
12358                        }
12359                    }
12360                    _ => {}
12361                }
12362            }
12363            // Worker-known recipe outputs even if the player hasn't learned them yet.
12364            if let Some(worker) = self
12365                .state
12366                .hired_workers
12367                .iter()
12368                .find(|w| w.instance_id == ed.worker_instance_id)
12369            {
12370                for recipe in &worker.known_blueprint_ids {
12371                    if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
12372                        extra.push(bp.output.clone());
12373                    }
12374                }
12375                for stack in &worker.inventory {
12376                    if !stack.template_id.is_empty() && stack.quantity > 0 {
12377                        extra.push(stack.template_id.clone());
12378                    }
12379                }
12380            }
12381        }
12382        crate::worker_route_editor::route_item_template_candidates(
12383            &self.state.placed_containers,
12384            self.state.character_id,
12385            &self.state.inventory,
12386            &self.state.blueprints,
12387            if self.state.harvest_route_nodes.is_empty() {
12388                &self.state.resource_nodes
12389            } else {
12390                &self.state.harvest_route_nodes
12391            },
12392            &extra,
12393            Some(&self.state.item_catalog),
12394        )
12395    }
12396
12397    fn re_blueprint_ids(&self) -> Vec<String> {
12398        let worker_known: Option<&[String]> = self
12399            .state
12400            .worker_route_editor
12401            .as_ref()
12402            .and_then(|ed| {
12403                self.state
12404                    .hired_workers
12405                    .iter()
12406                    .find(|w| w.instance_id == ed.worker_instance_id)
12407            })
12408            .map(|w| w.known_blueprint_ids.as_slice());
12409        crate::worker_route_editor::worker_craft_blueprint_ids(&self.state.blueprints, worker_known)
12410    }
12411
12412    fn re_bed_candidates(&self) -> Vec<(String, String)> {
12413        crate::worker_route_editor::owned_lodging_container_ids(
12414            &self.state.placed_containers,
12415            self.state.character_id,
12416        )
12417    }
12418
12419    fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
12420        self.state
12421            .placed_containers
12422            .iter()
12423            .find(|c| c.id == container_id)
12424            .map(|c| c.contents.clone())
12425            .unwrap_or_default()
12426    }
12427
12428    // ---- sheet navigation (`plans/33`) --------------------------------
12429
12430    fn re_sheet_supports_filter(&self) -> bool {
12431        use crate::worker_route_editor::RouteEditorSheet as S;
12432        self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12433            matches!(
12434                ed.sheet,
12435                S::HarvestPicker { .. }
12436                    | S::SellItem { .. }
12437                    | S::MarketListItem { .. }
12438                    | S::DepositFilter { .. }
12439                    | S::WithdrawItems { .. }
12440                    | S::WithdrawContainers { .. }
12441                    | S::DepositContainers { .. }
12442                    | S::SellNpcs { .. }
12443                    | S::CraftBlueprint { .. }
12444                    | S::BedPicker { .. }
12445            )
12446        })
12447    }
12448
12449    /// Whether a sheet row is shown under the current `/` filter.
12450    pub fn re_sheet_row_visible(&self, row: usize) -> bool {
12451        use crate::worker_route_editor::{
12452            harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
12453            ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
12454        };
12455        let Some(ed) = self.state.worker_route_editor.as_ref() else {
12456            return false;
12457        };
12458        let filter = &ed.sheet_filter;
12459        match &ed.sheet {
12460            S::HarvestPicker { nodes, .. } => harvest_picker_row_matches(nodes, row, filter),
12461            S::SellItem { templates, .. } | S::MarketListItem { templates, .. } => {
12462                if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
12463                    return true;
12464                }
12465                let slot = row.saturating_sub(2);
12466                templates.get(slot).is_some_and(|t| {
12467                    let label = self.state.template_display_name(t);
12468                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12469                })
12470            }
12471            S::DepositFilter { rows, .. } => {
12472                if row >= rows.len() {
12473                    return true;
12474                }
12475                rows.get(row).is_some_and(|(t, _)| {
12476                    let label = self.state.template_display_name(t);
12477                    list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
12478                })
12479            }
12480            S::WithdrawItems { lines, .. } => {
12481                if row >= lines.len() {
12482                    return true;
12483                }
12484                lines.get(row).is_some_and(|l| {
12485                    let label = self.state.template_display_name(&l.template);
12486                    list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
12487                })
12488            }
12489            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12490                self.re_container_candidates().get(row).is_some_and(|c| {
12491                    list_filter_row_matches(
12492                        filter,
12493                        Some(c.dist),
12494                        &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
12495                    )
12496                })
12497            }
12498            S::SellNpcs { .. } => {
12499                if row == 0 {
12500                    return true;
12501                }
12502                self.re_npc_candidates().get(row - 1).is_some_and(|n| {
12503                    list_filter_row_matches(
12504                        filter,
12505                        Some(n.dist),
12506                        &[n.label.as_str(), n.id.as_str()],
12507                    )
12508                })
12509            }
12510            S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
12511                let label = self
12512                    .state
12513                    .blueprints
12514                    .iter()
12515                    .find(|b| &b.id == id)
12516                    .map(|b| {
12517                        if b.label.is_empty() {
12518                            id.as_str()
12519                        } else {
12520                            b.label.as_str()
12521                        }
12522                    })
12523                    .unwrap_or(id.as_str());
12524                list_filter_row_matches(filter, None, &[id.as_str(), label])
12525            }),
12526            S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
12527                list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
12528            }),
12529            _ => true,
12530        }
12531    }
12532
12533    fn re_sheet_clamp_index(&mut self) {
12534        let count = self.re_sheet_row_count();
12535        if count == 0 {
12536            return;
12537        }
12538        let cur = self.re_sheet_index();
12539        if self.re_sheet_row_visible(cur) {
12540            return;
12541        }
12542        for offset in 1..count {
12543            if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
12544                self.re_sheet_set_index(cur + offset);
12545                return;
12546            }
12547            if cur >= offset && self.re_sheet_row_visible(cur - offset) {
12548                self.re_sheet_set_index(cur - offset);
12549                return;
12550            }
12551        }
12552    }
12553
12554    fn re_sheet_set_index(&mut self, index: usize) {
12555        use crate::worker_route_editor::RouteEditorSheet as S;
12556        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12557            return;
12558        };
12559        match &mut ed.sheet {
12560            S::AddMenu { index: slot }
12561            | S::WaypointMenu { index: slot }
12562            | S::HarvestPicker { index: slot, .. }
12563            | S::WithdrawContainers { index: slot }
12564            | S::DepositContainers { index: slot }
12565            | S::SellNpcs { index: slot }
12566            | S::CraftBlueprint { index: slot }
12567            | S::BedPicker { index: slot }
12568            | S::FarmPlotPicker { index: slot, .. }
12569            | S::FarmPlantSeed { index: slot, .. }
12570            | S::WithdrawItems { index: slot, .. }
12571            | S::DepositFilter { index: slot, .. }
12572            | S::SellItem { index: slot, .. } | S::MarketListItem { index: slot, .. } => *slot = index,
12573            _ => {}
12574        }
12575    }
12576
12577    pub fn re_focus_sheet_filter(&mut self) {
12578        if !self.re_sheet_supports_filter() {
12579            return;
12580        }
12581        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12582            ed.sheet_filter_focused = true;
12583        }
12584    }
12585
12586    pub fn re_blur_sheet_filter_keep_text(&mut self) {
12587        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12588            return;
12589        };
12590        if !ed.sheet_filter_focused {
12591            return;
12592        }
12593        ed.sheet_filter_focused = false;
12594        self.re_sheet_clamp_index();
12595    }
12596
12597    pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
12598        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12599            return false;
12600        };
12601        if ed.sheet_filter_focused {
12602            ed.sheet_filter_focused = false;
12603            self.re_sheet_clamp_index();
12604            return true;
12605        }
12606        if !ed.sheet_filter.is_empty() {
12607            ed.sheet_filter.clear();
12608            self.re_sheet_clamp_index();
12609            return true;
12610        }
12611        false
12612    }
12613
12614    pub fn re_append_sheet_filter_char(&mut self, ch: char) {
12615        if ch.is_control() {
12616            return;
12617        }
12618        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12619            return;
12620        };
12621        if !ed.sheet_filter_focused {
12622            return;
12623        }
12624        ed.sheet_filter.push(ch);
12625        self.re_sheet_set_index(0);
12626        self.re_sheet_clamp_index();
12627    }
12628
12629    pub fn re_sheet_filter_backspace(&mut self) {
12630        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12631            return;
12632        };
12633        if !ed.sheet_filter_focused {
12634            return;
12635        }
12636        ed.sheet_filter.pop();
12637        self.re_sheet_set_index(0);
12638        self.re_sheet_clamp_index();
12639    }
12640
12641    /// Row count of the current sheet (for cursor wrapping).
12642    pub fn re_sheet_row_count(&self) -> usize {
12643        use crate::worker_route_editor::{
12644            harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
12645        };
12646        let Some(ed) = self.state.worker_route_editor.as_ref() else {
12647            return 0;
12648        };
12649        match &ed.sheet {
12650            S::Stops => ed.stops.len(),
12651            S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
12652            S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
12653            S::WaypointMapPick => 0,
12654            S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
12655            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
12656                self.re_container_candidates().len()
12657            }
12658            S::WithdrawItems { lines, .. } => lines.len() + 1, // + Done row
12659            S::DepositFilter { rows, .. } => rows.len() + 1,   // + Done row
12660            S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, // + auto row
12661            S::SellItem { templates, .. } | S::MarketListItem { templates, .. } => {
12662                sell_item_picker_row_count(templates.len())
12663            }
12664            S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
12665            S::WaitEntry { .. } => 1,
12666            S::BedPicker { .. } => self.re_bed_candidates().len(),
12667            S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
12668            S::FarmPlantSeed { seeds, .. } => seeds.len(),
12669        }
12670    }
12671
12672    /// Current sheet cursor index (0 for sheets without one).
12673    pub fn re_sheet_index(&self) -> usize {
12674        use crate::worker_route_editor::RouteEditorSheet as S;
12675        let Some(ed) = self.state.worker_route_editor.as_ref() else {
12676            return 0;
12677        };
12678        match &ed.sheet {
12679            S::AddMenu { index }
12680            | S::WaypointMenu { index }
12681            | S::HarvestPicker { index, .. }
12682            | S::WithdrawContainers { index }
12683            | S::DepositContainers { index }
12684            | S::SellNpcs { index }
12685            | S::CraftBlueprint { index }
12686            | S::BedPicker { index }
12687            | S::FarmPlotPicker { index, .. }
12688            | S::FarmPlantSeed { index, .. }
12689            | S::WithdrawItems { index, .. }
12690            | S::DepositFilter { index, .. }
12691            | S::SellItem { index, .. } | S::MarketListItem { index, .. } => *index,
12692            _ => 0,
12693        }
12694    }
12695
12696    /// Move the current sheet's cursor, wrapping within its rows.
12697    pub fn re_sheet_move(&mut self, delta: i32) {
12698        let count = self.re_sheet_row_count();
12699        if count == 0 {
12700            return;
12701        }
12702        let cur = self.re_sheet_index();
12703        let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
12704        self.re_sheet_set_index(next);
12705    }
12706
12707    pub fn re_sheet_page(&mut self, pages: i32) {
12708        let count = self.re_sheet_row_count();
12709        if count == 0 {
12710            return;
12711        }
12712        let cur = self.re_sheet_index();
12713        let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
12714        self.re_sheet_set_index(next);
12715    }
12716
12717    /// `[`/`]` on a sheet: adjust quantity (withdraw lines, wait ticks).
12718    pub fn re_sheet_adjust(&mut self, delta: i32) {
12719        use crate::worker_route_editor::RouteEditorSheet as S;
12720        let index = self.re_sheet_index();
12721        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12722            return;
12723        };
12724        match &mut ed.sheet {
12725            S::WithdrawItems { lines, .. } => {
12726                if let Some(line) = lines.get_mut(index) {
12727                    line.adjust_qty(delta);
12728                }
12729            }
12730            S::WaitEntry { ticks } => {
12731                *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
12732            }
12733            _ => {}
12734        }
12735    }
12736
12737    pub fn re_sheet_back(&mut self) {
12738        let Some(ed) = self.state.worker_route_editor.as_mut() else {
12739            return;
12740        };
12741        use crate::worker_route_editor::RouteEditorSheet as S;
12742        let was_editing = ed.editing_index.is_some();
12743        let from_top_picker = matches!(
12744            ed.sheet,
12745            S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
12746        );
12747        ed.sheet_back();
12748        if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
12749            // Chest retarget commits on pick; Esc here just ends the edit sheet.
12750            self.state
12751                .push_log("Route: left edit sheet — press s to save current stops".to_string());
12752        }
12753    }
12754
12755    /// True when the editor is at its root sheet (Esc should close it).
12756    pub fn re_at_root_sheet(&self) -> bool {
12757        self.state.worker_route_editor.as_ref().is_some_and(|ed| {
12758            matches!(
12759                ed.sheet,
12760                crate::worker_route_editor::RouteEditorSheet::Stops
12761            )
12762        })
12763    }
12764
12765    pub fn re_open_add_menu(&mut self) {
12766        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12767            ed.open_add_menu();
12768        }
12769    }
12770
12771    pub fn re_open_bed_picker(&mut self) {
12772        let beds = self.re_bed_candidates();
12773        if beds.is_empty() {
12774            self.state
12775                .push_log("Route: place a camp bed first".to_string());
12776            return;
12777        }
12778        let current = self
12779            .state
12780            .worker_route_editor
12781            .as_ref()
12782            .and_then(|ed| ed.lodging_container_id.clone());
12783        let index = current
12784            .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
12785            .unwrap_or(0);
12786        self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
12787    }
12788
12789    fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
12790        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12791            ed.open_sheet(sheet);
12792        }
12793    }
12794
12795    /// Confirm a sheet's stop and log the outcome.
12796    fn re_confirm_stop(&mut self, stop: crate::worker_route_editor::WorkerRouteStop, what: String) {
12797        let appended = self
12798            .state
12799            .worker_route_editor
12800            .as_mut()
12801            .is_some_and(|ed| ed.confirm_stop(stop));
12802        if appended {
12803            self.state.push_log(format!("Route: + {what}"));
12804        } else {
12805            self.state
12806                .push_log(format!("Route: {what} already in route — selected it"));
12807        }
12808    }
12809
12810    fn re_open_withdraw_items(&mut self, container_id: String) {
12811        use crate::worker_route_editor::{
12812            RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
12813        };
12814        let contents = self.re_container_contents(&container_id);
12815        // When editing, keep the stop's item picks even if the player retargets
12816        // to a different (possibly empty) chest — otherwise Done has nothing to
12817        // confirm and the edit appears to "revert".
12818        let existing = self
12819            .state
12820            .worker_route_editor
12821            .as_ref()
12822            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12823            .and_then(|stop| match stop {
12824                WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
12825                _ => None,
12826            })
12827            .unwrap_or_default();
12828        let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
12829        // Commit the new chest immediately while editing so Esc-before-Done still
12830        // keeps the retarget (items update only when Done is pressed).
12831        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12832            let _ = ed.retarget_withdraw_container(container_id.clone());
12833        }
12834        self.re_open_sheet(S::WithdrawItems {
12835            container_id,
12836            lines,
12837            index: 0,
12838        });
12839    }
12840
12841    fn re_withdraw_items_activate(&mut self, index: usize) {
12842        use crate::worker_route_editor::{
12843            RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop,
12844        };
12845        enum Outcome {
12846            Cycled,
12847            Confirmed(String),
12848            Empty,
12849        }
12850        let outcome = {
12851            let Some(ed) = self.state.worker_route_editor.as_mut() else {
12852                return;
12853            };
12854            let S::WithdrawItems {
12855                container_id,
12856                lines,
12857                index: sheet_index,
12858            } = &mut ed.sheet
12859            else {
12860                return;
12861            };
12862            *sheet_index = index;
12863            if index < lines.len() {
12864                lines[index].cycle();
12865                Outcome::Cycled
12866            } else {
12867                let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
12868                if items.is_empty() {
12869                    Outcome::Empty
12870                } else {
12871                    let stop = WorkerRouteStop::WithdrawFrom {
12872                        container_id: container_id.clone(),
12873                        items,
12874                    };
12875                    let summary = stop.summary();
12876                    ed.confirm_stop(stop);
12877                    Outcome::Confirmed(summary)
12878                }
12879            }
12880        };
12881        match outcome {
12882            Outcome::Cycled => {}
12883            Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
12884            Outcome::Empty => self.state.push_log(
12885                "Route: pick at least one item (Space/Enter toggles All/qty)".to_string(),
12886            ),
12887        }
12888    }
12889
12890    fn re_open_deposit_filter(&mut self, container_id: String) {
12891        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12892        // Preserve filter when retargeting the deposit chest while editing.
12893        let existing_filter = self
12894            .state
12895            .worker_route_editor
12896            .as_ref()
12897            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12898            .and_then(|stop| match stop {
12899                WorkerRouteStop::DepositAt { filter, .. } => {
12900                    Some(filter.clone().unwrap_or_default())
12901                }
12902                _ => None,
12903            });
12904        let mut candidates = self.re_template_candidates();
12905        if let Some(ref chosen) = existing_filter {
12906            for t in chosen {
12907                if !candidates.iter().any(|c| c == t) {
12908                    candidates.push(t.clone());
12909                }
12910            }
12911            candidates.sort();
12912            candidates.dedup();
12913        }
12914        let rows: Vec<(String, bool)> = match existing_filter {
12915            Some(chosen) => candidates
12916                .iter()
12917                .map(|t| (t.clone(), chosen.contains(t)))
12918                .collect(),
12919            None => candidates.into_iter().map(|t| (t, false)).collect(),
12920        };
12921        if let Some(ed) = self.state.worker_route_editor.as_mut() {
12922            let _ = ed.retarget_deposit_container(container_id.clone());
12923        }
12924        self.re_open_sheet(S::DepositFilter {
12925            container_id,
12926            rows,
12927            index: 0,
12928        });
12929    }
12930
12931    fn re_deposit_filter_activate(&mut self, index: usize) {
12932        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12933        let mut confirmed: Option<String> = None;
12934        {
12935            let Some(ed) = self.state.worker_route_editor.as_mut() else {
12936                return;
12937            };
12938            let S::DepositFilter {
12939                container_id,
12940                rows,
12941                index: sheet_index,
12942            } = &mut ed.sheet
12943            else {
12944                return;
12945            };
12946            *sheet_index = index;
12947            if index < rows.len() {
12948                rows[index].1 = !rows[index].1;
12949            } else {
12950                // "Done" row.
12951                let chosen: Vec<String> = rows
12952                    .iter()
12953                    .filter(|(_, on)| *on)
12954                    .map(|(t, _)| t.clone())
12955                    .collect();
12956                let filter = if chosen.is_empty() {
12957                    None
12958                } else {
12959                    Some(chosen)
12960                };
12961                let stop = WorkerRouteStop::DepositAt {
12962                    container_id: container_id.clone(),
12963                    filter,
12964                };
12965                confirmed = Some(stop.summary());
12966                ed.confirm_stop(stop);
12967            }
12968        }
12969        if let Some(what) = confirmed {
12970            self.state.push_log(format!("Route: + {what}"));
12971        }
12972    }
12973
12974    fn re_open_sell_item(&mut self, npc_id: Option<String>) {
12975        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
12976        // Prefill when editing an existing sell stop.
12977        let (pre_npc, pre_template, pre_all) = self
12978            .state
12979            .worker_route_editor
12980            .as_ref()
12981            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
12982            .and_then(|stop| match stop {
12983                WorkerRouteStop::TradeWith {
12984                    npc_id,
12985                    template,
12986                    sell_all,
12987                } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
12988                _ => None,
12989            })
12990            .unwrap_or((None, None, true));
12991        let npc_id = npc_id.or(pre_npc);
12992        let mut templates = crate::worker_route_editor::sellable_route_item_template_candidates(
12993            &self.re_template_candidates(),
12994            &self.state.npcs,
12995            npc_id.as_deref(),
12996        );
12997        // Keep an existing invalid selection visible long enough to remove it
12998        // when editing a route after NPC buy lists change.
12999        if let Some(template) = pre_template.as_ref() {
13000            if !templates.iter().any(|candidate| candidate == template) {
13001                templates.push(template.clone());
13002                templates.sort();
13003            }
13004        }
13005        if templates.is_empty() {
13006            let msg = crate::worker_route_editor::sell_merchant_empty_reason(
13007                npc_id.as_deref(),
13008                &self.state.npcs,
13009                &self.re_template_candidates(),
13010            );
13011            self.state.push_log(msg);
13012            return;
13013        }
13014        let mut picked = std::collections::BTreeSet::new();
13015        if let Some(t) = pre_template {
13016            picked.insert(t);
13017        }
13018        self.re_open_sheet(S::SellItem {
13019            npc_id,
13020            templates,
13021            index: if picked.is_empty() {
13022                crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
13023            } else {
13024                2
13025            },
13026            sell_all: pre_all,
13027            picked,
13028        });
13029    }
13030
13031    fn re_sell_item_activate(&mut self, index: usize) {
13032        use crate::worker_route_editor::{
13033            RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
13034        };
13035        let mut batch_log: Option<String> = None;
13036        {
13037            let Some(ed) = self.state.worker_route_editor.as_mut() else {
13038                return;
13039            };
13040            let S::SellItem {
13041                npc_id,
13042                templates,
13043                index: sheet_index,
13044                sell_all,
13045                picked,
13046            } = &mut ed.sheet
13047            else {
13048                return;
13049            };
13050            *sheet_index = index;
13051            if index == ROUTE_PICKER_DONE_ROW {
13052                if picked.is_empty() {
13053                    batch_log =
13054                        Some("Route: pick at least one item (Space toggles, Done confirms)".into());
13055                } else {
13056                    let picks: Vec<String> = picked.iter().cloned().collect();
13057                    let npc = npc_id.clone();
13058                    let all = *sell_all;
13059                    let added = ed.confirm_trade_picks(npc, &picks, all);
13060                    batch_log = Some(format!("Route: + {added} sell stop(s)"));
13061                }
13062            } else if index == SELL_ITEM_TOGGLE_ROW {
13063                *sell_all = !*sell_all;
13064            } else if let Some(template) = templates.get(index.saturating_sub(2)) {
13065                let sellable = crate::worker_route_editor::sellable_route_item_template_candidates(
13066                    std::slice::from_ref(template),
13067                    &self.state.npcs,
13068                    npc_id.as_deref(),
13069                )
13070                .iter()
13071                .any(|candidate| candidate == template);
13072                if !sellable && !picked.contains(template) {
13073                    return;
13074                }
13075                if picked.contains(template) {
13076                    picked.remove(template);
13077                } else {
13078                    picked.insert(template.clone());
13079                }
13080            }
13081        }
13082        if let Some(msg) = batch_log {
13083            self.state.push_log(msg);
13084        }
13085    }
13086
13087    fn re_open_market_list_item(&mut self) {
13088        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13089        let (pre_hall, pre_template, pre_all) = self
13090            .state
13091            .worker_route_editor
13092            .as_ref()
13093            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
13094            .and_then(|stop| match stop {
13095                WorkerRouteStop::ListOnMarket {
13096                    hall_id,
13097                    template,
13098                    list_all,
13099                } => Some((hall_id.clone(), Some(template.clone()), *list_all)),
13100                _ => None,
13101            })
13102            .unwrap_or((None, None, true));
13103        let mut templates = self.re_template_candidates();
13104        // Prefer items with a known NPC-value hint when available; keep the rest
13105        // so the stop can still be authored (sim rejects no-value / unlistable).
13106        templates.sort_by_key(|t| {
13107            std::cmp::Reverse(self.state.item_base_value_copper_hint(t).unwrap_or(0))
13108        });
13109        if let Some(template) = pre_template.as_ref() {
13110            if !templates.iter().any(|c| c == template) {
13111                templates.push(template.clone());
13112            }
13113        }
13114        if templates.is_empty() {
13115            self.state.push_log("Route: no item templates available for market list".to_string());
13116            return;
13117        }
13118        let mut picked = std::collections::BTreeSet::new();
13119        if let Some(t) = pre_template {
13120            picked.insert(t);
13121        }
13122        self.re_open_sheet(S::MarketListItem {
13123            hall_id: pre_hall,
13124            templates,
13125            index: if picked.is_empty() {
13126                crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
13127            } else {
13128                2
13129            },
13130            list_all: pre_all,
13131            picked,
13132        });
13133    }
13134
13135    fn re_market_list_item_activate(&mut self, index: usize) {
13136        use crate::worker_route_editor::{
13137            RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
13138        };
13139        let mut batch_log: Option<String> = None;
13140        {
13141            let Some(ed) = self.state.worker_route_editor.as_mut() else {
13142                return;
13143            };
13144            let S::MarketListItem {
13145                hall_id,
13146                templates,
13147                index: sheet_index,
13148                list_all,
13149                picked,
13150            } = &mut ed.sheet
13151            else {
13152                return;
13153            };
13154            *sheet_index = index;
13155            if index == ROUTE_PICKER_DONE_ROW {
13156                if picked.is_empty() {
13157                    batch_log = Some(
13158                        "Route: pick at least one item (Space toggles, Done confirms)".into(),
13159                    );
13160                } else {
13161                    let picks: Vec<String> = picked.iter().cloned().collect();
13162                    let hall = hall_id.clone();
13163                    let all = *list_all;
13164                    let added = ed.confirm_market_list_picks(hall, &picks, all);
13165                    batch_log = Some(format!("Route: + {added} market-list stop(s)"));
13166                }
13167            } else if index == SELL_ITEM_TOGGLE_ROW {
13168                *list_all = !*list_all;
13169            } else if let Some(template) = templates.get(index.saturating_sub(2)) {
13170                if picked.contains(template) {
13171                    picked.remove(template);
13172                } else {
13173                    picked.insert(template.clone());
13174                }
13175            }
13176        }
13177        if let Some(msg) = batch_log {
13178            self.state.push_log(msg);
13179        }
13180    }
13181
13182    /// Enter on the stop list: open the selected stop's sheet, prefilled.
13183    pub fn re_edit_selected_stop(&mut self) {
13184        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13185        let Some(stop) = self
13186            .state
13187            .worker_route_editor
13188            .as_ref()
13189            .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
13190        else {
13191            self.state
13192                .push_log("Route: no stop selected — press a to add one".to_string());
13193            return;
13194        };
13195        if let Some(ed) = self.state.worker_route_editor.as_mut() {
13196            ed.begin_edit_selected();
13197        }
13198        match stop {
13199            WorkerRouteStop::Waypoint { .. } => {
13200                self.re_open_sheet(S::WaypointMenu { index: 0 });
13201            }
13202            WorkerRouteStop::HarvestNode { node_id } => {
13203                let nodes = self.state.route_editor_node_candidates();
13204                if nodes.is_empty() {
13205                    self.re_cancel_edit();
13206                    self.state
13207                        .push_log("Route: no harvestable nodes in this region to retarget".to_string());
13208                } else {
13209                    let mut picked = std::collections::BTreeSet::new();
13210                    picked.insert(node_id.clone());
13211                    let index = nodes
13212                        .iter()
13213                        .position(|n| n.id == node_id)
13214                        .map(|i| i + 1)
13215                        .unwrap_or(1);
13216                    self.re_open_harvest_picker(index, picked);
13217                }
13218            }
13219            WorkerRouteStop::WithdrawFrom { container_id, .. } => {
13220                // Open the container picker so the player can retarget storage
13221                // (previously jumped straight to items on the same chest).
13222                let containers = self.re_container_candidates();
13223                if containers.is_empty() {
13224                    self.re_cancel_edit();
13225                    self.state
13226                        .push_log("Route: place a storage chest first".to_string());
13227                } else {
13228                    let index = containers
13229                        .iter()
13230                        .position(|c| c.id == container_id)
13231                        .unwrap_or(0);
13232                    self.re_open_sheet(S::WithdrawContainers { index });
13233                }
13234            }
13235            WorkerRouteStop::DepositAt { container_id, .. } => {
13236                let containers = self.re_container_candidates();
13237                if containers.is_empty() {
13238                    self.re_cancel_edit();
13239                    self.state
13240                        .push_log("Route: place a storage chest first".to_string());
13241                } else {
13242                    let index = containers
13243                        .iter()
13244                        .position(|c| c.id == container_id)
13245                        .unwrap_or(0);
13246                    self.re_open_sheet(S::DepositContainers { index });
13247                }
13248            }
13249            WorkerRouteStop::TradeWith { npc_id, .. } => {
13250                let npcs = self.re_npc_candidates();
13251                // Row 0 is "auto / nearest"; rows 1.. map to npcs.
13252                let index = npc_id
13253                    .as_ref()
13254                    .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
13255                    .unwrap_or(0);
13256                self.re_open_sheet(S::SellNpcs { index });
13257            }
13258            WorkerRouteStop::ListOnMarket { .. } => {
13259                self.re_open_market_list_item();
13260            }
13261            WorkerRouteStop::CraftAt { blueprint, .. } => {
13262                let bps = self.re_blueprint_ids();
13263                let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
13264                if bps.is_empty() {
13265                    self.re_cancel_edit();
13266                    self.state
13267                        .push_log("Route: no known blueprints to retarget".to_string());
13268                } else {
13269                    self.re_open_sheet(S::CraftBlueprint { index });
13270                }
13271            }
13272            WorkerRouteStop::CultivatePlot { .. } => {
13273                self.re_open_farm_plot_picker(
13274                    crate::worker_route_editor::FarmPlotAction::Cultivate,
13275                );
13276            }
13277            WorkerRouteStop::PlantPlot { .. } => {
13278                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
13279            }
13280            WorkerRouteStop::HarvestPlot { .. } => {
13281                self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
13282            }
13283            WorkerRouteStop::RestIfNeeded => {
13284                self.re_cancel_edit();
13285                self.state
13286                    .push_log("Route: rest has no settings (change the bed with l)".to_string());
13287            }
13288            WorkerRouteStop::Wait { wait_ticks } => {
13289                self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
13290            }
13291        }
13292    }
13293
13294    fn re_cancel_edit(&mut self) {
13295        if let Some(ed) = self.state.worker_route_editor.as_mut() {
13296            ed.editing_index = None;
13297        }
13298    }
13299
13300    // ---- mouse clicks inside the overlay ------------------------------
13301
13302    pub fn worker_route_editor_ui_click(
13303        &mut self,
13304        click: crate::worker_route_editor::RouteEditorClick,
13305    ) {
13306        use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
13307        match click {
13308            RouteEditorClick::SelectStop(i) => {
13309                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13310                    ed.sheet = S::Stops;
13311                    ed.select_stop(i);
13312                }
13313            }
13314            RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
13315            RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
13316            RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
13317        }
13318    }
13319
13320    /// Activate (Enter / click) a row of the current sheet.
13321    pub fn re_sheet_row_activate(&mut self, row: usize) {
13322        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
13323        let Some(sheet) = self
13324            .state
13325            .worker_route_editor
13326            .as_ref()
13327            .map(|ed| ed.sheet.clone())
13328        else {
13329            return;
13330        };
13331        match sheet {
13332            S::Stops => {
13333                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13334                    ed.select_stop(row);
13335                }
13336            }
13337            S::AddMenu { .. } => match row {
13338                0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
13339                1 => {
13340                    if self.re_node_candidates().is_empty() {
13341                        self.state.push_log(
13342                            "Route: no harvestable nodes in this region".to_string(),
13343                        );
13344                    } else {
13345                        self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
13346                    }
13347                }
13348                2 | 3 => {
13349                    if self.re_container_candidates().is_empty() {
13350                        self.state
13351                            .push_log("Route: place a storage chest first".to_string());
13352                    } else if row == 2 {
13353                        self.re_open_sheet(S::WithdrawContainers { index: 0 });
13354                    } else {
13355                        self.re_open_sheet(S::DepositContainers { index: 0 });
13356                    }
13357                }
13358                4 => {
13359                    if self.re_template_candidates().is_empty() {
13360                        self.state.push_log(
13361                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
13362                                .to_string(),
13363                        );
13364                    } else {
13365                        self.re_open_sheet(S::SellNpcs { index: 0 });
13366                    }
13367                }
13368                5 => {
13369                    if self.re_template_candidates().is_empty() {
13370                        self.state.push_log(
13371                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
13372                                .to_string(),
13373                        );
13374                    } else {
13375                        self.re_open_market_list_item();
13376                    }
13377                }
13378                6 => {
13379                    if self.re_blueprint_ids().is_empty() {
13380                        self.state.push_log(
13381                            "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
13382                                .to_string(),
13383                        );
13384                    } else {
13385                        self.re_open_sheet(S::CraftBlueprint { index: 0 });
13386                    }
13387                }
13388                7 => self.re_confirm_stop(
13389                    WorkerRouteStop::RestIfNeeded,
13390                    "rest at lodging (if needed)".into(),
13391                ),
13392                8 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
13393                9 => self.re_open_farm_plot_picker(
13394                    crate::worker_route_editor::FarmPlotAction::Cultivate,
13395                ),
13396                10 => {
13397                    self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant)
13398                }
13399                11 => self
13400                    .re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
13401                _ => {}
13402            },
13403            S::WaypointMenu { .. } => match row {
13404                0 => {
13405                    let (x, y, z) = self.state.player_position_with_z();
13406                    let stop = WorkerRouteStop::Waypoint { x, y, z };
13407                    self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13408                }
13409                1 => {
13410                    self.re_open_sheet(S::WaypointMapPick);
13411                    self.state.push_log(
13412                        "Route: click the map to place the waypoint (Esc to finish)".to_string(),
13413                    );
13414                }
13415                _ => {}
13416            },
13417            S::HarvestPicker { .. } => {
13418                let mut log: Option<String> = None;
13419                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13420                    let S::HarvestPicker {
13421                        index: sheet_index,
13422                        picked,
13423                        nodes,
13424                    } = &mut ed.sheet
13425                    else {
13426                        return;
13427                    };
13428                    *sheet_index = row;
13429                    if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
13430                        if picked.is_empty() {
13431                            log = Some(
13432                                "Route: pick at least one node (Space toggles, Done confirms)"
13433                                    .into(),
13434                            );
13435                        } else {
13436                            let ids: Vec<String> = picked.iter().cloned().collect();
13437                            let added = ed.confirm_harvest_picks(&ids);
13438                            log = Some(format!("Route: + {added} harvest stop(s)"));
13439                        }
13440                    } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
13441                        if picked.contains(&n.id) {
13442                            picked.remove(&n.id);
13443                        } else {
13444                            picked.insert(n.id.clone());
13445                        }
13446                    }
13447                }
13448                if let Some(msg) = log {
13449                    self.state.push_log(msg);
13450                }
13451            }
13452            S::WithdrawContainers { .. } => {
13453                let containers = self.re_container_candidates();
13454                if let Some(c) = containers.get(row) {
13455                    let id = c.id.clone();
13456                    self.re_open_withdraw_items(id);
13457                }
13458            }
13459            S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
13460            S::DepositContainers { .. } => {
13461                let containers = self.re_container_candidates();
13462                if let Some(c) = containers.get(row) {
13463                    let id = c.id.clone();
13464                    self.re_open_deposit_filter(id);
13465                }
13466            }
13467            S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
13468            S::SellNpcs { .. } => {
13469                let templates = self.re_template_candidates();
13470                let npcs = self.re_npc_candidates();
13471                if row == 0 {
13472                    if !crate::worker_route_editor::any_trade_npc_buys_route_item(
13473                        &self.state.npcs,
13474                        &templates,
13475                    ) {
13476                        self.state.push_log(
13477                            crate::worker_route_editor::sell_merchant_empty_reason(
13478                                None,
13479                                &self.state.npcs,
13480                                &templates,
13481                            ),
13482                        );
13483                        return;
13484                    }
13485                    self.re_open_sell_item(None);
13486                    return;
13487                }
13488                let Some(n) = npcs.get(row - 1) else {
13489                    return;
13490                };
13491                if !n.buys_route_item {
13492                    self.state.push_log(
13493                        crate::worker_route_editor::sell_merchant_empty_reason(
13494                            Some(n.id.as_str()),
13495                            &self.state.npcs,
13496                            &templates,
13497                        ),
13498                    );
13499                    return;
13500                }
13501                self.re_open_sell_item(Some(n.id.clone()));
13502            }
13503            S::SellItem { .. } => self.re_sell_item_activate(row),
13504            S::MarketListItem { .. } => self.re_market_list_item_activate(row),
13505            S::CraftBlueprint { .. } => {
13506                let bps = self.re_blueprint_ids();
13507                if let Some(bp) = bps.get(row) {
13508                    let stop = WorkerRouteStop::CraftAt {
13509                        device: "hand".into(),
13510                        blueprint: bp.clone(),
13511                        qty: None,
13512                    };
13513                    self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
13514                }
13515            }
13516            S::WaitEntry { ticks } => {
13517                let stop = WorkerRouteStop::Wait { wait_ticks: ticks };
13518                self.re_confirm_stop(stop, format!("wait {ticks}t"));
13519            }
13520            S::BedPicker { .. } => {
13521                let beds = self.re_bed_candidates();
13522                if let Some((id, name)) = beds.get(row) {
13523                    let (id, name) = (id.clone(), name.clone());
13524                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13525                        ed.lodging_container_id = Some(id.clone());
13526                        ed.sheet = S::Stops;
13527                    }
13528                    self.state
13529                        .push_log(format!("Route: rest bed set to {name}"));
13530                }
13531            }
13532            S::FarmPlotPicker { action, .. } => {
13533                let plots = self.re_farm_plot_candidates();
13534                let Some(plot) = plots.get(row).cloned() else {
13535                    return;
13536                };
13537                match action {
13538                    crate::worker_route_editor::FarmPlotAction::Cultivate => {
13539                        let label = plot_route_label(&plot);
13540                        self.re_confirm_stop(
13541                            WorkerRouteStop::CultivatePlot {
13542                                plot_id: plot.plot_id,
13543                            },
13544                            format!("cultivate {label}"),
13545                        );
13546                    }
13547                    crate::worker_route_editor::FarmPlotAction::Harvest => {
13548                        let label = plot_route_label(&plot);
13549                        self.re_confirm_stop(
13550                            WorkerRouteStop::HarvestPlot {
13551                                plot_id: plot.plot_id,
13552                            },
13553                            format!("harvest {label}"),
13554                        );
13555                    }
13556                    crate::worker_route_editor::FarmPlotAction::Plant => {
13557                        let seeds = self.re_farm_seed_candidates();
13558                        if seeds.is_empty() {
13559                            self.state.push_log(
13560                                "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
13561                            );
13562                            return;
13563                        }
13564                        self.re_open_sheet(S::FarmPlantSeed {
13565                            plot_id: plot.plot_id,
13566                            seeds,
13567                            index: 0,
13568                        });
13569                    }
13570                }
13571            }
13572            S::FarmPlantSeed { plot_id, seeds, .. } => {
13573                if let Some(seed) = seeds.get(row).cloned() {
13574                    self.re_confirm_stop(
13575                        WorkerRouteStop::PlantPlot {
13576                            plot_id,
13577                            seed_template: seed.clone(),
13578                        },
13579                        format!("plant {seed}"),
13580                    );
13581                }
13582            }
13583            S::WaypointMapPick => {}
13584        }
13585    }
13586
13587    fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
13588        use crate::worker_route_editor::RouteEditorSheet as S;
13589        if self.re_farm_plot_candidates().is_empty() {
13590            self.state
13591                .push_log("Route: no farmable plots visible — claim land or get farm access first");
13592            return;
13593        }
13594        self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
13595    }
13596
13597    fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
13598        self.state
13599            .property_plots
13600            .iter()
13601            .filter(|p| p.is_mine || p.may_farm)
13602            .cloned()
13603            .collect()
13604    }
13605
13606    /// Seed templates for a plant-plot route stop — does **not** require seeds on the
13607    /// player. Unions inventory, owned storage, withdraw lines already in the draft,
13608    /// and catalog `seed_for` templates so routes can withdraw-then-plant.
13609    fn re_farm_seed_candidates(&self) -> Vec<String> {
13610        let mut set = std::collections::BTreeSet::new();
13611        let looks_like_seed = |id: &str, catalog: &std::collections::HashMap<String, ItemCatalogEntryView>| {
13612            catalog.get(id).is_some_and(|e| e.is_farm_seed()) || id.ends_with("_seed")
13613        };
13614        for (id, _, _) in self.state.farm_seed_entries() {
13615            set.insert(id);
13616        }
13617        for c in &self.state.placed_containers {
13618            let mine = match (self.state.character_id, c.owner_character_id) {
13619                (Some(a), Some(b)) => a == b,
13620                _ => false,
13621            };
13622            if !mine {
13623                continue;
13624            }
13625            for s in &c.contents {
13626                if s.quantity > 0
13627                    && (s.props.contains_key("seed_for")
13628                        || looks_like_seed(&s.template_id, &self.state.item_catalog))
13629                {
13630                    set.insert(s.template_id.clone());
13631                }
13632            }
13633        }
13634        if let Some(ed) = self.state.worker_route_editor.as_ref() {
13635            for stop in &ed.stops {
13636                if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } =
13637                    stop
13638                {
13639                    for it in items {
13640                        if looks_like_seed(&it.template, &self.state.item_catalog) {
13641                            set.insert(it.template.clone());
13642                        }
13643                    }
13644                }
13645                if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
13646                    seed_template,
13647                    ..
13648                } = stop
13649                {
13650                    if !seed_template.is_empty() {
13651                        set.insert(seed_template.clone());
13652                    }
13653                }
13654            }
13655        }
13656        for (id, entry) in &self.state.item_catalog {
13657            if entry.is_farm_seed() {
13658                set.insert(id.clone());
13659            }
13660        }
13661        set.into_iter().collect()
13662    }
13663
13664    // ---- map clicks (sheet-scoped accelerators) ------------------------
13665
13666    /// Map click while the route editor is open. When a picker sheet is open
13667    /// the click feeds *that* sheet (chest clicks choose the withdraw/deposit
13668    /// container, NPC clicks the merchant, node clicks the harvest target);
13669    /// otherwise the click quick-adds the nearest target (context-aware).
13670    pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
13671        use crate::worker_route_editor as wre;
13672        use wre::RouteEditorSheet as S;
13673        if self.state.worker_route_editor.is_none() {
13674            return;
13675        }
13676        let sheet = self
13677            .state
13678            .worker_route_editor
13679            .as_ref()
13680            .map(|ed| ed.sheet.clone())
13681            .unwrap_or(S::Stops);
13682        match sheet {
13683            S::WaypointMapPick => {
13684                let (_, _, z) = self.state.player_position_with_z();
13685                let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
13686                self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
13687                // Stay in map-pick mode when adding (not editing) for fast pathing.
13688                let editing = self
13689                    .state
13690                    .worker_route_editor
13691                    .as_ref()
13692                    .is_some_and(|ed| ed.editing_index.is_some());
13693                if !editing {
13694                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13695                        ed.sheet = S::WaypointMapPick;
13696                    }
13697                }
13698            }
13699            S::HarvestPicker { .. } => {
13700                if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
13701                    let mut log: Option<String> = None;
13702                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13703                        let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
13704                            return;
13705                        };
13706                        let selected = if picked.contains(&node.id) {
13707                            picked.remove(&node.id);
13708                            false
13709                        } else {
13710                            picked.insert(node.id.clone());
13711                            true
13712                        };
13713                        log = Some(format!(
13714                            "Route: {} {}",
13715                            if selected { "selected" } else { "deselected" },
13716                            resource_node_route_label(node)
13717                        ));
13718                    }
13719                    if let Some(msg) = log {
13720                        self.state.push_log(msg);
13721                    }
13722                }
13723            }
13724            S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
13725                // Chest click picks (or switches) the withdraw source.
13726                let inside = self.state.effective_inside_building();
13727                if let Some(cid) = wre::pick_storage_container_at(
13728                    &self.state.placed_containers,
13729                    self.state.character_id,
13730                    x,
13731                    y,
13732                    inside.as_deref(),
13733                ) {
13734                    self.re_open_withdraw_items(cid);
13735                }
13736            }
13737            S::DepositContainers { .. } | S::DepositFilter { .. } => {
13738                let inside = self.state.effective_inside_building();
13739                if let Some(cid) = wre::pick_storage_container_at(
13740                    &self.state.placed_containers,
13741                    self.state.character_id,
13742                    x,
13743                    y,
13744                    inside.as_deref(),
13745                ) {
13746                    self.re_open_deposit_filter(cid);
13747                }
13748            }
13749            S::SellNpcs { .. } => {
13750                if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13751                    self.re_open_sell_item(Some(npc_id));
13752                }
13753            }
13754            S::SellItem { .. } => {
13755                if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13756                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
13757                        if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
13758                            *slot = Some(npc_id.clone());
13759                        }
13760                    }
13761                    self.state
13762                        .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
13763                }
13764            }
13765            // Stop list / other sheets: context-aware quick add.
13766            _ => self.worker_route_editor_quick_add_click(x, y),
13767        }
13768    }
13769
13770    /// Quick-add map click with no picker open: nearest of bed/chest/merchant/
13771    /// node wins; duplicates select the existing stop; a click on a merchant
13772    /// while a sell stop is selected pins it.
13773    fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
13774        use crate::worker_route_editor as wre;
13775        let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
13776            let dx = ax - bx;
13777            let dy = ay - by;
13778            (dx * dx + dy * dy).sqrt()
13779        };
13780
13781        // 1. Retarget the selected stop when the click names its target:
13782        //    sell stop → pin the merchant; withdraw stop → set the source chest.
13783        let selected_stop_kind = self
13784            .state
13785            .worker_route_editor
13786            .as_ref()
13787            .and_then(|ed| ed.stops.get(ed.selected_stop_index))
13788            .map(|s| match s {
13789                wre::WorkerRouteStop::TradeWith { .. } => 1,
13790                wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
13791                _ => 0,
13792            })
13793            .unwrap_or(0);
13794        if selected_stop_kind == 1 {
13795            if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13796                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13797                    ed.set_selected_trade_npc(npc_id.clone());
13798                }
13799                self.state
13800                    .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
13801                return;
13802            }
13803        }
13804        if selected_stop_kind == 2 {
13805            let inside = self.state.effective_inside_building();
13806            if let Some(cid) = wre::pick_storage_container_at(
13807                &self.state.placed_containers,
13808                self.state.character_id,
13809                x,
13810                y,
13811                inside.as_deref(),
13812            ) {
13813                let name = self
13814                    .state
13815                    .placed_containers
13816                    .iter()
13817                    .find(|c| c.id == cid)
13818                    .map(|c| c.display_name.clone())
13819                    .unwrap_or_else(|| "container".into());
13820                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13821                    ed.set_selected_withdraw_container(cid.clone());
13822                }
13823                self.state
13824                    .push_log(format!("Route: withdraw source → {name}"));
13825                return;
13826            }
13827        }
13828
13829        // 2. Nearest candidate of any kind wins. Category order is the
13830        //    tie-break for exact overlaps (bed over chest over merchant over node).
13831        enum Target {
13832            Bed(String),
13833            Container(String),
13834            Npc(String, String),
13835            Node(String, String),
13836        }
13837        let mut best: Option<(f32, u8, Target)> = None;
13838        let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
13839            let better = match best {
13840                None => true,
13841                Some((bd, brank, _)) => {
13842                    d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank)
13843                }
13844            };
13845            if better {
13846                *best = Some((d, rank, t));
13847            }
13848        };
13849        let inside = self.state.effective_inside_building();
13850        if let Some(bed_id) = wre::pick_lodging_container_at(
13851            &self.state.placed_containers,
13852            self.state.character_id,
13853            x,
13854            y,
13855            inside.as_deref(),
13856        ) {
13857            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
13858                // A bed that's already the rest bed doubles as a storage chest —
13859                // a second click on it means "deposit here", not "set bed again".
13860                let already_bed =
13861                    self.state.worker_route_editor.as_ref().is_some_and(|ed| {
13862                        ed.lodging_container_id.as_deref() == Some(bed_id.as_str())
13863                    });
13864                if already_bed {
13865                    consider(
13866                        dist(x, y, c.x, c.y),
13867                        1,
13868                        Target::Container(bed_id),
13869                        &mut best,
13870                    );
13871                } else {
13872                    consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
13873                }
13874            }
13875        }
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            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
13884                consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
13885            }
13886        }
13887        if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
13888            if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
13889                consider(
13890                    dist(x, y, n.x, n.y),
13891                    2,
13892                    Target::Npc(npc_id, label),
13893                    &mut best,
13894                );
13895            }
13896        }
13897        if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
13898            let d = dist(x, y, node.x, node.y);
13899            let label = resource_node_route_label(node);
13900            consider(d, 3, Target::Node(node.id.clone(), label), &mut best);
13901        }
13902
13903        match best.map(|(_, _, t)| t) {
13904            Some(Target::Bed(bed_id)) => {
13905                let name = self
13906                    .state
13907                    .placed_containers
13908                    .iter()
13909                    .find(|c| c.id == bed_id)
13910                    .map(|c| c.display_name.clone())
13911                    .unwrap_or_else(|| "camp bed".into());
13912                if let Some(ed) = self.state.worker_route_editor.as_mut() {
13913                    ed.lodging_container_id = Some(bed_id.clone());
13914                }
13915                self.state
13916                    .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
13917            }
13918            Some(Target::Container(cid)) => {
13919                let name = self
13920                    .state
13921                    .placed_containers
13922                    .iter()
13923                    .find(|c| c.id == cid)
13924                    .map(|c| c.display_name.clone())
13925                    .unwrap_or_else(|| "container".into());
13926                let added = self
13927                    .state
13928                    .worker_route_editor
13929                    .as_mut()
13930                    .is_some_and(|ed| ed.append_deposit_at(&cid));
13931                if added {
13932                    self.state
13933                        .push_log(format!("Route: + deposit at {name} ({cid})"));
13934                } else {
13935                    self.state.push_log(format!(
13936                        "Route: {name} already in route — selected it (d to remove)"
13937                    ));
13938                }
13939            }
13940            Some(Target::Npc(npc_id, label)) => {
13941                // Quick-add sell: first storage template (the full sell flow
13942                // lives in the a → Sell sheet).
13943                let template = self.re_template_candidates().into_iter().next();
13944                let Some(template) = template else {
13945                    self.state.push_log(
13946                        "Route: no items in your storage to sell — stock a chest first".to_string(),
13947                    );
13948                    return;
13949                };
13950                let added = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
13951                    ed.append_trade_with(template.clone(), Some(npc_id.clone()), true)
13952                });
13953                if added {
13954                    self.state
13955                        .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
13956                } else {
13957                    self.state.push_log(format!(
13958                        "Route: {label} already sells {template} — selected it (d to remove)"
13959                    ));
13960                }
13961            }
13962            Some(Target::Node(id, label)) => {
13963                let added = self
13964                    .state
13965                    .worker_route_editor
13966                    .as_mut()
13967                    .is_some_and(|ed| ed.append_harvest_node(&id));
13968                if added {
13969                    self.state
13970                        .push_log(format!("Route: + harvest node {label}"));
13971                } else {
13972                    self.state.push_log(format!(
13973                        "Route: {label} already in route — selected it (d to remove)"
13974                    ));
13975                }
13976            }
13977            None => {}
13978        }
13979    }
13980
13981    pub fn worker_route_editor_select(&mut self, delta: i32) {
13982        let Some(ed) = self.state.worker_route_editor.as_mut() else {
13983            return;
13984        };
13985        if ed.stops.is_empty() {
13986            return;
13987        }
13988        let n = ed.stops.len() as i32;
13989        let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
13990        ed.selected_stop_index = next;
13991    }
13992
13993    pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
13994        let Some(ed) = self.state.worker_route_editor.as_mut() else {
13995            return;
13996        };
13997        if delta < 0 {
13998            ed.move_selected_up();
13999        } else if delta > 0 {
14000            ed.move_selected_down();
14001        }
14002    }
14003
14004    pub fn worker_route_editor_delete_selected(&mut self) {
14005        let removed = self.state.worker_route_editor.as_mut().is_some_and(|ed| {
14006            let before = ed.stop_count();
14007            ed.remove_selected_stop();
14008            ed.stop_count() < before
14009        });
14010        if removed {
14011            self.state.push_log("Route: removed selected stop");
14012        }
14013    }
14014
14015    /// Clear all stops. Saving afterwards parks the worker in idle mode
14016    /// instead of leaving it in a broken job loop.
14017    pub fn worker_route_editor_clear_stops(&mut self) {
14018        let Some(ed) = self.state.worker_route_editor.as_mut() else {
14019            return;
14020        };
14021        if ed.stops.is_empty() {
14022            self.state
14023                .push_log("Route: already empty — s saves an idle worker".to_string());
14024            return;
14025        }
14026        ed.stops.clear();
14027        ed.selected_stop_index = 0;
14028        self.state.push_log(
14029            "Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string(),
14030        );
14031    }
14032
14033    pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
14034        if self.state.pending_worker_job_ack.is_some() {
14035            anyhow::bail!("route save still pending — wait for server ack");
14036        }
14037        let Some(ed) = self.state.worker_route_editor.clone() else {
14038            anyhow::bail!("route editor not open");
14039        };
14040        // Empty route = stand down: park the worker in idle mode rather than
14041        // erroring out or leaving it marching a ghost loop.
14042        let (job_yaml, idle) = if ed.stops.is_empty() {
14043            (ed.build_idle_job_yaml(), true)
14044        } else {
14045            (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
14046        };
14047        let worker_id = ed.worker_instance_id.clone();
14048        let route_view = if idle { None } else { Some(ed.to_route_view()) };
14049        let mode = if idle {
14050            flatland_protocol::WorkerModeView::Idle
14051        } else {
14052            flatland_protocol::WorkerModeView::JobLoop
14053        };
14054        let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
14055            .state
14056            .hired_workers
14057            .iter()
14058            .find(|w| w.instance_id == worker_id)
14059            .map(|w| {
14060                (
14061                    w.route.clone(),
14062                    w.mode,
14063                    w.step_label.clone(),
14064                    w.last_error.clone(),
14065                )
14066            })
14067            .unwrap_or((
14068                None,
14069                flatland_protocol::WorkerModeView::Idle,
14070                String::new(),
14071                None,
14072            ));
14073        self.seq += 1;
14074        let seq = self.seq;
14075        self.session
14076            .submit_intent(Intent::SetWorkerJob {
14077                entity_id: self.state.entity_id,
14078                worker_instance_id: worker_id.clone(),
14079                job_yaml,
14080                seq,
14081            })
14082            .await?;
14083        self.state.intents_sent += 1;
14084        if let Some(w) = self
14085            .state
14086            .hired_workers
14087            .iter_mut()
14088            .find(|w| w.instance_id == worker_id)
14089        {
14090            w.route = route_view;
14091            w.mode = mode;
14092            w.last_error = None;
14093            if idle {
14094                w.step_label.clear();
14095                w.route_stop_index = None;
14096            }
14097        }
14098        self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
14099            seq,
14100            worker_instance_id: worker_id,
14101            worker_label: ed.worker_label.clone(),
14102            idle,
14103            stop_count: ed.stops.len(),
14104            prev_route,
14105            prev_mode,
14106            prev_step_label,
14107            prev_last_error,
14108        });
14109        self.state.push_log(format!(
14110            "Route: saving for {}… (waiting for server)",
14111            ed.worker_label
14112        ));
14113        // Keep editor open until IntentAck; reject Interaction reverts optimistic state.
14114        Ok(())
14115    }
14116    pub fn quest_menu_move(&mut self, delta: i32) {
14117        let n = self.state.active_quest_entries().len();
14118        if n == 0 {
14119            return;
14120        }
14121        let idx = self.state.quest_menu_index as i32;
14122        self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
14123    }
14124
14125    pub fn quest_menu_page(&mut self, pages: i32) {
14126        let n = self.state.active_quest_entries().len();
14127        self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
14128    }
14129
14130    pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
14131        let Some(offer) = self.state.selected_quest_offer().cloned() else {
14132            anyhow::bail!("no quest offer");
14133        };
14134        self.seq += 1;
14135        let seq = self.seq;
14136        self.session
14137            .submit_intent(Intent::AcceptQuest {
14138                entity_id: self.state.entity_id,
14139                quest_id: offer.quest_id,
14140                seq,
14141            })
14142            .await?;
14143        self.state.intents_sent += 1;
14144        Ok(())
14145    }
14146
14147    pub fn quest_offer_move(&mut self, delta: i32) {
14148        self.state.move_quest_offer_selection(delta);
14149    }
14150
14151    pub fn quest_offer_decline(&mut self) {
14152        self.state.clear_quest_offers();
14153        if !self.state.show_npc_chat
14154            && !self.state.show_shop_menu
14155            && self.state.npc_verb_target.is_some()
14156        {
14157            self.state.show_npc_verb_menu = true;
14158        }
14159    }
14160
14161    pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
14162        if !self.state.show_quest_menu {
14163            return Ok(());
14164        }
14165        let active: Vec<_> = self
14166            .state
14167            .active_quest_entries()
14168            .into_iter()
14169            .cloned()
14170            .collect();
14171        let Some(entry) = active.get(self.state.quest_menu_index) else {
14172            return Ok(());
14173        };
14174        if self.state.quest_withdraw_confirm {
14175            if !entry.can_withdraw {
14176                anyhow::bail!("quest cannot be withdrawn");
14177            }
14178            self.seq += 1;
14179            let seq = self.seq;
14180            self.session
14181                .submit_intent(Intent::WithdrawQuest {
14182                    entity_id: self.state.entity_id,
14183                    quest_id: entry.quest_id.clone(),
14184                    seq,
14185                })
14186                .await?;
14187            self.state.intents_sent += 1;
14188            self.state.quest_withdraw_confirm = false;
14189            return Ok(());
14190        }
14191        self.seq += 1;
14192        let seq = self.seq;
14193        self.session
14194            .submit_intent(Intent::TrackQuest {
14195                entity_id: self.state.entity_id,
14196                quest_id: entry.quest_id.clone(),
14197                seq,
14198            })
14199            .await?;
14200        self.state.intents_sent += 1;
14201        Ok(())
14202    }
14203
14204    pub fn quest_request_withdraw(&mut self) {
14205        if self.state.show_quest_menu {
14206            self.state.quest_withdraw_confirm = true;
14207        }
14208    }
14209
14210    pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
14211        if !self.state.is_alive() {
14212            anyhow::bail!("you are dead");
14213        }
14214        let Some(catalog) = self.state.shop_catalog.clone() else {
14215            anyhow::bail!("no shop open");
14216        };
14217        self.seq += 1;
14218        let seq = self.seq;
14219        match self.state.shop_tab {
14220            ShopTab::Buy => {
14221                let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
14222                    anyhow::bail!("nothing selected");
14223                };
14224                if offer.already_owned {
14225                    anyhow::bail!("already owned");
14226                }
14227                self.session
14228                    .submit_intent(Intent::ShopBuy {
14229                        entity_id: self.state.entity_id,
14230                        npc_id: catalog.npc_id.clone(),
14231                        offer_id: offer.offer_id.clone(),
14232                        quantity: self.state.shop_quantity,
14233                        seq,
14234                    })
14235                    .await?;
14236            }
14237            ShopTab::Sell => {
14238                let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
14239                    anyhow::bail!("nothing to sell");
14240                };
14241                if line.quantity == 0 {
14242                    anyhow::bail!("you have no {}", line.label);
14243                }
14244                let quantity = self.state.shop_quantity.min(line.quantity).max(1);
14245                self.session
14246                    .submit_intent(Intent::ShopSell {
14247                        entity_id: self.state.entity_id,
14248                        npc_id: catalog.npc_id.clone(),
14249                        template_id: line.template_id.clone(),
14250                        quantity,
14251                        seq,
14252                    })
14253                    .await?;
14254            }
14255        }
14256        self.state.intents_sent += 1;
14257        Ok(())
14258    }
14259
14260    pub fn craft_menu_move(&mut self, delta: i32) {
14261        let n = self.state.craft_filtered_indices().len();
14262        if n == 0 {
14263            return;
14264        }
14265        let idx = self.state.craft_menu_index as i32;
14266        let next = (idx + delta).rem_euclid(n as i32);
14267        self.state.craft_menu_index = next as usize;
14268        self.state.clamp_craft_batch_quantity();
14269    }
14270
14271    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
14272        self.state.craft_batch_adjust_quantity(delta);
14273    }
14274
14275    pub fn craft_batch_set_max(&mut self) {
14276        self.state.craft_batch_set_max();
14277    }
14278
14279    pub fn craft_batch_set_min(&mut self) {
14280        self.state.craft_batch_set_min();
14281    }
14282
14283    pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
14284        let Some(blueprint) = self.state.craft_selected_blueprint().cloned() else {
14285            anyhow::bail!("no blueprints in this tab");
14286        };
14287        if !self.state.can_craft_blueprint(&blueprint) {
14288            let hint = self
14289                .state
14290                .craft_missing_hint(&blueprint)
14291                .unwrap_or_else(|| "missing materials".into());
14292            anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
14293        }
14294        let count = self.state.craft_batch_quantity;
14295        let max = self.state.max_craft_batches(&blueprint);
14296        if max == 0 {
14297            anyhow::bail!("cannot craft {}", blueprint.label);
14298        }
14299        let batches = count.min(max);
14300        self.craft(&blueprint.id, Some(batches)).await?;
14301        // Keep the craft menu open so progress / blockers stay visible.
14302        Ok(())
14303    }
14304
14305    pub async fn move_by(
14306        &mut self,
14307        forward: f32,
14308        strafe: f32,
14309        vertical: f32,
14310        sprint: bool,
14311        sneak: bool,
14312    ) -> anyhow::Result<()> {
14313        if !self.state.is_alive() {
14314            anyhow::bail!("you are dead");
14315        }
14316        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
14317            self.last_move_forward = forward;
14318            self.last_move_strafe = strafe;
14319        }
14320        self.seq += 1;
14321        self.session
14322            .submit_intent(Intent::Move {
14323                entity_id: self.state.entity_id,
14324                forward,
14325                strafe,
14326                vertical,
14327                sprint: sprint && !sneak,
14328                sneak,
14329                seq: self.seq,
14330            })
14331            .await?;
14332        self.state.intents_sent += 1;
14333        Ok(())
14334    }
14335
14336    pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
14337        if !self.state.connected {
14338            crate::harvest_trace!("harvest_nearest rejected: not connected");
14339            anyhow::bail!("not connected");
14340        }
14341        if !self.state.is_alive() {
14342            crate::harvest_trace!("harvest_nearest rejected: player dead");
14343            anyhow::bail!("you are dead");
14344        }
14345        if self.state.harvest_in_progress {
14346            if self.state.harvest_state_stale() {
14347                self.state.clear_harvest_state();
14348            } else {
14349                anyhow::bail!("already harvesting");
14350            }
14351        }
14352        let (px, py) = self
14353            .state
14354            .player
14355            .as_ref()
14356            .map(|p| (p.transform.position.x, p.transform.position.y))
14357            .unwrap_or((0.0, 0.0));
14358
14359        let available = self
14360            .state
14361            .resource_nodes
14362            .iter()
14363            .filter(|n| !n.harvest_off)
14364            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14365            .count();
14366        let node_id = self
14367            .state
14368            .resource_nodes
14369            .iter()
14370            .filter(|n| !n.harvest_off)
14371            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
14372            .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
14373            .min_by(|a, b| {
14374                let da = distance(px, py, a.x, a.y);
14375                let db = distance(px, py, b.x, b.y);
14376                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
14377            })
14378            .map(|n| n.id.clone());
14379
14380        let Some(node_id) = node_id else {
14381            let has_loot = self
14382                .state
14383                .ground_drops
14384                .iter()
14385                .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14386            if has_loot {
14387                return self.pickup_nearest().await;
14388            }
14389            anyhow::bail!(
14390                "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
14391            );
14392        };
14393
14394        self.seq += 1;
14395        let seq = self.seq;
14396        crate::harvest_trace!(
14397            entity_id = self.state.entity_id,
14398            node_id = %node_id,
14399            seq,
14400            px,
14401            py,
14402            available_nodes = available,
14403            "submitting harvest intent"
14404        );
14405        self.session
14406            .submit_intent(Intent::Harvest {
14407                entity_id: self.state.entity_id,
14408                node_id,
14409                seq,
14410            })
14411            .await?;
14412        self.state.intents_sent += 1;
14413        self.state.harvest_in_progress = true;
14414        self.state.harvest_started_at = Some(Instant::now());
14415        self.state.push_log("Harvesting…");
14416        crate::harvest_trace!(
14417            entity_id = self.state.entity_id,
14418            seq,
14419            "harvest intent queued to session"
14420        );
14421        Ok(())
14422    }
14423
14424    pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
14425        if !self.state.is_alive() {
14426            anyhow::bail!("you are dead");
14427        }
14428        let blueprint_id = self
14429            .state
14430            .blueprints
14431            .iter()
14432            .find(|bp| self.state.can_craft_blueprint(bp))
14433            .map(|bp| bp.id.clone())
14434            .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
14435        self.craft(&blueprint_id, None).await
14436    }
14437
14438    pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
14439        if !self.state.is_alive() {
14440            anyhow::bail!("you are dead");
14441        }
14442        self.seq += 1;
14443        self.session
14444            .submit_intent(Intent::Craft {
14445                entity_id: self.state.entity_id,
14446                blueprint_id: blueprint_id.to_string(),
14447                count,
14448                seq: self.seq,
14449            })
14450            .await?;
14451        self.state.intents_sent += 1;
14452        let (label, batches) = self
14453            .state
14454            .blueprints
14455            .iter()
14456            .find(|b| b.id == blueprint_id)
14457            .map(|b| {
14458                let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
14459                (b.label.as_str(), n)
14460            })
14461            .unwrap_or((blueprint_id, count.unwrap_or(1)));
14462        self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
14463        self.state.craft_channel_blueprint_id = Some(blueprint_id.to_string());
14464        Ok(())
14465    }
14466
14467    pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
14468        if !self.state.is_alive() {
14469            anyhow::bail!("you are dead");
14470        }
14471        let target_id = match self.state.nearest_interact_target() {
14472            Some(id) => id,
14473            None => {
14474                anyhow::bail!("nothing to interact with nearby");
14475            }
14476        };
14477        if self.state.npcs.iter().any(|n| n.id == target_id) {
14478            self.state.show_npc_verb_menu = true;
14479            self.state.npc_verb_target = Some(target_id);
14480            self.state.npc_verb_index = 0;
14481            self.state.npc_verb_notice = None;
14482            return Ok(());
14483        }
14484        if self
14485            .state
14486            .hired_workers
14487            .iter()
14488            .any(|w| w.instance_id == target_id)
14489        {
14490            return self.open_workers_menu_for(&target_id).await;
14491        }
14492        if let Ok(peer_id) = target_id.parse::<EntityId>() {
14493            if self
14494                .state
14495                .hired_workers
14496                .iter()
14497                .any(|w| w.entity_id == peer_id)
14498            {
14499                if let Some(w) = self
14500                    .state
14501                    .hired_workers
14502                    .iter()
14503                    .find(|w| w.entity_id == peer_id)
14504                {
14505                    let id = w.instance_id.clone();
14506                    return self.open_workers_menu_for(&id).await;
14507                }
14508            }
14509            if let Some(entity) = self
14510                .state
14511                .entities
14512                .iter()
14513                .find(|e| e.id == peer_id && e.id != self.state.entity_id)
14514            {
14515                self.state.player_verbs.open_for(peer_id, &entity.label);
14516                return Ok(());
14517            }
14518        }
14519        self.seq += 1;
14520        self.session
14521            .submit_intent(Intent::Interact {
14522                entity_id: self.state.entity_id,
14523                target_id: target_id.clone(),
14524                seq: self.seq,
14525            })
14526            .await?;
14527        self.state.intents_sent += 1;
14528        Ok(())
14529    }
14530
14531    /// Context-sensitive world use: loot → interact/door → chest → plot farm → harvest.
14532    ///
14533    /// After loot-first, follows [`crate::UseWorldKind::cascade_stage`] so HUD probe
14534    /// and `f` never diverge (doors beat lodging/chest pickup indoors).
14535    pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
14536        if !self.state.is_alive() {
14537            anyhow::bail!("you are dead");
14538        }
14539        let (px, py) = self.state.player_position();
14540        let has_loot = self
14541            .state
14542            .ground_drops
14543            .iter()
14544            .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
14545        if has_loot {
14546            return self.pickup_nearest().await;
14547        }
14548
14549        // Align with probe cascade: interact (doors/NPCs/boards) → chest → harvest.
14550        if let Some(primary) = self.state.probe_use_world().primary {
14551            match primary.kind.cascade_stage() {
14552                0 => return self.interact_nearest().await,
14553                2 => return self.pickup_nearest_container().await,
14554                3 => return self.harvest_nearest().await,
14555                _ => {}
14556            }
14557        }
14558
14559        if let Some(plot) = self.state.my_plot_under_player().cloned() {
14560            // Double-tap f within 1.2s sells; otherwise farm the plot.
14561            const SELL_WINDOW: Duration = Duration::from_millis(1200);
14562            let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
14563                && self
14564                    .state
14565                    .sell_plot_armed_at
14566                    .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
14567            if sell_armed {
14568                return self.confirm_sell_plot_to_crown(plot.plot_id).await;
14569            }
14570            self.state.sell_plot_confirm = None;
14571            self.state.sell_plot_armed_at = None;
14572
14573            // Prefer farm actions on your own land unless an NPC/player/door/board
14574            // is in interact range (those still win).
14575            let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
14576                self.state.npcs.iter().any(|n| n.id == id)
14577                    || self.state.hired_workers.iter().any(|w| w.instance_id == id)
14578                    || self.state.doors.iter().any(|d| d.id == id)
14579                    || self.state.interactables.iter().any(|i| {
14580                        i.id == id
14581                            && matches!(i.kind.as_str(), "quest_board" | "well" | "exit" | "enter")
14582                    })
14583                    || id.parse::<EntityId>().is_ok_and(|eid| {
14584                        self.state
14585                            .entities
14586                            .iter()
14587                            .any(|e| e.id == eid && e.id != self.state.entity_id)
14588                    })
14589            });
14590            if !blocking_interact {
14591                // On your plot, `f` is harvest only — use `c` / `p` for till and plant.
14592                match self.harvest_nearest().await {
14593                    Ok(()) => return Ok(()),
14594                    Err(err) => {
14595                        let msg = err.to_string();
14596                        if !(msg.contains("no harvestable")
14597                            || msg.contains("press p")
14598                            || msg.contains("press f")
14599                            || msg.contains("nothing"))
14600                        {
14601                            return Err(err);
14602                        }
14603                    }
14604                }
14605                return Ok(());
14606            }
14607        }
14608        if self.state.nearest_interact_target().is_some() {
14609            return self.interact_nearest().await;
14610        }
14611        // Prefer a clear "move closer" when a board is visible but out of reach,
14612        // instead of silently falling through to harvest.
14613        if let Some((label, dist)) = self.state.nearest_quest_board() {
14614            if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
14615                anyhow::bail!(
14616                    "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
14617                );
14618            }
14619        }
14620
14621        match self.harvest_nearest().await {
14622            Ok(()) => Ok(()),
14623            Err(err) => {
14624                let msg = err.to_string();
14625                if msg.contains("no harvestable")
14626                    || msg.contains("press p")
14627                    || msg.contains("press f")
14628                {
14629                    anyhow::bail!(
14630                        "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
14631                    );
14632                }
14633                Err(err)
14634            }
14635        }
14636    }
14637
14638    /// Enter claim-mode footprint editor on unclaimed crown land (`k`).
14639    pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
14640        if !self.state.is_alive() {
14641            anyhow::bail!("you are dead");
14642        }
14643        if self.state.claim_mode.is_some() {
14644            anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
14645        }
14646        let zone = self
14647            .state
14648            .free_property_zone_under_player()
14649            .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
14650        let zone_id = zone.id.clone();
14651        let label = zone
14652            .label
14653            .as_deref()
14654            .filter(|s| !s.trim().is_empty())
14655            .unwrap_or(zone.id.as_str())
14656            .to_string();
14657        self.enter_claim_mode(&zone_id);
14658        self.state.push_log(format!(
14659            "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
14660        ));
14661        Ok(())
14662    }
14663
14664    /// Enter claim mode over a property zone (default 4×4 or min size).
14665    pub fn enter_claim_mode(&mut self, zone_id: &str) {
14666        let Some(zone) = self
14667            .state
14668            .property_zones
14669            .iter()
14670            .find(|z| z.id == zone_id)
14671            .cloned()
14672        else {
14673            self.state.push_log("unknown property zone");
14674            return;
14675        };
14676        self.state.sell_plot_confirm = None;
14677        self.state.sell_plot_armed_at = None;
14678        let min_area = self
14679            .state
14680            .property_plot_settings
14681            .as_ref()
14682            .map(|s| s.min_plot_area_m2)
14683            .unwrap_or(4.0)
14684            .max(1.0);
14685        let min_side = min_area.sqrt().ceil().max(1.0) as u32;
14686        let side = 4u32.max(min_side);
14687        let (px, py) = self.state.player_position();
14688        let anchor_x = px.floor();
14689        let anchor_y = py.floor();
14690        self.state.claim_mode = Some(ClaimModeState {
14691            zone_id: zone.id.clone(),
14692            width_m: side,
14693            height_m: side,
14694            anchor_x,
14695            anchor_y,
14696        });
14697        let label = zone
14698            .label
14699            .as_deref()
14700            .filter(|s| !s.trim().is_empty())
14701            .unwrap_or(zone.id.as_str());
14702        self.state.push_log(format!(
14703            "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
14704        ));
14705    }
14706
14707    pub fn cancel_claim_mode(&mut self) {
14708        if self.state.claim_mode.take().is_some() {
14709            self.state.push_log("Claim cancelled");
14710        }
14711    }
14712
14713    /// Enter relocate mode for a placed container (1×1 ghost).
14714    pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
14715        if !self.state.is_alive() {
14716            anyhow::bail!("you are dead");
14717        }
14718        if self.state.relocate_mode.is_some() {
14719            anyhow::bail!("already relocating — Enter confirm, Esc cancel");
14720        }
14721        if self.state.claim_mode.is_some() {
14722            anyhow::bail!("finish or cancel claim mode first");
14723        }
14724        let chest = self
14725            .state
14726            .placed_containers
14727            .iter()
14728            .find(|c| c.id == container_id)
14729            .cloned()
14730            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
14731        let (px, py) = self.state.player_position();
14732        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
14733            anyhow::bail!("too far from {}", chest.display_name);
14734        }
14735        if chest.locked && !chest.accessible {
14736            anyhow::bail!(
14737                "need the matching key for {} before moving it",
14738                chest.display_name
14739            );
14740        }
14741        let label = if chest.display_name.trim().is_empty() {
14742            chest.template_id.clone()
14743        } else {
14744            chest.display_name.clone()
14745        };
14746        self.state.relocate_mode = Some(RelocateModeState {
14747            container_id: chest.id.clone(),
14748            label: label.clone(),
14749            cursor_x: chest.x.floor() + 0.5,
14750            cursor_y: chest.y.floor() + 0.5,
14751        });
14752        self.state.push_log(format!(
14753            "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
14754        ));
14755        Ok(())
14756    }
14757
14758    /// World `Shift+m`: relocate nearest owned/accessible placed container.
14759    pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
14760        let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
14761            anyhow::bail!("no chest nearby to relocate");
14762        };
14763        if chest.locked && !chest.accessible {
14764            anyhow::bail!(
14765                "need the matching key for {} before moving it",
14766                chest.display_name
14767            );
14768        }
14769        // Prefer own chests: if owner is set and we're not the owner, skip unless accessible unlocked?
14770        // Server enforces ownership; client starts on nearest accessible chest in range.
14771        self.begin_relocate_container(&chest.id)
14772    }
14773
14774    pub fn cancel_relocate_mode(&mut self) {
14775        if self.state.relocate_mode.take().is_some() {
14776            self.state.push_log("Relocate cancelled");
14777        }
14778    }
14779
14780    pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
14781        let Some(mode) = self.state.relocate_mode.as_mut() else {
14782            return;
14783        };
14784        let max_x = self.state.world_width_m.max(1.0);
14785        let max_y = self.state.world_height_m.max(1.0);
14786        let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
14787        let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
14788        mode.cursor_x = nx.floor() + 0.5;
14789        mode.cursor_y = ny.floor() + 0.5;
14790    }
14791
14792    pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
14793        let Some(mode) = self.state.relocate_mode.as_mut() else {
14794            return;
14795        };
14796        let max_x = self.state.world_width_m.max(1.0);
14797        let max_y = self.state.world_height_m.max(1.0);
14798        mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
14799        mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
14800    }
14801
14802    pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
14803        if !self.state.is_alive() {
14804            anyhow::bail!("you are dead");
14805        }
14806        let Some(mode) = self.state.relocate_mode.clone() else {
14807            anyhow::bail!("not relocating");
14808        };
14809        let (px, py) = self.state.player_position();
14810        let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
14811        if dist > 8.0 {
14812            anyhow::bail!("destination too far (max 8 m)");
14813        }
14814        self.seq += 1;
14815        self.session
14816            .submit_intent(Intent::MovePlacedContainer {
14817                entity_id: self.state.entity_id,
14818                container_id: mode.container_id.clone(),
14819                x: mode.cursor_x,
14820                y: mode.cursor_y,
14821                seq: self.seq,
14822            })
14823            .await?;
14824        self.state.intents_sent += 1;
14825        self.state.relocate_mode = None;
14826        self.state.push_log(format!("Moving {}…", mode.label));
14827        Ok(())
14828    }
14829
14830    pub fn claim_set_preset(&mut self, w: u32, h: u32) {
14831        let Some(mode) = self.state.claim_mode.as_mut() else {
14832            return;
14833        };
14834        mode.width_m = w.max(1);
14835        mode.height_m = h.max(1);
14836    }
14837
14838    pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
14839        let Some(mode) = self.state.claim_mode.as_mut() else {
14840            return;
14841        };
14842        let w = (mode.width_m as i32 + dw).max(1) as u32;
14843        let h = (mode.height_m as i32 + dh).max(1) as u32;
14844        mode.width_m = w;
14845        mode.height_m = h;
14846    }
14847
14848    /// Nudge the claim footprint SW corner one meter cell (WASD / arrows).
14849    pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
14850        let Some(mode) = self.state.claim_mode.as_mut() else {
14851            return;
14852        };
14853        let max_x = self.state.world_width_m.max(1.0);
14854        let max_y = self.state.world_height_m.max(1.0);
14855        let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
14856        let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
14857        mode.anchor_x = nx.floor();
14858        mode.anchor_y = ny.floor();
14859    }
14860
14861    pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
14862        if !self.state.is_alive() {
14863            anyhow::bail!("you are dead");
14864        }
14865        let Some(mode) = self.state.claim_mode.clone() else {
14866            anyhow::bail!("not in claim mode");
14867        };
14868        let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
14869            self.state.claim_quote()
14870        else {
14871            anyhow::bail!("cannot quote claim");
14872        };
14873        if !valid {
14874            anyhow::bail!(reason);
14875        }
14876        if !can_afford {
14877            anyhow::bail!(
14878                "not enough copper (need {})",
14879                crate::currency::format_copper(purchase)
14880            );
14881        }
14882        let (x0, y0, x1, y1) = self
14883            .state
14884            .claim_footprint_rect()
14885            .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
14886        let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
14887        self.seq += 1;
14888        self.session
14889            .submit_intent(Intent::BuyPlot {
14890                entity_id: self.state.entity_id,
14891                zone_id: mode.zone_id,
14892                x0,
14893                y0,
14894                x1,
14895                y1,
14896                seq: self.seq,
14897            })
14898            .await?;
14899        self.state.intents_sent += 1;
14900        self.state.claim_mode = None;
14901        self.state.push_log(format!(
14902            "Buying plot for {}",
14903            crate::currency::format_copper(purchase)
14904        ));
14905        Ok(())
14906    }
14907
14908    pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
14909        if !self.state.is_alive() {
14910            anyhow::bail!("you are dead");
14911        }
14912        let zone_id = self
14913            .state
14914            .claim_mode
14915            .as_ref()
14916            .map(|m| m.zone_id.clone())
14917            .or_else(|| {
14918                self.state
14919                    .free_property_zone_under_player()
14920                    .map(|z| z.id.clone())
14921            })
14922            .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
14923        self.seq += 1;
14924        self.session
14925            .submit_intent(Intent::BuyPlotAllFree {
14926                entity_id: self.state.entity_id,
14927                zone_id,
14928                seq: self.seq,
14929            })
14930            .await?;
14931        self.state.intents_sent += 1;
14932        self.state.claim_mode = None;
14933        self.state.push_log("Claiming largest free plot…");
14934        Ok(())
14935    }
14936
14937    pub async fn confirm_sell_plot_to_crown(&mut self, plot_id: uuid::Uuid) -> anyhow::Result<()> {
14938        if !self.state.is_alive() {
14939            anyhow::bail!("you are dead");
14940        }
14941        self.seq += 1;
14942        self.session
14943            .submit_intent(Intent::SellPlotToCrown {
14944                entity_id: self.state.entity_id,
14945                plot_id,
14946                seq: self.seq,
14947            })
14948            .await?;
14949        self.state.intents_sent += 1;
14950        self.state.sell_plot_confirm = None;
14951        self.state.sell_plot_armed_at = None;
14952        self.state.push_log("Selling plot to the crown…");
14953        Ok(())
14954    }
14955
14956    pub async fn set_plot_farm_public(
14957        &mut self,
14958        plot_id: uuid::Uuid,
14959        public: bool,
14960        public_tax_discount_bps: u32,
14961    ) -> anyhow::Result<()> {
14962        self.seq += 1;
14963        self.session
14964            .submit_intent(Intent::SetPlotFarmPublic {
14965                entity_id: self.state.entity_id,
14966                plot_id,
14967                public,
14968                public_tax_discount_bps,
14969                seq: self.seq,
14970            })
14971            .await?;
14972        self.state.intents_sent += 1;
14973        Ok(())
14974    }
14975
14976    pub async fn plot_farm_allow_upsert(
14977        &mut self,
14978        plot_id: uuid::Uuid,
14979        character_id: Option<uuid::Uuid>,
14980        character_name: String,
14981        tax_discount_bps: u32,
14982    ) -> anyhow::Result<()> {
14983        self.seq += 1;
14984        self.session
14985            .submit_intent(Intent::PlotFarmAllowUpsert {
14986                entity_id: self.state.entity_id,
14987                plot_id,
14988                character_id,
14989                character_name,
14990                tax_discount_bps,
14991                seq: self.seq,
14992            })
14993            .await?;
14994        self.state.intents_sent += 1;
14995        Ok(())
14996    }
14997
14998    pub async fn plot_farm_allow_remove(
14999        &mut self,
15000        plot_id: uuid::Uuid,
15001        character_id: uuid::Uuid,
15002    ) -> anyhow::Result<()> {
15003        self.seq += 1;
15004        self.session
15005            .submit_intent(Intent::PlotFarmAllowRemove {
15006                entity_id: self.state.entity_id,
15007                plot_id,
15008                character_id,
15009                seq: self.seq,
15010            })
15011            .await?;
15012        self.state.intents_sent += 1;
15013        Ok(())
15014    }
15015
15016    pub fn open_farm_access_panel(&mut self) {
15017        let Some(plot) = self.state.my_plot_under_player() else {
15018            self.state
15019                .push_log("Stand on your deed plot to manage farm access");
15020            return;
15021        };
15022        self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
15023        self.state.farm_access_index = 0;
15024        self.state.show_farm_access = true;
15025    }
15026
15027    pub fn close_farm_access_panel(&mut self) {
15028        self.state.show_farm_access = false;
15029        self.state.farm_access_name_draft.clear();
15030        self.state.farm_access_index = 0;
15031    }
15032
15033    pub fn farm_access_move(&mut self, delta: i32) {
15034        let n = self.farm_access_row_count().max(1);
15035        let idx = self.state.farm_access_index as i32 + delta;
15036        self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
15037    }
15038
15039    pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
15040        let Some(plot) = self.state.my_plot_under_player() else {
15041            return vec![FarmAccessRow::PublicToggle];
15042        };
15043        let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
15044        for g in &plot.farm_allow {
15045            rows.push(FarmAccessRow::AllowRemove {
15046                character_id: g.character_id,
15047                label: if g.character_label.trim().is_empty() {
15048                    g.character_id.to_string()[..8].to_string()
15049                } else {
15050                    g.character_label.clone()
15051                },
15052                tax_discount_bps: g.tax_discount_bps,
15053            });
15054        }
15055        for e in &self.state.entities {
15056            if e.id == self.state.entity_id || e.label.trim().is_empty() {
15057                continue;
15058            }
15059            if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
15060                continue;
15061            }
15062            if self
15063                .state
15064                .npcs
15065                .iter()
15066                .any(|n| n.id == e.label || n.label == e.label)
15067            {
15068                continue;
15069            }
15070            if plot
15071                .farm_allow
15072                .iter()
15073                .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
15074            {
15075                continue;
15076            }
15077            rows.push(FarmAccessRow::NearbyAdd {
15078                name: e.label.clone(),
15079            });
15080        }
15081        rows
15082    }
15083
15084    pub fn farm_access_row_count(&self) -> usize {
15085        self.farm_access_rows().len().max(1)
15086    }
15087
15088    pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
15089        let Some(plot) = self.state.my_plot_under_player().cloned() else {
15090            self.close_farm_access_panel();
15091            return Ok(());
15092        };
15093        let rows = self.farm_access_rows();
15094        let Some(row) = rows.get(self.state.farm_access_index) else {
15095            return Ok(());
15096        };
15097        match row {
15098            FarmAccessRow::PublicToggle => {
15099                self.set_plot_farm_public(
15100                    plot.plot_id,
15101                    !plot.farm_public,
15102                    plot.public_tax_discount_bps,
15103                )
15104                .await
15105            }
15106            FarmAccessRow::PublicDiscount => Ok(()),
15107            FarmAccessRow::AllowRemove { character_id, .. } => {
15108                self.plot_farm_allow_remove(plot.plot_id, *character_id)
15109                    .await
15110            }
15111            FarmAccessRow::NearbyAdd { name } => {
15112                let disc = self
15113                    .state
15114                    .farm_access_discount_bps
15115                    .max(plot.public_tax_discount_bps);
15116                self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
15117                    .await
15118            }
15119        }
15120    }
15121
15122    pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
15123        let Some(plot) = self.state.my_plot_under_player().cloned() else {
15124            return Ok(());
15125        };
15126        let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
15127        self.state.farm_access_discount_bps = next;
15128        self.state.farm_access_index = 1;
15129        self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
15130            .await
15131    }
15132
15133    /// Till the cell under you on a farmable plot (`c`).
15134    pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
15135        if self.state.farmable_plot_under_player().is_none() {
15136            anyhow::bail!("stand on a farmable plot to cultivate");
15137        }
15138        let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
15139            let (px, py) = self.state.player_position();
15140            if self
15141                .state
15142                .terrain_at(px, py)
15143                .is_some_and(|k| k == TerrainKindView::Tilled)
15144            {
15145                anyhow::bail!("already tilled — stand on bare soil and press c");
15146            }
15147            anyhow::bail!("cannot till this cell — move onto soil on your plot");
15148        };
15149        self.cultivate_at(tx, ty).await
15150    }
15151
15152    /// Plant seeds on empty tilled soil under you (`p`).
15153    pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
15154        if self.state.farmable_plot_under_player().is_none() {
15155            anyhow::bail!("stand on a farmable plot to plant");
15156        }
15157        if !self.state.underfoot_free_tilled_plant_slot() {
15158            anyhow::bail!("stand on empty tilled soil and press p");
15159        }
15160        let seeds = self.state.farm_seed_entries();
15161        if seeds.is_empty() {
15162            anyhow::bail!("no seeds in inventory — buy seeds from Eli");
15163        }
15164        if seeds.len() == 1 {
15165            return self.plant_seeds(seeds[0].0.clone(), 1).await;
15166        }
15167        self.open_plant_menu();
15168        Ok(())
15169    }
15170
15171    /// Open the wall/roof build picker on the owned plot underfoot (`B`).
15172    pub fn open_plot_build_menu(&mut self) -> anyhow::Result<()> {
15173        let Some(plot) = self.state.my_plot_under_player() else {
15174            anyhow::bail!("stand on your plot to build");
15175        };
15176        if plot.building_id.is_some() {
15177            anyhow::bail!("this plot already has a building");
15178        }
15179        let building_now = self
15180            .state
15181            .timed_channel
15182            .as_ref()
15183            .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
15184        if !building_now && self.state.building_materials.is_empty() {
15185            anyhow::bail!("no building materials loaded — wait a moment and try again");
15186        }
15187        self.state.show_plot_build_menu = true;
15188        self.state.show_craft_menu = false;
15189        self.state.show_shop_menu = false;
15190        self.state.shop_catalog = None;
15191        self.state.show_stats = false;
15192        self.state.show_inventory_menu = false;
15193        self.state.plot_build_focus_wall = true;
15194        let walls = self.state.plot_build_wall_options().len();
15195        let roofs = self.state.plot_build_roof_options().len();
15196        if walls > 0 {
15197            self.state.plot_build_wall_index = self.state.plot_build_wall_index.min(walls - 1);
15198        } else {
15199            self.state.plot_build_wall_index = 0;
15200        }
15201        if roofs > 0 {
15202            self.state.plot_build_roof_index = self.state.plot_build_roof_index.min(roofs - 1);
15203        } else {
15204            self.state.plot_build_roof_index = 0;
15205        }
15206        Ok(())
15207    }
15208
15209    pub fn close_plot_build_menu(&mut self) {
15210        self.state.show_plot_build_menu = false;
15211    }
15212
15213    pub fn plot_build_menu_move(&mut self, delta: i32) {
15214        let walls = self.state.plot_build_wall_options();
15215        let roofs = self.state.plot_build_roof_options();
15216        if self.state.plot_build_focus_wall {
15217            if walls.is_empty() {
15218                return;
15219            }
15220            let n = walls.len() as i32;
15221            let cur = self.state.plot_build_wall_index as i32;
15222            self.state.plot_build_wall_index = ((cur + delta).rem_euclid(n)) as usize;
15223        } else {
15224            if roofs.is_empty() {
15225                return;
15226            }
15227            let n = roofs.len() as i32;
15228            let cur = self.state.plot_build_roof_index as i32;
15229            self.state.plot_build_roof_index = ((cur + delta).rem_euclid(n)) as usize;
15230        }
15231    }
15232
15233    pub fn plot_build_menu_toggle_focus(&mut self) {
15234        self.state.plot_build_focus_wall = !self.state.plot_build_focus_wall;
15235    }
15236
15237    /// Confirm selection from the B menu.
15238    pub async fn plot_build_menu_confirm(&mut self) -> anyhow::Result<()> {
15239        let wall = self
15240            .state
15241            .plot_build_selected_wall()
15242            .ok_or_else(|| anyhow::anyhow!("pick a wall material"))?
15243            .id
15244            .clone();
15245        let roof = self
15246            .state
15247            .plot_build_selected_roof()
15248            .ok_or_else(|| anyhow::anyhow!("pick a roof material"))?
15249            .id
15250            .clone();
15251        // Keep the B menu open so it switches to the in-progress panel.
15252        self.start_plot_build(&wall, &roof).await
15253    }
15254
15255    /// Cancel an in-progress plot build (materials returned).
15256    pub async fn plot_build_menu_cancel_build(&mut self) -> anyhow::Result<()> {
15257        self.seq += 1;
15258        self.session
15259            .submit_intent(Intent::CancelPlotBuild {
15260                entity_id: self.state.entity_id,
15261                seq: self.seq,
15262            })
15263            .await?;
15264        self.state.intents_sent += 1;
15265        Ok(())
15266    }
15267
15268    /// Start timed shell craft on the plot's solid tilled rectangle.
15269    pub async fn start_plot_build(
15270        &mut self,
15271        wall_material_id: &str,
15272        roof_material_id: &str,
15273    ) -> anyhow::Result<()> {
15274        let Some(plot) = self.state.my_plot_under_player() else {
15275            anyhow::bail!("stand on your plot to build");
15276        };
15277        if plot.building_id.is_some() {
15278            anyhow::bail!("this plot already has a building");
15279        }
15280        let plot_id = plot.plot_id;
15281        self.seq += 1;
15282        self.session
15283            .submit_intent(Intent::StartPlotBuild {
15284                entity_id: self.state.entity_id,
15285                plot_id,
15286                wall_material_id: wall_material_id.to_string(),
15287                roof_material_id: roof_material_id.to_string(),
15288                seq: self.seq,
15289            })
15290            .await?;
15291        self.state.intents_sent += 1;
15292        Ok(())
15293    }
15294
15295    /// Lock/unlock nearest exterior door when you hold its key (`l` / `L`).
15296    pub async fn toggle_nearby_door_lock(&mut self) -> anyhow::Result<()> {
15297        let (px, py) = self.state.player_position();
15298        let mut best: Option<(f32, String, bool)> = None;
15299        for d in &self.state.doors {
15300            if d.lock_id.is_none() {
15301                continue;
15302            }
15303            let dist = (d.x - px).hypot(d.y - py);
15304            if dist > DOOR_INTERACTION_RADIUS_M {
15305                continue;
15306            }
15307            if best.as_ref().is_none_or(|(bd, _, _)| dist < *bd) {
15308                best = Some((dist, d.id.clone(), d.locked));
15309            }
15310        }
15311        let Some((_, door_id, locked_now)) = best else {
15312            anyhow::bail!("no lockable door nearby");
15313        };
15314        let locked = !locked_now;
15315        self.seq += 1;
15316        self.session
15317            .submit_intent(Intent::SetDoorLocked {
15318                entity_id: self.state.entity_id,
15319                door_id,
15320                locked,
15321                seq: self.seq,
15322            })
15323            .await?;
15324        self.state.intents_sent += 1;
15325        Ok(())
15326    }
15327
15328    /// Enter through a nearby open player-building door (`Enter`).
15329    pub async fn enter_nearby_open_door(&mut self) -> anyhow::Result<()> {
15330        if !self.state.is_alive() {
15331            anyhow::bail!("you are dead");
15332        }
15333        if self.state.effective_inside_building().is_some() {
15334            anyhow::bail!("already inside");
15335        }
15336        let (px, py) = self.state.player_position();
15337        let mut best: Option<(f32, String)> = None;
15338        for d in &self.state.doors {
15339            if !d.open || d.locked {
15340                continue;
15341            }
15342            let player_house = self
15343                .state
15344                .buildings
15345                .iter()
15346                .find(|b| b.id == d.building_id)
15347                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15348            if !player_house {
15349                continue;
15350            }
15351            let dist = (d.x - px).hypot(d.y - py);
15352            if dist > DOOR_INTERACTION_RADIUS_M {
15353                continue;
15354            }
15355            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15356                best = Some((dist, d.id.clone()));
15357            }
15358        }
15359        let Some((_, door_id)) = best else {
15360            anyhow::bail!("no open house door nearby — open with f first");
15361        };
15362        self.seq += 1;
15363        self.session
15364            .submit_intent(Intent::EnterBuildingDoor {
15365                entity_id: self.state.entity_id,
15366                door_id,
15367                seq: self.seq,
15368            })
15369            .await?;
15370        self.state.intents_sent += 1;
15371        Ok(())
15372    }
15373
15374    /// Exit through a nearby exterior portal from inside a player house (`Enter`).
15375    /// Works even when the door is closed or locked.
15376    pub async fn exit_nearby_building_door(&mut self) -> anyhow::Result<()> {
15377        if !self.state.is_alive() {
15378            anyhow::bail!("you are dead");
15379        }
15380        let Some(bid) = self.state.effective_inside_building() else {
15381            anyhow::bail!("not inside a building");
15382        };
15383        let (px, py) = self.state.player_position();
15384        let mut best: Option<(f32, String)> = None;
15385        for d in &self.state.doors {
15386            if d.building_id != bid || d.portal.is_none() {
15387                continue;
15388            }
15389            let player_house = self
15390                .state
15391                .buildings
15392                .iter()
15393                .find(|b| b.id == d.building_id)
15394                .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
15395            if !player_house {
15396                continue;
15397            }
15398            let dist = (d.x - px).hypot(d.y - py);
15399            if dist > 1.5 {
15400                continue;
15401            }
15402            if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
15403                best = Some((dist, d.id.clone()));
15404            }
15405        }
15406        let Some((_, door_id)) = best else {
15407            anyhow::bail!("stand by the door to exit");
15408        };
15409        self.seq += 1;
15410        self.session
15411            .submit_intent(Intent::ExitBuildingDoor {
15412                entity_id: self.state.entity_id,
15413                door_id,
15414                seq: self.seq,
15415            })
15416            .await?;
15417        self.state.intents_sent += 1;
15418        Ok(())
15419    }
15420
15421    /// Confirm interior layout for your player building (copper charged on server).
15422    pub async fn confirm_interior_edit(
15423        &mut self,
15424        building_id: String,
15425        rooms: Vec<flatland_protocol::InteriorRoomEdit>,
15426        room_doors: Vec<flatland_protocol::InteriorRoomDoorEdit>,
15427    ) -> anyhow::Result<()> {
15428        self.seq += 1;
15429        self.session
15430            .submit_intent(Intent::ConfirmInteriorEdit {
15431                entity_id: self.state.entity_id,
15432                building_id,
15433                rooms,
15434                room_doors,
15435                seq: self.seq,
15436            })
15437            .await?;
15438        self.state.intents_sent += 1;
15439        Ok(())
15440    }
15441
15442    pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
15443        if !self.state.is_alive() {
15444            anyhow::bail!("you are dead");
15445        }
15446        self.seq += 1;
15447        self.session
15448            .submit_intent(Intent::Cultivate {
15449                entity_id: self.state.entity_id,
15450                x,
15451                y,
15452                seq: self.seq,
15453            })
15454            .await?;
15455        self.state.intents_sent += 1;
15456        Ok(())
15457    }
15458
15459    pub async fn plant_seeds(
15460        &mut self,
15461        seed_template_id: String,
15462        quantity: u32,
15463    ) -> anyhow::Result<()> {
15464        if !self.state.is_alive() {
15465            anyhow::bail!("you are dead");
15466        }
15467        self.seq += 1;
15468        self.session
15469            .submit_intent(Intent::PlantSeeds {
15470                entity_id: self.state.entity_id,
15471                seed_template_id: seed_template_id.clone(),
15472                quantity,
15473                seq: self.seq,
15474            })
15475            .await?;
15476        self.state.intents_sent += 1;
15477        self.state
15478            .push_log(format!("Planting {quantity}× {seed_template_id}…"));
15479        Ok(())
15480    }
15481
15482    pub fn open_plant_menu(&mut self) {
15483        if self.state.farm_seed_entries().is_empty() {
15484            self.state.push_log("No seeds in inventory to plant");
15485            return;
15486        }
15487        self.state.show_plant_menu = true;
15488        self.state.plant_menu_index = 0;
15489        self.state.plant_quantity = 1;
15490        self.state.clamp_plant_menu();
15491    }
15492
15493    pub fn close_plant_menu(&mut self) {
15494        self.state.show_plant_menu = false;
15495    }
15496
15497    pub fn plant_menu_move(&mut self, delta: i32) {
15498        let n = self.state.farm_seed_entries().len();
15499        if n == 0 {
15500            return;
15501        }
15502        let idx = self.state.plant_menu_index as i32 + delta;
15503        self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
15504        self.state.clamp_plant_menu();
15505    }
15506
15507    pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
15508        let next = self.state.plant_quantity as i32 + delta;
15509        self.state.plant_quantity = next.max(1) as u32;
15510        self.state.clamp_plant_menu();
15511    }
15512
15513    pub fn plant_menu_set_quantity_max(&mut self) {
15514        if let Some((_, max, _)) = self.state.plant_menu_selection() {
15515            self.state.plant_quantity = max;
15516        }
15517        self.state.clamp_plant_menu();
15518    }
15519
15520    pub fn plant_menu_set_quantity_min(&mut self) {
15521        self.state.plant_quantity = 1;
15522        self.state.clamp_plant_menu();
15523    }
15524
15525    pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
15526        let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
15527            self.close_plant_menu();
15528            anyhow::bail!("no seeds to plant");
15529        };
15530        self.close_plant_menu();
15531        self.plant_seeds(seed, qty).await?;
15532        self.state.push_log(format!("Planted {qty}× {label}"));
15533        Ok(())
15534    }
15535
15536    /// Activate the ability or consumable bound to hotbar slot `1`–`9`.
15537    /// Heals prefer T2/self; other abilities prefer T1. Consumables need no target.
15538    pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
15539        if !self.state.is_alive() {
15540            anyhow::bail!("you are dead");
15541        }
15542        let binding = self
15543            .state
15544            .hotbar_ability(slot)
15545            .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
15546            .to_string();
15547        if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
15548            let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
15549            if qty == 0 {
15550                anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
15551            }
15552            return self.use_item(template_id).await;
15553        }
15554        let ability_id = binding;
15555        if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
15556            return self
15557                .cast_ability(&ability_id, Some(self.state.entity_id))
15558                .await;
15559        }
15560        let is_heal = ability_id == "heal_touch"
15561            || self
15562                .state
15563                .ability_meta
15564                .get(&ability_id)
15565                .map(|meta| meta.is_heal)
15566                .unwrap_or(false);
15567        let target = if is_heal {
15568            Some(
15569                self.state
15570                    .target_for_slot(2)
15571                    .unwrap_or(self.state.entity_id),
15572            )
15573        } else {
15574            self.state
15575                .target_for_slot(1)
15576                .or_else(|| self.state.target_for_slot(2))
15577        };
15578        let Some(target_id) = target else {
15579            anyhow::bail!("no target — Tab to select, then press the hotbar key");
15580        };
15581        self.cast_ability(&ability_id, Some(target_id)).await
15582    }
15583
15584    /// Bind or clear a hotbar slot (`1`–`9`) via [`Intent::SetHotbarSlot`].
15585    /// `ability_id` may be a learned ability or `item:<template_id>` for consumables.
15586    pub async fn set_hotbar_slot(
15587        &mut self,
15588        slot: u8,
15589        ability_id: Option<&str>,
15590    ) -> anyhow::Result<()> {
15591        if !self.state.is_alive() {
15592            anyhow::bail!("you are dead");
15593        }
15594        if !(1..=9).contains(&slot) {
15595            anyhow::bail!("hotbar slot must be 1–9");
15596        }
15597        let ability_id = ability_id
15598            .map(str::trim)
15599            .filter(|id| !id.is_empty())
15600            .map(str::to_string);
15601        self.seq += 1;
15602        self.session
15603            .submit_intent(Intent::SetHotbarSlot {
15604                entity_id: self.state.entity_id,
15605                slot,
15606                ability_id: ability_id.clone(),
15607                seq: self.seq,
15608            })
15609            .await?;
15610        self.state.intents_sent += 1;
15611        let idx = (slot - 1) as usize;
15612        if self.state.hotbar.len() < 9 {
15613            self.state.hotbar.resize(9, None);
15614        }
15615        if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
15616            *slot_mut = ability_id.clone();
15617        }
15618        match ability_id {
15619            Some(id) => {
15620                let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
15621                    format!("use {tid}")
15622                } else {
15623                    id
15624                };
15625                self.state.push_log(format!("Hotbar {slot} → {label}"))
15626            }
15627            None => self.state.push_log(format!("Hotbar {slot} cleared")),
15628        }
15629        Ok(())
15630    }
15631
15632    pub fn npc_verb_options(&self) -> Vec<NpcVerbChoice> {
15633        self.state.npc_verb_options()
15634    }
15635
15636    pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
15637        let Some(npc_id) = self.state.npc_verb_target.clone() else {
15638            return Ok(());
15639        };
15640        let options = self.npc_verb_options();
15641        let choice = options
15642            .get(self.state.npc_verb_index)
15643            .cloned()
15644            .unwrap_or_else(GameState::talk_choice);
15645        match choice.action {
15646            NpcVerbAction::QuestGive { quest_id } => {
15647                self.submit_npc_quest_turn_in(&npc_id, Some(&quest_id))
15648                    .await?;
15649                self.state.show_npc_verb_menu = false;
15650            }
15651            NpcVerbAction::Talk => {
15652                self.open_npc_talk(&npc_id, None).await?;
15653            }
15654            NpcVerbAction::QuestTalk { quest_id } => {
15655                self.open_npc_talk(&npc_id, Some(&quest_id)).await?;
15656            }
15657            NpcVerbAction::Trade | NpcVerbAction::Bank | NpcVerbAction::Storage | NpcVerbAction::Market => {
15658                self.seq += 1;
15659                self.session
15660                    .submit_intent(Intent::Interact {
15661                        entity_id: self.state.entity_id,
15662                        target_id: npc_id,
15663                        seq: self.seq,
15664                    })
15665                    .await?;
15666                self.state.intents_sent += 1;
15667            }
15668        }
15669        Ok(())
15670    }
15671
15672    async fn open_npc_talk(&mut self, npc_id: &str, quest_id: Option<&str>) -> anyhow::Result<()> {
15673        self.seq += 1;
15674        self.session
15675            .submit_intent(Intent::NpcTalkOpen {
15676                entity_id: self.state.entity_id,
15677                npc_id: npc_id.to_string(),
15678                quest_id: quest_id.map(str::to_string),
15679                seq: self.seq,
15680            })
15681            .await?;
15682        self.state.intents_sent += 1;
15683        Ok(())
15684    }
15685
15686    async fn submit_npc_quest_turn_in(
15687        &mut self,
15688        npc_id: &str,
15689        quest_id: Option<&str>,
15690    ) -> anyhow::Result<()> {
15691        let catalog_ref = self.state.npc_quest_catalog_ref(npc_id);
15692        let pending: Vec<(String, u32, String)> = self
15693            .state
15694            .quest_log
15695            .iter()
15696            .filter(|q| {
15697                q.status == flatland_protocol::QuestStatusView::Active
15698                    && quest_id.is_none_or(|id| q.quest_id == id)
15699            })
15700            .flat_map(|q| q.objectives.iter())
15701            .filter(|o| {
15702                !o.done
15703                    && o.kind == "give_item"
15704                    && o.npc_ref.as_deref() == Some(catalog_ref.as_str())
15705            })
15706            .filter_map(|o| {
15707                let template = o.item_template.clone()?;
15708                let remaining = o.required.saturating_sub(o.current);
15709                if remaining == 0 {
15710                    return None;
15711                }
15712                Some((template, remaining, o.label.clone()))
15713            })
15714            .collect();
15715        if pending.is_empty() {
15716            self.state.push_log("Nothing to turn in here.");
15717            return Ok(());
15718        }
15719        let mut sent = 0u32;
15720        for (template, remaining, label) in pending {
15721            let held = self.state.count_inventory_template(&template);
15722            let qty = remaining.min(held);
15723            if qty == 0 {
15724                self.state.push_log(format!("Need {label}"));
15725                continue;
15726            }
15727            self.seq += 1;
15728            self.session
15729                .submit_intent(Intent::QuestGiveItem {
15730                    entity_id: self.state.entity_id,
15731                    npc_id: npc_id.to_string(),
15732                    template_id: template,
15733                    quantity: qty,
15734                    seq: self.seq,
15735                })
15736                .await?;
15737            self.state.intents_sent += 1;
15738            sent += 1;
15739        }
15740        if sent > 0 {
15741            self.state.push_log("Turning in quest items.");
15742        }
15743        Ok(())
15744    }
15745
15746    pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
15747        let Some(chat) = self.state.npc_chat.clone() else {
15748            return Ok(());
15749        };
15750        let message = chat.input.trim().to_string();
15751        if message.is_empty() || chat.pending {
15752            return Ok(());
15753        }
15754        if let Some(c) = self.state.npc_chat.as_mut() {
15755            c.lines.push(format!("You: {message}"));
15756            c.input.clear();
15757            c.pending = true;
15758        }
15759        self.seq += 1;
15760        self.session
15761            .submit_intent(Intent::NpcTalkSay {
15762                entity_id: self.state.entity_id,
15763                npc_id: chat.npc_id,
15764                message,
15765                seq: self.seq,
15766            })
15767            .await?;
15768        self.state.intents_sent += 1;
15769        Ok(())
15770    }
15771
15772    pub async fn npc_talk_topic(&mut self, index: usize) -> anyhow::Result<()> {
15773        let topic = self
15774            .state
15775            .npc_chat
15776            .as_ref()
15777            .and_then(|c| c.suggested_topics.get(index))
15778            .cloned();
15779        let Some(topic) = topic else {
15780            return Ok(());
15781        };
15782        if let Some(c) = self.state.npc_chat.as_mut() {
15783            if c.pending {
15784                return Ok(());
15785            }
15786            c.input = topic;
15787        }
15788        self.npc_talk_send().await
15789    }
15790
15791    pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
15792        let return_to_verbs = self.state.npc_verb_target.is_some();
15793        let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
15794            self.state.show_npc_chat = false;
15795            if return_to_verbs {
15796                self.state.show_npc_verb_menu = true;
15797            }
15798            return Ok(());
15799        };
15800        self.seq += 1;
15801        self.session
15802            .submit_intent(Intent::NpcTalkClose {
15803                entity_id: self.state.entity_id,
15804                npc_id,
15805                seq: self.seq,
15806            })
15807            .await?;
15808        self.state.intents_sent += 1;
15809        self.state.show_npc_chat = false;
15810        self.state.npc_chat = None;
15811        if return_to_verbs {
15812            self.state.show_npc_verb_menu = true;
15813            self.state.npc_verb_notice = None;
15814        }
15815        Ok(())
15816    }
15817
15818    /// Esc/back inside Talk, Trade, quest-offer, or the verb menu — pop one layer, not the whole session.
15819    pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
15820        if self.state.show_quest_offer
15821            && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
15822        {
15823            self.quest_offer_decline();
15824            return Ok(());
15825        }
15826        if self.state.show_npc_chat {
15827            return self.npc_talk_close().await;
15828        }
15829        if self.state.show_shop_menu {
15830            return self.back_from_shop_menu().await;
15831        }
15832        if self.state.bank_panel.is_some() {
15833            if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
15834                self.bank_transfer_back();
15835                return Ok(());
15836            }
15837            return self.close_bank_panel().await;
15838        }
15839        if self.state.storage_panel.is_some() {
15840            if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
15841                self.storage_ui_back();
15842                return Ok(());
15843            }
15844            return self.close_storage_panel().await;
15845        }
15846        if self.state.market_panel.is_some() {
15847            if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
15848                self.market_ui_back();
15849                return Ok(());
15850            }
15851            if self.state.market_buy_confirm.is_some() {
15852                self.state.market_buy_confirm = None;
15853                return Ok(());
15854            }
15855            return self.close_market_panel().await;
15856        }
15857        if self.state.show_npc_verb_menu {
15858            self.state.show_npc_verb_menu = false;
15859            self.state.npc_verb_target = None;
15860        }
15861        Ok(())
15862    }
15863
15864    pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
15865        self.seq += 1;
15866        self.session
15867            .submit_intent(Intent::TestDamage {
15868                entity_id: self.state.entity_id,
15869                amount,
15870                seq: self.seq,
15871            })
15872            .await?;
15873        self.state.intents_sent += 1;
15874        Ok(())
15875    }
15876
15877    pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
15878        self.cycle_combat_target_slot(1, reverse).await
15879    }
15880
15881    pub async fn cycle_combat_target_slot(
15882        &mut self,
15883        slot_index: u8,
15884        reverse: bool,
15885    ) -> anyhow::Result<()> {
15886        if !self.state.is_alive() {
15887            anyhow::bail!("you are dead");
15888        }
15889        let candidates = self.state.candidates_for_slot(slot_index);
15890        if candidates.is_empty() {
15891            anyhow::bail!("no targets nearby");
15892        }
15893        let current = self.state.target_for_slot(slot_index);
15894        let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
15895        let next_idx = match idx {
15896            None => 0,
15897            Some(i) if reverse => {
15898                if i == 0 {
15899                    candidates.len() - 1
15900                } else {
15901                    i - 1
15902                }
15903            }
15904            Some(i) => (i + 1) % candidates.len(),
15905        };
15906        if idx == Some(next_idx) && candidates.len() == 1 {
15907            self.clear_combat_target_slot(slot_index).await?;
15908            return Ok(());
15909        }
15910        let (target_id, label) = candidates[next_idx].clone();
15911        self.set_combat_target_slot(slot_index, target_id, &label)
15912            .await
15913    }
15914
15915    pub async fn set_combat_target_slot(
15916        &mut self,
15917        slot_index: u8,
15918        target_id: EntityId,
15919        label: &str,
15920    ) -> anyhow::Result<()> {
15921        if !self.state.is_alive() {
15922            anyhow::bail!("you are dead");
15923        }
15924        self.seq += 1;
15925        self.session
15926            .submit_intent(Intent::SetTargetSlot {
15927                entity_id: self.state.entity_id,
15928                slot_index,
15929                target_id,
15930                seq: self.seq,
15931            })
15932            .await?;
15933        self.state.intents_sent += 1;
15934        if slot_index == 1 {
15935            self.state.combat_target = Some(target_id);
15936            self.state.combat_target_label = Some(label.to_string());
15937        }
15938        self.state
15939            .push_log(format!("Slot {slot_index} target: {label}"));
15940        Ok(())
15941    }
15942
15943    pub async fn set_combat_target(
15944        &mut self,
15945        target_id: EntityId,
15946        label: &str,
15947    ) -> anyhow::Result<()> {
15948        self.set_combat_target_slot(1, target_id, label).await
15949    }
15950
15951    pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
15952        if slot_index == 1 && self.state.combat_target.is_none() {
15953            return Ok(());
15954        }
15955        self.seq += 1;
15956        self.session
15957            .submit_intent(Intent::ClearTargetSlot {
15958                entity_id: self.state.entity_id,
15959                slot_index,
15960                seq: self.seq,
15961            })
15962            .await?;
15963        if slot_index == 1 {
15964            self.state.combat_target = None;
15965            self.state.combat_target_label = None;
15966        }
15967        self.state.intents_sent += 1;
15968        self.state
15969            .push_log(format!("Slot {slot_index} target cleared"));
15970        Ok(())
15971    }
15972
15973    pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
15974        self.clear_combat_target_slot(1).await
15975    }
15976
15977    pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
15978        if !self.state.is_alive() {
15979            anyhow::bail!("you are dead");
15980        }
15981        self.seq += 1;
15982        self.session
15983            .submit_intent(Intent::AdvanceRotation {
15984                entity_id: self.state.entity_id,
15985                slot_index,
15986                seq: self.seq,
15987            })
15988            .await?;
15989        self.state.intents_sent += 1;
15990        Ok(())
15991    }
15992
15993    pub async fn assign_slot_preset(
15994        &mut self,
15995        slot_index: u8,
15996        preset_id: &str,
15997    ) -> anyhow::Result<()> {
15998        if !self.state.is_alive() {
15999            anyhow::bail!("you are dead");
16000        }
16001        self.seq += 1;
16002        self.session
16003            .submit_intent(Intent::AssignSlotPreset {
16004                entity_id: self.state.entity_id,
16005                slot_index,
16006                preset_id: preset_id.to_string(),
16007                seq: self.seq,
16008            })
16009            .await?;
16010        self.state.intents_sent += 1;
16011        if let Some(slot) = self
16012            .state
16013            .combat_slots
16014            .iter_mut()
16015            .find(|s| s.slot_index == slot_index)
16016        {
16017            slot.preset_id = Some(preset_id.to_string());
16018            if let Some(preset) = self
16019                .state
16020                .rotation_presets
16021                .iter()
16022                .find(|p| p.id == preset_id)
16023            {
16024                slot.preset_label = Some(preset.label.clone());
16025                slot.rotation = preset.abilities.clone();
16026                slot.rotation_index = 0;
16027            }
16028        }
16029        self.state
16030            .push_log(format!("T{slot_index} loadout → {preset_id}"));
16031        Ok(())
16032    }
16033
16034    pub async fn cast_ability(
16035        &mut self,
16036        ability_id: &str,
16037        target_id: Option<EntityId>,
16038    ) -> anyhow::Result<()> {
16039        if !self.state.is_alive() {
16040            anyhow::bail!("you are dead");
16041        }
16042        let allows_ground = self.state.ability_allows_ground(ability_id);
16043        let requires_ground = self.state.ability_requires_ground(ability_id);
16044        if requires_ground && self.state.ground_target.is_none() {
16045            anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
16046        }
16047        let (resolved_target_id, target_point) = if allows_ground {
16048            if let Some((x, y, z)) = self.state.ground_target {
16049                (
16050                    target_id.unwrap_or(self.state.entity_id),
16051                    Some(flatland_protocol::AimPoint { x, y, z }),
16052                )
16053            } else {
16054                (
16055                    target_id
16056                        .or_else(|| self.state.target_for_slot(2))
16057                        .or_else(|| self.state.target_for_slot(1))
16058                        .unwrap_or(self.state.entity_id),
16059                    None,
16060                )
16061            }
16062        } else {
16063            (
16064                target_id
16065                    .or_else(|| self.state.target_for_slot(2))
16066                    .or_else(|| self.state.target_for_slot(1))
16067                    .unwrap_or(self.state.entity_id),
16068                None,
16069            )
16070        };
16071        self.seq += 1;
16072        self.session
16073            .submit_intent(Intent::Cast {
16074                entity_id: self.state.entity_id,
16075                ability_id: ability_id.to_string(),
16076                target_id: resolved_target_id,
16077                target_point,
16078                seq: self.seq,
16079            })
16080            .await?;
16081        self.state.intents_sent += 1;
16082        match target_point {
16083            Some(point) => self.state.push_log(format!(
16084                "Cast {ability_id} → ({:.1}, {:.1})",
16085                point.x, point.y
16086            )),
16087            None => self
16088                .state
16089                .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
16090        }
16091        Ok(())
16092    }
16093
16094    pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
16095        self.seq += 1;
16096        self.session
16097            .submit_intent(Intent::UpsertRotationPreset {
16098                entity_id: self.state.entity_id,
16099                preset: preset.clone(),
16100                seq: self.seq,
16101            })
16102            .await?;
16103        self.state.intents_sent += 1;
16104        if let Some(existing) = self
16105            .state
16106            .rotation_presets
16107            .iter_mut()
16108            .find(|p| p.id == preset.id)
16109        {
16110            *existing = preset.clone();
16111        } else {
16112            self.state.rotation_presets.push(preset.clone());
16113        }
16114        for slot in &mut self.state.combat_slots {
16115            if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
16116                slot.preset_label = Some(preset.label.clone());
16117                slot.rotation = preset.abilities.clone();
16118            }
16119        }
16120        self.state
16121            .push_log(format!("Saved rotation: {}", preset.label));
16122        Ok(())
16123    }
16124
16125    pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
16126        self.seq += 1;
16127        self.session
16128            .submit_intent(Intent::DeleteRotationPreset {
16129                entity_id: self.state.entity_id,
16130                preset_id: preset_id.to_string(),
16131                seq: self.seq,
16132            })
16133            .await?;
16134        self.state.intents_sent += 1;
16135        self.state.rotation_presets.retain(|p| p.id != preset_id);
16136        for slot in &mut self.state.combat_slots {
16137            if slot.preset_id.as_deref() == Some(preset_id) {
16138                slot.preset_id = None;
16139                slot.preset_label = None;
16140                slot.rotation.clear();
16141                slot.rotation_index = 0;
16142            }
16143        }
16144        self.state
16145            .push_log(format!("Deleted rotation: {preset_id}"));
16146        Ok(())
16147    }
16148
16149    pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
16150        if !self.state.is_alive() {
16151            anyhow::bail!("you are dead");
16152        }
16153        let enabled = !self
16154            .state
16155            .combat_slots
16156            .iter()
16157            .find(|s| s.slot_index == slot_index)
16158            .map(|s| s.auto_enabled)
16159            .unwrap_or(false);
16160        self.seq += 1;
16161        self.session
16162            .submit_intent(Intent::SetAutoAttack {
16163                entity_id: self.state.entity_id,
16164                slot_index,
16165                enabled,
16166                seq: self.seq,
16167            })
16168            .await?;
16169        if slot_index == 1 {
16170            self.state.auto_attack = enabled;
16171        }
16172        self.state.intents_sent += 1;
16173        self.state.push_log(format!(
16174            "T{slot_index} auto {}",
16175            if enabled { "ON" } else { "OFF" }
16176        ));
16177        Ok(())
16178    }
16179
16180    pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
16181        if !self.state.connected {
16182            anyhow::bail!("not connected");
16183        }
16184        if !self.state.is_alive() {
16185            anyhow::bail!("you are dead");
16186        }
16187        let (px, py) = self.state.player_position();
16188        if self
16189            .state
16190            .ground_drops
16191            .iter()
16192            .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
16193        {
16194            anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
16195        }
16196        self.seq += 1;
16197        self.session
16198            .submit_intent(Intent::Pickup {
16199                entity_id: self.state.entity_id,
16200                drop_id: None,
16201                seq: self.seq,
16202            })
16203            .await?;
16204        self.state.intents_sent += 1;
16205        self.state.push_audio(crate::social::AudioCue::LootPickup);
16206        Ok(())
16207    }
16208
16209    pub async fn dodge(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16210        if !self.state.is_alive() {
16211            anyhow::bail!("you are dead");
16212        }
16213        // Only currently held WASD counts — sticky last_move would skip smart dodge after walking.
16214        self.seq += 1;
16215        self.session
16216            .submit_intent(Intent::Dodge {
16217                entity_id: self.state.entity_id,
16218                forward,
16219                strafe,
16220                seq: self.seq,
16221            })
16222            .await?;
16223        self.state.intents_sent += 1;
16224        self.state.push_log("Dodge!");
16225        self.state.push_audio(crate::social::AudioCue::CombatDodge);
16226        Ok(())
16227    }
16228
16229    pub async fn lunge(&mut self) -> anyhow::Result<()> {
16230        if !self.state.is_alive() {
16231            anyhow::bail!("you are dead");
16232        }
16233        let (forward, strafe) = self.last_move_axes();
16234        self.seq += 1;
16235        self.session
16236            .submit_intent(Intent::Lunge {
16237                entity_id: self.state.entity_id,
16238                forward,
16239                strafe,
16240                seq: self.seq,
16241            })
16242            .await?;
16243        self.state.intents_sent += 1;
16244        self.state.push_log("Lunge!");
16245        Ok(())
16246    }
16247
16248    pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
16249        if !self.state.is_alive() {
16250            anyhow::bail!("you are dead");
16251        }
16252        self.seq += 1;
16253        self.session
16254            .submit_intent(Intent::DirectionalJump {
16255                entity_id: self.state.entity_id,
16256                forward,
16257                strafe,
16258                seq: self.seq,
16259            })
16260            .await?;
16261        self.state.intents_sent += 1;
16262        self.state.push_log("Jump!");
16263        Ok(())
16264    }
16265
16266    /// Remembered WASD axes for lunge when not currently moving.
16267    pub fn last_move_axes(&self) -> (f32, f32) {
16268        (self.last_move_forward, self.last_move_strafe)
16269    }
16270
16271    pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
16272        if !self.state.is_alive() {
16273            anyhow::bail!("you are dead");
16274        }
16275        self.seq += 1;
16276        self.session
16277            .submit_intent(Intent::Block {
16278                entity_id: self.state.entity_id,
16279                enabled,
16280                seq: self.seq,
16281            })
16282            .await?;
16283        self.state.intents_sent += 1;
16284        if enabled {
16285            self.state.push_log("Blocking");
16286            self.state.push_audio(crate::social::AudioCue::CombatBlock);
16287        }
16288        Ok(())
16289    }
16290
16291    pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
16292        if !self.state.is_alive() {
16293            anyhow::bail!("you are dead");
16294        }
16295        self.seq += 1;
16296        self.session
16297            .submit_intent(Intent::EquipMainhand {
16298                entity_id: self.state.entity_id,
16299                template_id,
16300                instance_id: None,
16301                seq: self.seq,
16302            })
16303            .await?;
16304        self.state.intents_sent += 1;
16305        Ok(())
16306    }
16307
16308    /// Equip paperdoll: activate selected slot (unequip filled, or equip first matching candidate).
16309    pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
16310        let idx = self.state.equip_menu_index;
16311        let slots = equip_paperdoll_rows(&self.state);
16312        let Some(row) = slots.get(idx) else {
16313            return Ok(());
16314        };
16315        match row {
16316            EquipPaperdollRow::Body { slot, filled } => {
16317                if *filled {
16318                    self.equip_worn(*slot, None).await
16319                } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
16320                    self.equip_worn(*slot, Some(inst)).await
16321                } else {
16322                    self.state
16323                        .push_log(format!("No item for {}", body_slot_label(*slot)));
16324                    Ok(())
16325                }
16326            }
16327            EquipPaperdollRow::Mainhand { filled } => {
16328                if *filled {
16329                    self.unequip_mainhand().await
16330                } else if let Some(tid) = first_inventory_weapon(&self.state) {
16331                    self.equip_mainhand(Some(tid)).await
16332                } else {
16333                    self.state.push_log("No weapon in inventory".to_string());
16334                    Ok(())
16335                }
16336            }
16337            EquipPaperdollRow::Offhand { filled, locked } => {
16338                if *locked {
16339                    self.state
16340                        .push_log("Offhand locked — two-handed weapon equipped".to_string());
16341                    Ok(())
16342                } else if *filled {
16343                    self.unequip_offhand().await
16344                } else if let Some(tid) = first_inventory_offhand(&self.state) {
16345                    self.equip_offhand(Some(tid)).await
16346                } else {
16347                    self.state
16348                        .push_log("No offhand item in inventory".to_string());
16349                    Ok(())
16350                }
16351            }
16352        }
16353    }
16354
16355    pub async fn say(
16356        &mut self,
16357        channel: flatland_protocol::ChatChannel,
16358        text: &str,
16359    ) -> anyhow::Result<()> {
16360        self.say_to(channel, text, None).await
16361    }
16362
16363    pub async fn say_to(
16364        &mut self,
16365        channel: flatland_protocol::ChatChannel,
16366        text: &str,
16367        to_entity: Option<EntityId>,
16368    ) -> anyhow::Result<()> {
16369        self.seq += 1;
16370        self.session
16371            .submit_intent(Intent::Say {
16372                entity_id: self.state.entity_id,
16373                channel,
16374                text: text.to_string(),
16375                to_entity,
16376                seq: self.seq,
16377            })
16378            .await?;
16379        self.state.intents_sent += 1;
16380        Ok(())
16381    }
16382
16383    pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
16384        let Some(peer) = self.state.player_verbs.target_entity else {
16385            return Ok(());
16386        };
16387        let label = self.state.player_verbs.target_label.clone();
16388        let choice = crate::social::PlayerVerbState::options()
16389            .get(self.state.player_verbs.index)
16390            .copied()
16391            .unwrap_or("Whisper");
16392        self.state.player_verbs.close();
16393        match choice {
16394            "Trade" => {
16395                // Only request — peer must accept (Y in chat). Do not auto-respond;
16396                // TradeRespond requires a pending inbound request from the peer.
16397                self.seq += 1;
16398                self.session
16399                    .submit_intent(Intent::TradeRequest {
16400                        entity_id: self.state.entity_id,
16401                        peer_entity_id: peer,
16402                        seq: self.seq,
16403                    })
16404                    .await?;
16405                self.state.intents_sent += 1;
16406                self.state.social_chat.push_system(format!(
16407                    "Trade request sent to {label} — waiting for accept"
16408                ));
16409            }
16410            "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
16411            _ => self.state.social_chat.focus_nearby(),
16412        }
16413        Ok(())
16414    }
16415
16416    pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
16417        let Some(pending) = self.state.social_chat.pending_trade.take() else {
16418            return Ok(());
16419        };
16420        self.seq += 1;
16421        self.session
16422            .submit_intent(Intent::TradeRespond {
16423                entity_id: self.state.entity_id,
16424                peer_entity_id: pending.from_entity,
16425                accept,
16426                seq: self.seq,
16427            })
16428            .await?;
16429        self.state.intents_sent += 1;
16430        if accept {
16431            self.state
16432                .social_chat
16433                .push_system(format!("Accepted trade with {}", pending.from_name));
16434        } else {
16435            self.state
16436                .social_chat
16437                .push_system(format!("Declined trade with {}", pending.from_name));
16438        }
16439        Ok(())
16440    }
16441
16442    pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
16443        let text = self.state.social_chat.buffer.trim().to_string();
16444        if text.is_empty() {
16445            return Ok(());
16446        }
16447        self.state.social_chat.buffer.clear();
16448        if crate::social::is_chat_slash_line(&text) {
16449            match crate::social::parse_chat_slash(&text) {
16450                Some(cmd) => return self.apply_chat_slash(cmd).await,
16451                None => {
16452                    self.state.social_chat.push_system(format!(
16453                        "Unknown command — {}",
16454                        crate::social::chat_slash_help_text()
16455                    ));
16456                    return Ok(());
16457                }
16458            }
16459        }
16460        let thread = self.state.social_chat.thread;
16461        let channel = thread.channel();
16462        let to = thread.to_entity();
16463        if let Some(peer) = to {
16464            let label = self.state.social_chat.peer_label.clone();
16465            self.state
16466                .social_chat
16467                .remember_whisper_peer(peer, &label, channel);
16468        }
16469        self.say_to(channel, &text, to).await
16470    }
16471
16472    async fn apply_chat_slash(
16473        &mut self,
16474        cmd: crate::social::ChatSlashCommand,
16475    ) -> anyhow::Result<()> {
16476        use crate::social::{chat_slash_help_text, ChatSlashCommand};
16477        match cmd {
16478            ChatSlashCommand::Help => {
16479                self.state
16480                    .social_chat
16481                    .push_system(chat_slash_help_text().to_string());
16482                Ok(())
16483            }
16484            ChatSlashCommand::Nearby { message } => {
16485                self.state.social_chat.focus_nearby();
16486                self.state
16487                    .social_chat
16488                    .push_system("Nearby speech — everyone close can hear");
16489                if let Some(msg) = message {
16490                    self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
16491                        .await
16492                } else {
16493                    Ok(())
16494                }
16495            }
16496            ChatSlashCommand::Reply { message } => {
16497                let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16498                    self.state
16499                        .social_chat
16500                        .push_system("No one to reply to — wait for a whisper, or /whisper Name");
16501                    return Ok(());
16502                };
16503                let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
16504                self.state
16505                    .social_chat
16506                    .set_whisper_thread(peer.entity_id, &peer.label, stone);
16507                self.state.social_chat.push_system(format!(
16508                    "Replying to {} — type and Enter · /nearby",
16509                    peer.label
16510                ));
16511                if let Some(msg) = message {
16512                    self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
16513                } else {
16514                    Ok(())
16515                }
16516            }
16517            ChatSlashCommand::Whisper { name, message } => {
16518                let (peer_id, label, stone) = if let Some(name) = name {
16519                    match self.resolve_whisper_target(&name) {
16520                        Ok(t) => t,
16521                        Err(err) => {
16522                            self.state.social_chat.push_system(err);
16523                            return Ok(());
16524                        }
16525                    }
16526                } else {
16527                    let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
16528                        self.state.social_chat.push_system(
16529                            "Usage: /whisper Name [message] · or /reply after someone whispers you",
16530                        );
16531                        return Ok(());
16532                    };
16533                    (
16534                        peer.entity_id,
16535                        peer.label,
16536                        peer.channel == flatland_protocol::ChatChannel::WhisperStone,
16537                    )
16538                };
16539                self.state
16540                    .social_chat
16541                    .set_whisper_thread(peer_id, &label, stone);
16542                let channel = if stone {
16543                    flatland_protocol::ChatChannel::WhisperStone
16544                } else {
16545                    flatland_protocol::ChatChannel::Whisper
16546                };
16547                if let Some(msg) = message {
16548                    self.state
16549                        .social_chat
16550                        .push_system(format!("Whisper → {label}"));
16551                    self.say_to(channel, &msg, Some(peer_id)).await
16552                } else {
16553                    self.state.social_chat.push_system(format!(
16554                        "Whispering {label} — type and Enter · Esc / /nearby cancels"
16555                    ));
16556                    Ok(())
16557                }
16558            }
16559        }
16560    }
16561
16562    /// Resolve `/whisper Name` against AOI players (label match).
16563    fn resolve_whisper_target(&self, name: &str) -> Result<(EntityId, String, bool), String> {
16564        let needle = name.trim().to_ascii_lowercase();
16565        if needle.is_empty() {
16566            return Err("Usage: /whisper Name [message]".into());
16567        }
16568        let mut candidates: Vec<(EntityId, String)> = self
16569            .state
16570            .entities
16571            .iter()
16572            .filter(|e| e.id != self.state.entity_id)
16573            .filter(|e| !e.label.trim().is_empty())
16574            .filter(|e| e.vitals.is_some())
16575            .filter(|e| !self.state.npcs.iter().any(|n| n.id == e.id.to_string()))
16576            .filter(|e| !self.state.hired_workers.iter().any(|w| w.entity_id == e.id))
16577            .map(|e| (e.id, e.label.clone()))
16578            .collect();
16579
16580        // Also allow matching the last whisper peer by name even if they left AOI briefly.
16581        if let Some(last) = &self.state.social_chat.last_whisper_peer {
16582            if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
16583                candidates.push((last.entity_id, last.label.clone()));
16584            }
16585        }
16586
16587        let exact: Vec<_> = candidates
16588            .iter()
16589            .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
16590            .cloned()
16591            .collect();
16592        let pool = if exact.len() == 1 {
16593            exact
16594        } else if exact.len() > 1 {
16595            return Err(format!(
16596                "Several players named '{name}' nearby — move closer and try again"
16597            ));
16598        } else {
16599            let starts: Vec<_> = candidates
16600                .iter()
16601                .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
16602                .cloned()
16603                .collect();
16604            if starts.len() == 1 {
16605                starts
16606            } else if starts.len() > 1 {
16607                let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
16608                return Err(format!(
16609                    "Ambiguous name '{name}' — matches: {}",
16610                    names.join(", ")
16611                ));
16612            } else {
16613                let contains: Vec<_> = candidates
16614                    .iter()
16615                    .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
16616                    .cloned()
16617                    .collect();
16618                if contains.len() == 1 {
16619                    contains
16620                } else if contains.is_empty() {
16621                    return Err(format!(
16622                        "No player matching '{name}' in range — get closer or check the spelling"
16623                    ));
16624                } else {
16625                    let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
16626                    return Err(format!(
16627                        "Ambiguous name '{name}' — matches: {}",
16628                        names.join(", ")
16629                    ));
16630                }
16631            }
16632        };
16633
16634        let (id, label) = pool.into_iter().next().unwrap();
16635        let stone = self
16636            .state
16637            .social_chat
16638            .last_whisper_peer
16639            .as_ref()
16640            .is_some_and(|p| {
16641                p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone
16642            });
16643        Ok((id, label, stone))
16644    }
16645
16646    pub async fn trade_present_selected(
16647        &mut self,
16648        item_instance_id: uuid::Uuid,
16649    ) -> anyhow::Result<()> {
16650        self.trade_present_quantity(item_instance_id, None).await
16651    }
16652
16653    pub async fn trade_present_quantity(
16654        &mut self,
16655        item_instance_id: uuid::Uuid,
16656        quantity: Option<u32>,
16657    ) -> anyhow::Result<()> {
16658        self.seq += 1;
16659        self.session
16660            .submit_intent(Intent::TradePresent {
16661                entity_id: self.state.entity_id,
16662                item_instance_id,
16663                quantity,
16664                seq: self.seq,
16665            })
16666            .await?;
16667        self.state.intents_sent += 1;
16668        self.state.trade_ui.qty_entry = None;
16669        self.state.trade_ui.picking_inventory = false;
16670        Ok(())
16671    }
16672
16673    /// Confirm the trade quantity prompt (or present whole stack when qty==1).
16674    pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
16675        if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
16676            let qty = self.state.trade_ui.present_quantity();
16677            return self
16678                .trade_present_quantity(entry.item_instance_id, qty)
16679                .await;
16680        }
16681        if !self.state.trade_ui.picking_inventory {
16682            return Ok(());
16683        }
16684        let stacks = self.state.trade_presentable_stacks();
16685        let Some(stack) = stacks.get(self.state.trade_ui.inventory_index).copied() else {
16686            return Ok(());
16687        };
16688        let Some(id) = stack.item_instance_id else {
16689            return Ok(());
16690        };
16691        let label = stack
16692            .display_name
16693            .clone()
16694            .unwrap_or_else(|| stack.template_id.clone());
16695        if stack.quantity <= 1 {
16696            self.trade_present_quantity(id, Some(1)).await
16697        } else {
16698            self.state
16699                .trade_ui
16700                .begin_qty_entry(id, label, stack.quantity);
16701            Ok(())
16702        }
16703    }
16704
16705    pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
16706        self.seq += 1;
16707        self.session
16708            .submit_intent(Intent::TradeSetReady {
16709                entity_id: self.state.entity_id,
16710                ready,
16711                seq: self.seq,
16712            })
16713            .await?;
16714        self.state.intents_sent += 1;
16715        Ok(())
16716    }
16717
16718    pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
16719        self.seq += 1;
16720        self.session
16721            .submit_intent(Intent::TradeCancel {
16722                entity_id: self.state.entity_id,
16723                seq: self.seq,
16724            })
16725            .await?;
16726        self.state.intents_sent += 1;
16727        self.state.trade_ui.close();
16728        Ok(())
16729    }
16730
16731    pub async fn destroy_whisper_stone(
16732        &mut self,
16733        item_instance_id: uuid::Uuid,
16734    ) -> anyhow::Result<()> {
16735        self.seq += 1;
16736        self.session
16737            .submit_intent(Intent::DestroyWhisperStone {
16738                entity_id: self.state.entity_id,
16739                item_instance_id,
16740                seq: self.seq,
16741            })
16742            .await?;
16743        self.state.intents_sent += 1;
16744        Ok(())
16745    }
16746
16747    pub async fn stop(&mut self) -> anyhow::Result<()> {
16748        self.seq += 1;
16749        self.session
16750            .submit_intent(Intent::Stop {
16751                entity_id: self.state.entity_id,
16752                seq: self.seq,
16753            })
16754            .await?;
16755        self.state.intents_sent += 1;
16756        Ok(())
16757    }
16758
16759    pub fn disconnect(&self) {
16760        self.session.disconnect();
16761    }
16762}
16763
16764fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
16765    let dx = ax - bx;
16766    let dy = ay - by;
16767    (dx * dx + dy * dy).sqrt()
16768}
16769
16770#[cfg(test)]
16771mod tests {
16772    use std::collections::BTreeMap;
16773
16774    use super::*;
16775    use flatland_protocol::{
16776        BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
16777    };
16778
16779    fn sample_state() -> GameState {
16780        let mut state = GameState {
16781            session_id: 1,
16782            entity_id: 1,
16783            character_id: None,
16784            tick: 0,
16785            chunk_rev: 0,
16786            content_rev: 0,
16787            publish_rev: 0,
16788            entities: vec![EntityState {
16789                id: 1,
16790                label: "You".into(),
16791                transform: Transform {
16792                    position: WorldCoord::surface(128.0, 128.0),
16793                    yaw: 0.0,
16794                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
16795                },
16796                vitals: None,
16797                attributes: None,
16798                skills: None,
16799                inside_building: None,
16800                tile_id: None,
16801                paperdoll_ref: None,
16802                draw_scale: 1.0,
16803                presentation_state: None,
16804                sprite_mode: None,
16805                progression_xp: None,
16806                combat_cues: vec![],
16807                statuses: vec![],
16808            }],
16809            player: None,
16810            resource_nodes: vec![ResourceNodeView {
16811                id: "oak-1".into(),
16812                label: "Oak".into(),
16813                x: 130.0,
16814                y: 128.0,
16815                z: 0.0,
16816                item_template: "oak_log".into(),
16817                state: ResourceNodeState::Available,
16818                blocking: true,
16819                blocking_radius_m: 0.8,
16820                harvest_off: false,
16821                tile_id: None,
16822                yaw: 0.0,
16823                pitch: 0.0,
16824                roll: 0.0,
16825                draw_scale: 1.0,
16826                sprite_mode: None,
16827                growth_progress: None,
16828                presentation_state: None,
16829                channel_start_tick: None,
16830                channel_end_tick: None,
16831                harvest_drop_templates: vec![],
16832            }],
16833            harvest_route_nodes: vec![],
16834            ground_drops: vec![],
16835            placed_containers: vec![],
16836            buildings: vec![BuildingView {
16837                id: "broker-hut".into(),
16838                label: "Broker".into(),
16839                x: 148.0,
16840                y: 118.0,
16841                width_m: 8.0,
16842                depth_m: 6.0,
16843                interior_blueprint: Some("broker_hut".into()),
16844                tags: vec![],
16845                market_boundary_zone_ids: vec![],
16846                market_max_volume: None,
16847                wall_set: None,
16848                roof_set: None,
16849            }],
16850            doors: vec![flatland_protocol::DoorView {
16851                id: "door-1".into(),
16852                building_id: "broker-hut".into(),
16853                x: 148.0,
16854                y: 118.0,
16855                open: false,
16856                portal: Some("front".into()),
16857                locked: false,
16858                accessible: true,
16859                lock_id: None,
16860            }],
16861            interior_map: None,
16862            npcs: vec![],
16863            blueprints: vec![],
16864            building_materials: vec![],
16865            world_x0: 0.0,
16866            world_y0: 0.0,
16867            world_width_m: 256.0,
16868            world_height_m: 256.0,
16869            terrain_zones: Vec::new(),
16870            z_platforms: Vec::new(),
16871            z_transitions: Vec::new(),
16872            z_bands_outdoor_backup: None,
16873            world_clock: flatland_protocol::WorldClock::default(),
16874            inventory: std::collections::HashMap::new(),
16875            inventory_hints: std::collections::HashMap::new(),
16876            item_catalog: std::collections::HashMap::new(),
16877            logs: VecDeque::new(),
16878            intents_sent: 0,
16879            ticks_received: 0,
16880            connected: true,
16881            disconnect_reason: None,
16882            show_stats: false,
16883            hud_log_hidden: false,
16884            show_equip_menu: false,
16885            equip_menu_index: 0,
16886            show_craft_menu: false,
16887            show_plot_build_menu: false,
16888            plot_build_focus_wall: true,
16889            plot_build_wall_index: 0,
16890            plot_build_roof_index: 0,
16891            craft_menu_index: 0,
16892            craft_batch_quantity: 1,
16893            craft_tab: CraftTab::Ready,
16894            craft_filter: String::new(),
16895            craft_filter_focused: false,
16896            craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
16897            show_shop_menu: false,
16898            shop_catalog: None,
16899            bank_panel: None,
16900            bank_menu_index: 0,
16901            bank_ui_mode: BankUiMode::Menu,
16902            storage_panel: None,
16903            market_panel: None,
16904            market_menu_index: 0,
16905            market_filter: String::new(),
16906            market_filter_focused: false,
16907            market_category_filter: None,
16908            market_buy_confirm: None,
16909            market_ui_mode: MarketUiMode::Browse,
16910            storage_menu_index: 0,
16911            storage_ui_mode: StorageUiMode::Menu,
16912            shop_tab: ShopTab::default(),
16913            shop_menu_index: 0,
16914            shop_quantity: 1,
16915            shop_trade_log: VecDeque::new(),
16916            show_npc_verb_menu: false,
16917            npc_verb_target: None,
16918            npc_verb_index: 0,
16919            npc_verb_notice: None,
16920            player_verbs: crate::social::PlayerVerbState::default(),
16921            social_chat: crate::social::SocialChatState::default(),
16922            trade_ui: crate::social::TradeUiState::default(),
16923            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
16924            show_npc_chat: false,
16925            npc_chat: None,
16926            show_inventory_menu: false,
16927            inventory_menu_index: 0,
16928            inventory_tab: InventoryTab::OnPerson,
16929            inventory_filter: String::new(),
16930            inventory_filter_focused: false,
16931            show_move_picker: false,
16932            show_rename_prompt: false,
16933            rename_plot_id: None,
16934            highlighted_plot_id: None,
16935            show_worker_rename: false,
16936            rename_buffer: String::new(),
16937            move_picker_index: 0,
16938            move_picker: None,
16939            show_grant_picker: false,
16940            grant_picker_index: 0,
16941            grant_picker: None,
16942            show_destroy_picker: false,
16943            destroy_confirm_pending: false,
16944            destroy_picker: None,
16945            combat_target: None,
16946            combat_target_label: None,
16947            ground_target: None,
16948            combat_fx: Vec::new(),
16949            ground_hazards: Vec::new(),
16950            property_zones: Vec::new(),
16951            tax_zones: Vec::new(),
16952            growth_zones: Vec::new(),
16953            biome_zones: Vec::new(),
16954            terrain_kind_nav: Vec::new(),
16955            property_plots: Vec::new(),
16956            property_plot_settings: None,
16957            claim_mode: None,
16958            relocate_mode: None,
16959            sell_plot_confirm: None,
16960            sell_plot_armed_at: None,
16961            show_plant_menu: false,
16962            plant_menu_index: 0,
16963            show_farm_access: false,
16964            farm_access_name_draft: String::new(),
16965            farm_access_discount_bps: 0,
16966            farm_access_index: 0,
16967            plant_quantity: 1,
16968            in_combat: false,
16969            auto_attack: true,
16970            combat_has_los: false,
16971            attack_cd_ticks: 0,
16972            gcd_ticks: 0,
16973            weapon_ability_id: "unarmed".into(),
16974            mainhand_template_id: None,
16975            mainhand_label: None,
16976            mainhand_instance_id: None,
16977            offhand_template_id: None,
16978            offhand_label: None,
16979            offhand_instance_id: None,
16980            mainhand_hand_slots: 1,
16981            defense: None,
16982            worn: BTreeMap::new(),
16983            carry_mass: 0.0,
16984            carry_mass_max: 0.0,
16985            encumbrance: flatland_protocol::EncumbranceState::Light,
16986            move_speed_mps: 0.0,
16987            move_speed_mult: 0.0,
16988            inventory_stacks: Vec::new(),
16989            keychain_stacks: Vec::new(),
16990            whisper_pouch_stacks: Vec::new(),
16991            combat_target_detail: None,
16992            statuses: Vec::new(),
16993            cast_progress: None,
16994            timed_channel: None,
16995            plot_build_offer: None,
16996            ability_cooldowns: Vec::new(),
16997            blocking_active: false,
16998            max_target_slots: 1,
16999            combat_slots: Vec::new(),
17000            rotation_presets: Vec::new(),
17001            known_abilities: Vec::new(),
17002            ability_meta: std::collections::HashMap::new(),
17003            ability_mastery: std::collections::HashMap::new(),
17004            hotbar: vec![None; 9],
17005            max_abilities_per_rotation: 0,
17006            show_loadout_menu: false,
17007            show_keychain_menu: false,
17008            keychain_menu_index: 0,
17009            show_rotation_editor: false,
17010            loadout_menu_index: 0,
17011            loadout_hotbar_slot: 1,
17012            loadout_ability_index: 0,
17013            loadout_focus_presets: false,
17014            rotation_editor: RotationEditorState::default(),
17015            harvest_in_progress: false,
17016            harvest_started_at: None,
17017            pending_craft_ack: None,
17018            craft_channel_blueprint_id: None,
17019            pending_worker_job_ack: None,
17020            attending_worker_instance_id: None,
17021            quest_log: Vec::new(),
17022            interactables: Vec::new(),
17023            ledger: None,
17024            career: None,
17025            character_sheet_tab: CharacterSheetTab::Character,
17026            ledger_period: LedgerPeriod::Day,
17027            show_quest_offer: false,
17028            pending_quest_offers: Vec::new(),
17029                quest_offer_index: 0,
17030            show_quest_menu: false,
17031            quest_menu_index: 0,
17032            quest_withdraw_confirm: false,
17033            hired_workers: Vec::new(),
17034            show_workers_menu: false,
17035            workers_menu_index: 0,
17036            worker_dismiss_confirmation: None,
17037            workers_menu_compact: false,
17038            worker_step_display: BTreeMap::new(),
17039            worker_error_display: BTreeMap::new(),
17040            worker_health_ring_until: BTreeMap::new(),
17041            pending_worker_hire_since: None,
17042            show_worker_give_picker: false,
17043            worker_give_picker_index: 0,
17044            worker_give_picker: None,
17045            show_worker_give_target_picker: false,
17046            worker_give_target_picker_index: 0,
17047            worker_give_target_picker: None,
17048            show_worker_take_picker: false,
17049            worker_take_picker_index: 0,
17050            worker_take_picker: None,
17051            show_worker_teach_picker: false,
17052            worker_teach_picker_index: 0,
17053            worker_teach_picker: None,
17054            worker_route_editor: None,
17055            progression_curve: None,
17056        };
17057        state.player = state.entities.first().cloned();
17058        state
17059    }
17060
17061    #[test]
17062    fn template_display_name_uses_item_catalog_for_uuid_ids() {
17063        let mut state = sample_state();
17064        let id = "7ac81ed5-e3d3-4b7f-a7fb-11d6c34a0995";
17065        assert_eq!(state.template_display_name(id), "Unknown item");
17066        state.item_catalog.insert(
17067            id.into(),
17068            ItemCatalogEntryView {
17069                template_id: id.into(),
17070                display_name: "Emerald".into(),
17071                category: "resource".into(),
17072                seed_for: None,
17073            },
17074        );
17075        assert_eq!(state.template_display_name(id), "Emerald");
17076    }
17077
17078    #[test]
17079    fn whisper_cancels_when_peer_walks_out_of_range() {
17080        let mut state = sample_state();
17081        state.player = state.entities.first().cloned();
17082        let mut peer = state.entities[0].clone();
17083        peer.id = 2;
17084        peer.label = "Ada".into();
17085        peer.transform.position = WorldCoord::surface(129.0, 128.0); // 1m — in range
17086        state.entities.push(peer.clone());
17087        state.social_chat.focus_whisper(2, "Ada");
17088        state.refresh_whisper_range();
17089        assert!(matches!(
17090            state.social_chat.thread,
17091            crate::social::ChatThreadKind::Whisper { peer: 2 }
17092        ));
17093
17094        peer.transform.position = WorldCoord::surface(132.0, 128.0); // 4m — out of range
17095        state.entities[1] = peer;
17096        state.refresh_whisper_range();
17097        assert_eq!(
17098            state.social_chat.thread,
17099            crate::social::ChatThreadKind::Nearby
17100        );
17101        assert!(!state.social_chat.input_focused);
17102    }
17103
17104    #[test]
17105    fn probe_use_world_hired_worker_manage() {
17106        let mut state = sample_state();
17107        state
17108            .hired_workers
17109            .push(flatland_protocol::HiredWorkerView {
17110                instance_id: "worker-1".into(),
17111                entity_id: 42,
17112                def_id: "worker_laborer".into(),
17113                label: "Sam".into(),
17114                x: 129.0,
17115                y: 128.0,
17116                z: 0.0,
17117                mode: flatland_protocol::WorkerModeView::JobLoop,
17118                state: flatland_protocol::WorkerStateView::Working,
17119                step_label: "cultivate".into(),
17120                vitals: flatland_protocol::WorkerVitalsSummary {
17121                    health_pct: 100.0,
17122                    stamina_pct: 100.0,
17123                    mana_pct: 100.0,
17124                    hunger_pct: 100.0,
17125                    thirst_pct: 100.0,
17126                },
17127                carry_pct: 0.0,
17128                last_error: None,
17129                wage_copper_per_interval: 1,
17130                effective_wage_copper: 1,
17131                wage_meters_walked: 0.0,
17132                lodging_container_id: None,
17133                route: None,
17134                route_stop_index: None,
17135                known_blueprint_ids: Vec::new(),
17136                level: 1,
17137                worker_xp: 0.0,
17138                inventory: Vec::new(),
17139                equipment: flatland_protocol::WorkerEquipmentView::default(),
17140                issue_hint: None,
17141            });
17142        let probe = state.probe_use_world();
17143        let primary = probe.primary.expect("primary");
17144        assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17145        assert_eq!(primary.id, "worker-1");
17146        assert!(primary.hint_line().contains("Manage"));
17147        assert!(primary.hint_line().contains("Sam"));
17148        assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17149    }
17150
17151    fn sample_hired_worker(x: f32, y: f32) -> flatland_protocol::HiredWorkerView {
17152        flatland_protocol::HiredWorkerView {
17153            instance_id: "worker-1".into(),
17154            entity_id: 42,
17155            def_id: "worker_laborer".into(),
17156            label: "Sam".into(),
17157            x,
17158            y,
17159            z: 0.0,
17160            mode: flatland_protocol::WorkerModeView::JobLoop,
17161            state: flatland_protocol::WorkerStateView::Working,
17162            step_label: "follow".into(),
17163            vitals: flatland_protocol::WorkerVitalsSummary {
17164                health_pct: 100.0,
17165                stamina_pct: 100.0,
17166                mana_pct: 100.0,
17167                hunger_pct: 100.0,
17168                thirst_pct: 100.0,
17169            },
17170            carry_pct: 0.0,
17171            last_error: None,
17172            wage_copper_per_interval: 1,
17173            effective_wage_copper: 1,
17174            wage_meters_walked: 0.0,
17175            lodging_container_id: None,
17176            route: None,
17177            route_stop_index: None,
17178            known_blueprint_ids: Vec::new(),
17179            level: 1,
17180            worker_xp: 0.0,
17181            inventory: Vec::new(),
17182            equipment: flatland_protocol::WorkerEquipmentView::default(),
17183            issue_hint: None,
17184        }
17185    }
17186
17187    #[test]
17188    fn probe_harvest_beats_closer_hired_worker() {
17189        let mut state = sample_state();
17190        state.resource_nodes[0].x = 129.0;
17191        state.resource_nodes[0].y = 128.0;
17192        state.hired_workers.push(sample_hired_worker(128.2, 128.0));
17193        let probe = state.probe_use_world();
17194        let primary = probe.primary.expect("primary");
17195        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
17196        assert_eq!(primary.id, "oak-1");
17197        assert!(state.harvestable_node_in_range());
17198        assert_eq!(
17199            state.nearest_interact_target().as_deref(),
17200            Some("worker-1"),
17201            "harvest is not Interact — worker remains the interact target"
17202        );
17203    }
17204
17205    #[test]
17206    fn probe_door_beats_closer_hired_worker() {
17207        let mut state = sample_state();
17208        state.doors[0].x = 129.2;
17209        state.doors[0].y = 128.0;
17210        state.hired_workers.push(sample_hired_worker(128.3, 128.0));
17211        let probe = state.probe_use_world();
17212        let primary = probe.primary.expect("primary");
17213        assert!(
17214            matches!(
17215                primary.kind,
17216                crate::UseWorldKind::EnterDoor
17217                    | crate::UseWorldKind::OpenDoor
17218                    | crate::UseWorldKind::CloseDoor
17219                    | crate::UseWorldKind::ExitDoor
17220            ),
17221            "door should win over closer worker, got {:?}",
17222            primary.kind
17223        );
17224        assert_eq!(primary.id, "door-1");
17225        assert_eq!(state.nearest_interact_target().as_deref(), Some("door-1"));
17226    }
17227
17228    #[test]
17229    fn probe_indoor_exit_door_beats_lodging_chest_pickup() {
17230        // MadSin-style house: exit door + lodging beds within chest pickup radius.
17231        // `f` / probe must prefer the door (cascade stage 0) over ChestPickup (stage 2).
17232        let mut state = sample_state();
17233        state.entities[0].inside_building = Some("player_house".into());
17234        state.entities[0].transform.position = WorldCoord::surface(5.0, 2.0);
17235        state.player = state.entities.first().cloned();
17236        state.buildings = vec![BuildingView {
17237            id: "player_house".into(),
17238            label: "MadSin's house".into(),
17239            x: 100.0,
17240            y: 100.0,
17241            width_m: 10.0,
17242            depth_m: 8.0,
17243            interior_blueprint: Some("player_house".into()),
17244            tags: vec!["player_built".into()],
17245            market_boundary_zone_ids: vec![],
17246            market_max_volume: None,
17247            wall_set: None,
17248            roof_set: None,
17249        }];
17250        state.doors = vec![flatland_protocol::DoorView {
17251            id: "house_exit".into(),
17252            building_id: "player_house".into(),
17253            x: 5.0,
17254            y: 1.0,
17255            open: true,
17256            portal: Some("front".into()),
17257            locked: false,
17258            accessible: true,
17259            lock_id: None,
17260        }];
17261        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
17262            id: "lodging_bed".into(),
17263            template_id: "camp_bed".into(),
17264            display_name: "Camp bed".into(),
17265            x: 5.5,
17266            y: 2.4,
17267            z: 0.0,
17268            locked: false,
17269            accessible: true,
17270            owner_character_id: None,
17271            contents: vec![],
17272            lock_id: None,
17273            capacity_volume: None,
17274            item_instance_id: Some(uuid::Uuid::from_u128(99)),
17275            tile_id: None,
17276            worker_lodging_capacity: Some(1),
17277            blocking: false,
17278            blocking_radius_m: 0.0,
17279            building_id: Some("player_house".into()),
17280        }];
17281        // Worker assigned to the bed but standing far away — occupancy only.
17282        let mut worker = sample_hired_worker(40.0, 40.0);
17283        worker.lodging_container_id = Some("lodging_bed".into());
17284        state.hired_workers.push(worker);
17285
17286        let probe = state.probe_use_world();
17287        let primary = probe.primary.expect("primary");
17288        assert!(
17289            matches!(
17290                primary.kind,
17291                crate::UseWorldKind::ExitDoor
17292                    | crate::UseWorldKind::OpenDoor
17293                    | crate::UseWorldKind::CloseDoor
17294                    | crate::UseWorldKind::EnterDoor
17295            ),
17296            "indoor exit must beat lodging ChestPickup, got {:?}",
17297            primary.kind
17298        );
17299        assert_eq!(primary.id, "house_exit");
17300        assert_eq!(primary.kind.cascade_stage(), 0);
17301        assert!(
17302            probe
17303                .candidates
17304                .iter()
17305                .any(|c| c.kind == crate::UseWorldKind::ChestPickup && c.in_range),
17306            "lodging bed should still be an in-range chest candidate"
17307        );
17308        assert_eq!(
17309            state.nearest_interact_target().as_deref(),
17310            Some("house_exit"),
17311            "use_nearest interact path should target the door"
17312        );
17313        assert!(state.lodging_is_occupied("lodging_bed"));
17314    }
17315
17316    #[test]
17317    fn probe_worker_when_no_resource_or_door_in_range() {
17318        let mut state = sample_state();
17319        // oak stays at 130,128 (2m, out of harvest); door stays at hut (~22m)
17320        state.hired_workers.push(sample_hired_worker(129.0, 128.0));
17321        let probe = state.probe_use_world();
17322        let primary = probe.primary.expect("primary");
17323        assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
17324        assert_eq!(state.nearest_interact_target().as_deref(), Some("worker-1"));
17325        assert!(!state.harvestable_node_in_range());
17326    }
17327
17328    #[test]
17329    fn market_clerk_verb_options_include_market() {
17330        let mut state = sample_state();
17331        state.npcs.push(flatland_protocol::NpcView {
17332            id: "mira_market".into(),
17333            label: "Mira".into(),
17334            role: "market_clerk".into(),
17335            x: 129.0,
17336            y: 128.0,
17337            building_id: Some("town_market".into()),
17338            entity_id: None,
17339            life_state: None,
17340            hp_pct: None,
17341            can_trade: false,
17342            buy_templates: vec![],
17343            tile_id: None,
17344            behavior_state: None,
17345            presentation_state: None,
17346            sprite_mode: None,
17347            paperdoll_ref: None,
17348            draw_scale: 1.0,
17349            yaw: None,
17350            perception_fov_deg: None,
17351            perception_sight_m: None,
17352            perception_hear_m: None,
17353            quest_verbs: Vec::new(),
17354        });
17355        state.npc_verb_target = Some("mira_market".into());
17356        assert_eq!(
17357            state
17358                .npc_verb_options()
17359                .iter()
17360                .map(|v| v.label.as_str())
17361                .collect::<Vec<_>>(),
17362            vec!["Market", "Talk"]
17363        );
17364    }
17365
17366    #[test]
17367    fn butcher_verb_options_include_turn_in_for_give_item() {
17368        let mut state = sample_state();
17369        state.npcs.push(flatland_protocol::NpcView {
17370            id: "town_butcher_1".into(),
17371            label: "Brutus".into(),
17372            role: "butcher".into(),
17373            x: 129.0,
17374            y: 128.0,
17375            building_id: None,
17376            entity_id: None,
17377            life_state: None,
17378            hp_pct: None,
17379            can_trade: true,
17380            buy_templates: vec!["raw_venison".into()],
17381            tile_id: None,
17382            behavior_state: None,
17383            presentation_state: None,
17384            sprite_mode: None,
17385            paperdoll_ref: None,
17386            draw_scale: 1.0,
17387            yaw: None,
17388            perception_fov_deg: None,
17389            perception_sight_m: None,
17390            perception_hear_m: None,
17391            quest_verbs: Vec::new(),
17392        });
17393        state.quest_log.push(flatland_protocol::QuestLogEntry {
17394            quest_id: "deer_threat".into(),
17395            title: "Deer threat".into(),
17396            description: String::new(),
17397            status: flatland_protocol::QuestStatusView::Active,
17398            current_step_id: Some("deliver".into()),
17399            current_step_title: "Deliver venison".into(),
17400            current_step_index: 0,
17401            objectives: vec![flatland_protocol::QuestObjectiveProgress {
17402                label: "Give 3 Raw venison to Brutus".into(),
17403                current: 0,
17404                required: 3,
17405                done: false,
17406                kind: "give_item".into(),
17407                npc_ref: Some("town_butcher_1".into()),
17408                item_template: Some("raw_venison".into()),
17409                blueprint_id: None,
17410                building_id: None,
17411            }],
17412            current_step_reward: flatland_protocol::QuestRewardView::default(),
17413            completion_reward: flatland_protocol::QuestRewardView::default(),
17414            steps: Vec::new(),
17415            is_tracked: true,
17416            can_withdraw: true,
17417        });
17418        state.npc_verb_target = Some("town_butcher_1".into());
17419        assert_eq!(
17420            state
17421                .npc_verb_options()
17422                .iter()
17423                .map(|v| v.label.as_str())
17424                .collect::<Vec<_>>(),
17425            vec!["Turn in: Deer threat", "Talk", "Trade"]
17426        );
17427    }
17428
17429    #[test]
17430    fn ada_verb_options_include_quest_offer() {
17431        let mut state = sample_state();
17432        state.npcs.push(flatland_protocol::NpcView {
17433            id: "ada_broker".into(),
17434            label: "Ada".into(),
17435            role: "broker".into(),
17436            x: 129.0,
17437            y: 128.0,
17438            building_id: None,
17439            entity_id: None,
17440            life_state: None,
17441            hp_pct: None,
17442            can_trade: true,
17443            buy_templates: vec![],
17444            tile_id: None,
17445            behavior_state: None,
17446            presentation_state: None,
17447            sprite_mode: None,
17448            paperdoll_ref: Some("ada_broker".into()),
17449            draw_scale: 1.0,
17450            yaw: None,
17451            perception_fov_deg: None,
17452            perception_sight_m: None,
17453            perception_hear_m: None,
17454            quest_verbs: vec![flatland_protocol::NpcQuestVerb {
17455                quest_id: "ada_goblin_hunt".into(),
17456                label: "Ask about goblins".into(),
17457                kind: flatland_protocol::NpcQuestVerb::KIND_OFFER.into(),
17458            }],
17459        });
17460        state.npc_verb_target = Some("ada_broker".into());
17461        assert_eq!(
17462            state
17463                .npc_verb_options()
17464                .iter()
17465                .map(|v| v.label.as_str())
17466                .collect::<Vec<_>>(),
17467            vec!["Ask about goblins", "Talk", "Trade"]
17468        );
17469    }
17470
17471    #[test]
17472    fn market_list_excludes_currency_stacks() {
17473        let mut state = sample_state();
17474        state.inventory_stacks = vec![
17475            flatland_protocol::ItemStack {
17476                template_id: "copper_coin".into(),
17477                quantity: 50,
17478                item_instance_id: Some(uuid::Uuid::from_u128(10)),
17479                display_name: Some("Copper Coin".into()),
17480                ..Default::default()
17481            },
17482            flatland_protocol::ItemStack {
17483                template_id: "oak_log".into(),
17484                quantity: 2,
17485                item_instance_id: Some(uuid::Uuid::from_u128(11)),
17486                display_name: Some("Oak Log".into()),
17487                ..Default::default()
17488            },
17489            flatland_protocol::ItemStack {
17490                template_id: "whisper_stone".into(),
17491                quantity: 1,
17492                item_instance_id: Some(uuid::Uuid::from_u128(12)),
17493                display_name: Some("Whisper Stone".into()),
17494                category: Some("quest".into()),
17495                listable: Some(false),
17496                ..Default::default()
17497            },
17498        ];
17499        let opts = state.market_list_item_options(&MarketListSourceKind::Person);
17500        assert_eq!(opts.len(), 1);
17501        assert!(opts[0].label.contains("Oak"));
17502    }
17503
17504    #[test]
17505    fn market_browse_filters_by_category_and_search() {
17506        let mut state = sample_state();
17507        state.market_panel = Some(flatland_protocol::MarketPanel {
17508            npc_id: "mira_market".into(),
17509            npc_label: "Mira".into(),
17510            building_id: "town_market".into(),
17511            building_label: "Town Market".into(),
17512            used_volume: 0.0,
17513            max_volume: 100.0,
17514            listings: vec![
17515                flatland_protocol::MarketListingView {
17516                    listing_id: uuid::Uuid::from_u128(1),
17517                    seller_character_id: uuid::Uuid::from_u128(2),
17518                    seller_label: "Ada".into(),
17519                    hall_building_id: "town_market".into(),
17520                    hall_label: "Town Market".into(),
17521                    template_id: "oak_log".into(),
17522                    display_name: "Oak Log".into(),
17523                    category: "resource".into(),
17524                    quantity: 3,
17525                    unit_price_copper: 10,
17526                    line_total_copper: 30,
17527                    npc_price: false,
17528                    npc_dump_unit_copper: None,
17529                    mine: false,
17530                },
17531                flatland_protocol::MarketListingView {
17532                    listing_id: uuid::Uuid::from_u128(3),
17533                    seller_character_id: uuid::Uuid::from_u128(2),
17534                    seller_label: "Ada".into(),
17535                    hall_building_id: "town_market".into(),
17536                    hall_label: "Town Market".into(),
17537                    template_id: "short_sword".into(),
17538                    display_name: "Short Sword".into(),
17539                    category: "weapon".into(),
17540                    quantity: 1,
17541                    unit_price_copper: 100,
17542                    line_total_copper: 100,
17543                    npc_price: false,
17544                    npc_dump_unit_copper: None,
17545                    mine: false,
17546                },
17547            ],
17548            tax_bps: 0,
17549            tax_flat_copper: 0,
17550            list_vaults: vec![],
17551        });
17552        assert_eq!(state.market_filtered_listing_indices().len(), 2);
17553        state.market_category_filter = Some("Weapons");
17554        let weapons = state.market_filtered_listing_indices();
17555        assert_eq!(weapons.len(), 1);
17556        assert_eq!(
17557            state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
17558            "Short Sword"
17559        );
17560        state.market_category_filter = None;
17561        state.market_filter = "oak".into();
17562        let oak = state.market_filtered_listing_indices();
17563        assert_eq!(oak.len(), 1);
17564        assert_eq!(
17565            state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
17566            "Oak Log"
17567        );
17568    }
17569
17570    #[test]
17571    fn market_list_source_includes_person_and_vaults() {
17572        let mut state = sample_state();
17573        let item_id = uuid::Uuid::from_u128(1);
17574        state.inventory_stacks = vec![flatland_protocol::ItemStack {
17575            template_id: "oak_log".into(),
17576            quantity: 2,
17577            item_instance_id: Some(item_id),
17578            display_name: Some("Oak Log".into()),
17579            ..Default::default()
17580        }];
17581        state.market_panel = Some(flatland_protocol::MarketPanel {
17582            npc_id: "mira_market".into(),
17583            npc_label: "Mira".into(),
17584            building_id: "town_market".into(),
17585            building_label: "Town Market".into(),
17586            used_volume: 0.0,
17587            max_volume: 100.0,
17588            listings: vec![],
17589            tax_bps: 0,
17590            tax_flat_copper: 0,
17591            list_vaults: vec![flatland_protocol::MarketListVault {
17592                building_id: "town_storage".into(),
17593                building_label: "Town Storage".into(),
17594                contents: vec![flatland_protocol::ItemStack {
17595                    template_id: "lumber".into(),
17596                    quantity: 1,
17597                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
17598                    display_name: Some("Lumber".into()),
17599                    ..Default::default()
17600                }],
17601            }],
17602        });
17603        let sources = state.market_list_source_options();
17604        assert_eq!(sources.len(), 2);
17605        assert!(matches!(sources[0].0, MarketListSourceKind::Person));
17606        assert!(matches!(
17607            sources[1].0,
17608            MarketListSourceKind::TownStorage { .. }
17609        ));
17610        assert!(sources[1].1.contains("Town Storage"));
17611    }
17612
17613    #[test]
17614    fn npc_market_dump_estimate_from_town_storage_vault() {
17615        let mut state = sample_state();
17616        state.market_panel = Some(flatland_protocol::MarketPanel {
17617            npc_id: "mira_market".into(),
17618            npc_label: "Mira".into(),
17619            building_id: "town_market".into(),
17620            building_label: "Town Market".into(),
17621            used_volume: 0.0,
17622            max_volume: 100.0,
17623            listings: vec![],
17624            tax_bps: 0,
17625            tax_flat_copper: 0,
17626            list_vaults: vec![flatland_protocol::MarketListVault {
17627                building_id: "town_storage".into(),
17628                building_label: "Town Storage".into(),
17629                contents: vec![flatland_protocol::ItemStack {
17630                    template_id: "lumber".into(),
17631                    quantity: 3,
17632                    item_instance_id: Some(uuid::Uuid::from_u128(2)),
17633                    display_name: Some("Lumber".into()),
17634                    base_value_copper: Some(20),
17635                    ..Default::default()
17636                }],
17637            }],
17638        });
17639        assert_eq!(
17640            state.npc_market_dump_unit_estimate("lumber"),
17641            Some(9),
17642            "vault stack base_value should enable NPC price estimate"
17643        );
17644    }
17645
17646    #[test]
17647    fn probe_use_world_npc_beats_nearby_loot() {
17648        let mut state = sample_state();
17649        state.npcs.push(flatland_protocol::NpcView {
17650            id: "ada".into(),
17651            label: "Ada".into(),
17652            role: "broker".into(),
17653            x: 129.0,
17654            y: 128.0,
17655            building_id: None,
17656            entity_id: None,
17657            life_state: None,
17658            hp_pct: None,
17659            can_trade: true,
17660            buy_templates: vec!["lumber".into()],
17661            tile_id: None,
17662            behavior_state: None,
17663            presentation_state: None,
17664            sprite_mode: None,
17665            paperdoll_ref: None,
17666            draw_scale: 1.0,
17667            yaw: None,
17668            perception_fov_deg: None,
17669            perception_sight_m: None,
17670            perception_hear_m: None,
17671            quest_verbs: Vec::new(),
17672        });
17673        state.ground_drops.push(flatland_protocol::GroundDropView {
17674            id: "d1".into(),
17675            template_id: "lumber".into(),
17676            quantity: 1,
17677            x: 128.5,
17678            y: 128.0,
17679            z: 0.0,
17680            tile_id: None,
17681            display_name: None,
17682            yaw: 0.0,
17683            pitch: 0.0,
17684            roll: 0.0,
17685            draw_scale: 1.0,
17686            item_instance_id: None,
17687            props: Default::default(),
17688            status_bindings: Vec::new(),
17689        });
17690        let probe = state.probe_use_world();
17691        let primary = probe.primary.expect("primary");
17692        assert_eq!(primary.kind, crate::UseWorldKind::Npc);
17693        assert_eq!(primary.id, "ada");
17694    }
17695
17696    #[test]
17697    fn probe_use_world_harvest_when_in_range() {
17698        let state = sample_state(); // oak at 130,128 — player 128,128 → dist 2 > 1.5
17699        let probe = state.probe_use_world();
17700        assert!(
17701            probe.primary.is_none(),
17702            "oak is 2m away, out of harvest range"
17703        );
17704        assert!(probe
17705            .candidates
17706            .iter()
17707            .any(|c| c.kind == crate::UseWorldKind::Harvest));
17708
17709        let mut state = sample_state();
17710        state.resource_nodes[0].x = 129.0;
17711        let probe = state.probe_use_world();
17712        let primary = probe.primary.expect("primary");
17713        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
17714    }
17715
17716    #[test]
17717    fn probe_use_world_door_uses_building_label() {
17718        let mut state = sample_state();
17719        state.doors[0].x = 129.0;
17720        state.doors[0].y = 128.0;
17721        let probe = state.probe_use_world();
17722        let primary = probe.primary.expect("primary");
17723        assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
17724        assert_eq!(primary.label, "Broker");
17725        assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
17726    }
17727
17728    #[test]
17729    fn empty_entity_tick_preserves_welcome_snapshot() {
17730        let mut state = sample_state();
17731        state.inventory.insert("carrot".into(), 3);
17732        let delta = TickDelta {
17733            tick: 1,
17734            entities: vec![],
17735            resource_nodes: vec![],
17736            ground_drops: vec![],
17737            placed_containers: vec![],
17738            buildings: vec![],
17739            doors: vec![],
17740            interior_map: None,
17741            npcs: vec![],
17742            inventory: vec![],
17743            blueprints: vec![],
17744            building_materials: vec![],
17745            world_clock: flatland_protocol::WorldClock::default(),
17746            combat: None,
17747            quest_log: vec![],
17748            hired_workers: Vec::new(),
17749            interactables: vec![],
17750            ledger: None,
17751            career: None,
17752            combat_fx: Vec::new(),
17753            ground_hazards: Vec::new(),
17754            property_plots: Vec::new(),
17755            terrain_overlays: Vec::new(),
17756        };
17757
17758        state.apply_tick_fields(&delta, 1);
17759
17760        assert_eq!(state.entities.len(), 1);
17761        assert!(state.player.is_some());
17762        assert_eq!(state.inventory.get("carrot"), Some(&3));
17763        assert_eq!(state.resource_nodes.len(), 1);
17764    }
17765
17766    #[test]
17767    fn tick_preserves_world_layers_when_delta_omits_them() {
17768        let mut state = sample_state();
17769        let delta = TickDelta {
17770            tick: 1,
17771            entities: state.entities.clone(),
17772            resource_nodes: vec![],
17773            ground_drops: vec![],
17774            placed_containers: vec![],
17775            buildings: vec![],
17776            doors: vec![],
17777            interior_map: None,
17778            npcs: vec![],
17779            inventory: vec![],
17780            blueprints: vec![],
17781            building_materials: vec![],
17782            world_clock: flatland_protocol::WorldClock::default(),
17783            combat: None,
17784            quest_log: vec![],
17785            hired_workers: Vec::new(),
17786            interactables: vec![],
17787            ledger: None,
17788            career: None,
17789            combat_fx: Vec::new(),
17790            ground_hazards: Vec::new(),
17791            property_plots: Vec::new(),
17792            terrain_overlays: Vec::new(),
17793        };
17794
17795        state.apply_tick_fields(&delta, 1);
17796
17797        assert_eq!(state.resource_nodes.len(), 1);
17798        assert_eq!(state.buildings.len(), 1);
17799        assert_eq!(state.doors.len(), 1);
17800    }
17801
17802    #[test]
17803    fn tick_updates_resource_nodes_when_server_sends_them() {
17804        let mut state = sample_state();
17805        let delta = TickDelta {
17806            tick: 1,
17807            entities: state.entities.clone(),
17808            resource_nodes: vec![ResourceNodeView {
17809                id: "oak-1".into(),
17810                label: "Oak".into(),
17811                x: 130.0,
17812                y: 128.0,
17813                z: 0.0,
17814                item_template: "oak_log".into(),
17815                state: ResourceNodeState::Cooldown,
17816                blocking: true,
17817                blocking_radius_m: 0.8,
17818                harvest_off: false,
17819                tile_id: None,
17820                yaw: 0.0,
17821                pitch: 0.0,
17822                roll: 0.0,
17823                draw_scale: 1.0,
17824                sprite_mode: None,
17825                growth_progress: None,
17826                presentation_state: None,
17827                channel_start_tick: None,
17828                channel_end_tick: None,
17829                harvest_drop_templates: vec![],
17830            }],
17831            buildings: vec![],
17832            doors: vec![],
17833            interior_map: None,
17834            npcs: vec![],
17835            inventory: vec![],
17836            blueprints: vec![],
17837            building_materials: vec![],
17838            world_clock: flatland_protocol::WorldClock::default(),
17839            ground_drops: vec![],
17840            placed_containers: vec![],
17841            combat: None,
17842            quest_log: vec![],
17843            hired_workers: Vec::new(),
17844            interactables: vec![],
17845            ledger: None,
17846            career: None,
17847            combat_fx: Vec::new(),
17848            ground_hazards: Vec::new(),
17849            property_plots: Vec::new(),
17850            terrain_overlays: Vec::new(),
17851        };
17852
17853        state.apply_tick_fields(&delta, 1);
17854
17855        assert!(matches!(
17856            state.resource_nodes[0].state,
17857            ResourceNodeState::Cooldown
17858        ));
17859    }
17860
17861    #[test]
17862    fn harvest_picker_keeps_welcome_nodes_after_aoi_tick() {
17863        let mut state = sample_state();
17864        let nearby = state.resource_nodes[0].clone();
17865        let mut far = nearby.clone();
17866        far.id = "far-oak".into();
17867        far.label = "Far Oak".into();
17868        far.x = 200.0;
17869        far.y = 200.0;
17870        state.replace_harvest_route_nodes(&[nearby.clone(), far.clone()]);
17871
17872        let delta = TickDelta {
17873            tick: 1,
17874            entities: state.entities.clone(),
17875            resource_nodes: vec![nearby],
17876            ground_drops: vec![],
17877            placed_containers: vec![],
17878            buildings: vec![],
17879            doors: vec![],
17880            interior_map: None,
17881            npcs: vec![],
17882            inventory: vec![],
17883            blueprints: vec![],
17884            building_materials: vec![],
17885            world_clock: flatland_protocol::WorldClock::default(),
17886            combat: None,
17887            quest_log: vec![],
17888            hired_workers: Vec::new(),
17889            interactables: vec![],
17890            ledger: None,
17891            career: None,
17892            combat_fx: Vec::new(),
17893            ground_hazards: Vec::new(),
17894            property_plots: Vec::new(),
17895            terrain_overlays: Vec::new(),
17896        };
17897        state.apply_tick_fields(&delta, 1);
17898
17899        assert_eq!(state.resource_nodes.len(), 1);
17900        let ids: Vec<_> = state
17901            .route_editor_node_candidates()
17902            .into_iter()
17903            .map(|n| n.id)
17904            .collect();
17905        assert!(ids.contains(&"oak-1".to_string()), "got {ids:?}");
17906        assert!(ids.contains(&"far-oak".to_string()), "got {ids:?}");
17907    }
17908
17909    #[test]
17910    fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
17911        let mut state = GameState {
17912            session_id: 1,
17913            entity_id: 1,
17914            character_id: None,
17915            tick: 0,
17916            chunk_rev: 0,
17917            content_rev: 0,
17918            publish_rev: 0,
17919            entities: vec![EntityState {
17920                id: 1,
17921                label: "You".into(),
17922                transform: Transform {
17923                    position: WorldCoord::surface(4.5, 2.0),
17924                    yaw: 0.0,
17925                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
17926                },
17927                vitals: None,
17928                attributes: None,
17929                skills: None,
17930                inside_building: Some("broker_hut".into()),
17931                tile_id: None,
17932                paperdoll_ref: None,
17933                draw_scale: 1.0,
17934                presentation_state: None,
17935                sprite_mode: None,
17936                progression_xp: None,
17937                combat_cues: vec![],
17938                statuses: vec![],
17939            }],
17940            player: None,
17941            resource_nodes: vec![],
17942            harvest_route_nodes: vec![],
17943            ground_drops: vec![],
17944            placed_containers: vec![],
17945            buildings: vec![BuildingView {
17946                id: "broker_hut".into(),
17947                label: "Broker".into(),
17948                x: 158.0,
17949                y: 124.0,
17950                width_m: 8.0,
17951                depth_m: 6.0,
17952                interior_blueprint: Some("broker_hut".into()),
17953                tags: vec![],
17954                market_boundary_zone_ids: vec![],
17955                market_max_volume: None,
17956                wall_set: None,
17957                roof_set: None,
17958            }],
17959            doors: vec![flatland_protocol::DoorView {
17960                id: "broker_hut_exit".into(),
17961                building_id: "broker_hut".into(),
17962                x: 4.3,
17963                y: 0.9,
17964                open: true,
17965                portal: Some("front".into()),
17966                locked: false,
17967                accessible: true,
17968                lock_id: None,
17969            }],
17970            interior_map: None,
17971            npcs: vec![flatland_protocol::NpcView {
17972                id: "ada_broker".into(),
17973                label: "Ada".into(),
17974                x: 4.5,
17975                y: 2.0,
17976                building_id: Some("broker_hut".into()),
17977                role: "broker".into(),
17978                entity_id: None,
17979                life_state: None,
17980                hp_pct: None,
17981                can_trade: true,
17982                buy_templates: vec!["lumber".into()],
17983                tile_id: None,
17984                behavior_state: None,
17985                presentation_state: None,
17986                sprite_mode: None,
17987                paperdoll_ref: None,
17988                draw_scale: 1.0,
17989                yaw: None,
17990                perception_fov_deg: None,
17991                perception_sight_m: None,
17992                perception_hear_m: None,
17993                quest_verbs: Vec::new(),
17994            }],
17995            blueprints: vec![],
17996            building_materials: vec![],
17997            world_x0: 0.0,
17998            world_y0: 0.0,
17999            world_width_m: 256.0,
18000            world_height_m: 256.0,
18001            terrain_zones: Vec::new(),
18002            z_platforms: Vec::new(),
18003            z_transitions: Vec::new(),
18004            z_bands_outdoor_backup: None,
18005            world_clock: flatland_protocol::WorldClock::default(),
18006            inventory: std::collections::HashMap::new(),
18007            inventory_hints: std::collections::HashMap::new(),
18008            item_catalog: std::collections::HashMap::new(),
18009            logs: VecDeque::new(),
18010            intents_sent: 0,
18011            ticks_received: 0,
18012            connected: true,
18013            disconnect_reason: None,
18014            show_stats: false,
18015            hud_log_hidden: false,
18016            show_equip_menu: false,
18017            equip_menu_index: 0,
18018            show_craft_menu: false,
18019            show_plot_build_menu: false,
18020            plot_build_focus_wall: true,
18021            plot_build_wall_index: 0,
18022            plot_build_roof_index: 0,
18023            craft_menu_index: 0,
18024            craft_batch_quantity: 1,
18025            craft_tab: CraftTab::Ready,
18026            craft_filter: String::new(),
18027            craft_filter_focused: false,
18028            craft_prefs: crate::craft_prefs::CraftCharacterPrefs::default(),
18029            show_shop_menu: false,
18030            shop_catalog: None,
18031            bank_panel: None,
18032            bank_menu_index: 0,
18033            bank_ui_mode: BankUiMode::Menu,
18034            storage_panel: None,
18035            market_panel: None,
18036            market_menu_index: 0,
18037            market_filter: String::new(),
18038            market_filter_focused: false,
18039            market_category_filter: None,
18040            market_buy_confirm: None,
18041            market_ui_mode: MarketUiMode::Browse,
18042            storage_menu_index: 0,
18043            storage_ui_mode: StorageUiMode::Menu,
18044            shop_tab: ShopTab::default(),
18045            shop_menu_index: 0,
18046            shop_quantity: 1,
18047            shop_trade_log: VecDeque::new(),
18048            show_npc_verb_menu: false,
18049            npc_verb_target: None,
18050            npc_verb_index: 0,
18051            npc_verb_notice: None,
18052            player_verbs: crate::social::PlayerVerbState::default(),
18053            social_chat: crate::social::SocialChatState::default(),
18054            trade_ui: crate::social::TradeUiState::default(),
18055            whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
18056            show_npc_chat: false,
18057            npc_chat: None,
18058            show_inventory_menu: false,
18059            inventory_menu_index: 0,
18060            inventory_tab: InventoryTab::OnPerson,
18061            inventory_filter: String::new(),
18062            inventory_filter_focused: false,
18063            show_move_picker: false,
18064            show_rename_prompt: false,
18065            rename_plot_id: None,
18066            highlighted_plot_id: None,
18067            show_worker_rename: false,
18068            rename_buffer: String::new(),
18069            move_picker_index: 0,
18070            move_picker: None,
18071            show_grant_picker: false,
18072            grant_picker_index: 0,
18073            grant_picker: None,
18074            show_destroy_picker: false,
18075            destroy_confirm_pending: false,
18076            destroy_picker: None,
18077            combat_target: None,
18078            combat_target_label: None,
18079            ground_target: None,
18080            combat_fx: Vec::new(),
18081            ground_hazards: Vec::new(),
18082            property_zones: Vec::new(),
18083            tax_zones: Vec::new(),
18084            growth_zones: Vec::new(),
18085            biome_zones: Vec::new(),
18086            terrain_kind_nav: Vec::new(),
18087            property_plots: Vec::new(),
18088            property_plot_settings: None,
18089            claim_mode: None,
18090            relocate_mode: None,
18091            sell_plot_confirm: None,
18092            sell_plot_armed_at: None,
18093            show_plant_menu: false,
18094            plant_menu_index: 0,
18095            show_farm_access: false,
18096            farm_access_name_draft: String::new(),
18097            farm_access_discount_bps: 0,
18098            farm_access_index: 0,
18099            plant_quantity: 1,
18100            in_combat: false,
18101            auto_attack: true,
18102            combat_has_los: false,
18103            attack_cd_ticks: 0,
18104            gcd_ticks: 0,
18105            weapon_ability_id: "unarmed".into(),
18106            mainhand_template_id: None,
18107            mainhand_label: None,
18108            mainhand_instance_id: None,
18109            offhand_template_id: None,
18110            offhand_label: None,
18111            offhand_instance_id: None,
18112            mainhand_hand_slots: 1,
18113            defense: None,
18114            worn: BTreeMap::new(),
18115            carry_mass: 0.0,
18116            carry_mass_max: 0.0,
18117            encumbrance: flatland_protocol::EncumbranceState::Light,
18118            move_speed_mps: 0.0,
18119            move_speed_mult: 0.0,
18120            inventory_stacks: Vec::new(),
18121            keychain_stacks: Vec::new(),
18122            whisper_pouch_stacks: Vec::new(),
18123            combat_target_detail: None,
18124            statuses: Vec::new(),
18125            cast_progress: None,
18126            timed_channel: None,
18127            plot_build_offer: None,
18128            ability_cooldowns: Vec::new(),
18129            blocking_active: false,
18130            max_target_slots: 1,
18131            combat_slots: Vec::new(),
18132            rotation_presets: Vec::new(),
18133            known_abilities: Vec::new(),
18134            ability_meta: std::collections::HashMap::new(),
18135            ability_mastery: std::collections::HashMap::new(),
18136            hotbar: vec![None; 9],
18137            max_abilities_per_rotation: 0,
18138            show_loadout_menu: false,
18139            show_keychain_menu: false,
18140            keychain_menu_index: 0,
18141            show_rotation_editor: false,
18142            loadout_menu_index: 0,
18143            loadout_hotbar_slot: 1,
18144            loadout_ability_index: 0,
18145            loadout_focus_presets: false,
18146            rotation_editor: RotationEditorState::default(),
18147            harvest_in_progress: false,
18148            harvest_started_at: None,
18149            pending_craft_ack: None,
18150            craft_channel_blueprint_id: None,
18151            pending_worker_job_ack: None,
18152            attending_worker_instance_id: None,
18153            quest_log: Vec::new(),
18154            interactables: Vec::new(),
18155            ledger: None,
18156            career: None,
18157            character_sheet_tab: CharacterSheetTab::Character,
18158            ledger_period: LedgerPeriod::Day,
18159            show_quest_offer: false,
18160            pending_quest_offers: Vec::new(),
18161                quest_offer_index: 0,
18162            show_quest_menu: false,
18163            quest_menu_index: 0,
18164            quest_withdraw_confirm: false,
18165            hired_workers: Vec::new(),
18166            show_workers_menu: false,
18167            workers_menu_index: 0,
18168            worker_dismiss_confirmation: None,
18169            workers_menu_compact: false,
18170            worker_step_display: BTreeMap::new(),
18171            worker_error_display: BTreeMap::new(),
18172            worker_health_ring_until: BTreeMap::new(),
18173            pending_worker_hire_since: None,
18174            show_worker_give_picker: false,
18175            worker_give_picker_index: 0,
18176            worker_give_picker: None,
18177            show_worker_give_target_picker: false,
18178            worker_give_target_picker_index: 0,
18179            worker_give_target_picker: None,
18180            show_worker_take_picker: false,
18181            worker_take_picker_index: 0,
18182            worker_take_picker: None,
18183            show_worker_teach_picker: false,
18184            worker_teach_picker_index: 0,
18185            worker_teach_picker: None,
18186            worker_route_editor: None,
18187            progression_curve: None,
18188        };
18189        state.player = state.entities.first().cloned();
18190        assert_eq!(
18191            state.nearest_interact_target().as_deref(),
18192            Some("ada_broker")
18193        );
18194    }
18195
18196    #[test]
18197    fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
18198        let mut state = sample_state();
18199        // Player is at (128, 128) per sample_state(). One chest just inside
18200        // CONTAINER_RANGE_M, one clearly beyond it.
18201        state.placed_containers = vec![
18202            flatland_protocol::PlacedContainerView {
18203                id: "near".into(),
18204                template_id: "wooden_chest_small".into(),
18205                display_name: "Wooden Chest".into(),
18206                x: 130.0,
18207                y: 128.0,
18208                z: 0.0,
18209                locked: true,
18210                accessible: true,
18211                owner_character_id: None,
18212                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
18213                lock_id: None,
18214                capacity_volume: None,
18215                item_instance_id: Some(uuid::Uuid::from_u128(1)),
18216                tile_id: None,
18217                worker_lodging_capacity: None,
18218                blocking: false,
18219                blocking_radius_m: 0.0,
18220                building_id: None,
18221            },
18222            flatland_protocol::PlacedContainerView {
18223                id: "far".into(),
18224                template_id: "wooden_chest_small".into(),
18225                display_name: "Distant Chest".into(),
18226                x: 128.0 + CONTAINER_RANGE_M + 5.0,
18227                y: 128.0,
18228                z: 0.0,
18229                locked: false,
18230                accessible: true,
18231                owner_character_id: None,
18232                contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
18233                lock_id: None,
18234                capacity_volume: None,
18235                item_instance_id: Some(uuid::Uuid::from_u128(2)),
18236                tile_id: None,
18237                worker_lodging_capacity: None,
18238                blocking: false,
18239                blocking_radius_m: 0.0,
18240                building_id: None,
18241            },
18242        ];
18243
18244        let nearby = state.nearby_containers();
18245        assert_eq!(
18246            nearby.len(),
18247            1,
18248            "far chest must not appear once out of range"
18249        );
18250        assert_eq!(nearby[0].view.id, "near");
18251        assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
18252        assert!(nearby[0].rows[0].is_chest_shell);
18253
18254        // The same chest, but locked and inaccessible (no key held), must hide
18255        // contents but still show the selectable chest shell row.
18256        state.placed_containers[0].accessible = false;
18257        let nearby = state.nearby_containers();
18258        assert_eq!(nearby.len(), 1);
18259        assert_eq!(nearby[0].rows.len(), 1);
18260        assert!(nearby[0].rows[0].is_chest_shell);
18261    }
18262
18263    #[test]
18264    fn chest_pickup_destinations_offer_person_and_worn_bag() {
18265        let mut state = sample_state();
18266        let back_id = uuid::Uuid::from_u128(42);
18267        state.worn.insert(
18268            BodySlot::Back,
18269            flatland_protocol::ItemStack {
18270                template_id: "travel_backpack".into(),
18271                quantity: 1,
18272                item_instance_id: Some(back_id),
18273                props: Default::default(),
18274                status_bindings: Vec::new(),
18275                contents: Vec::new(),
18276                display_name: Some("Travel Backpack".into()),
18277                category: Some("container".into()),
18278                base_mass: Some(2.5),
18279                base_volume: Some(12.0),
18280                capacity_volume: Some(80.0),
18281                stackable: Some(false),
18282                world_placeable: Some(false),
18283                worker_lodging_capacity: None,
18284                equip_slot: None,
18285                armor_physical: None,
18286                resists: vec![],
18287                hand_slots: None,
18288                listable: None,
18289                ..Default::default()
18290            },
18291        );
18292        let opts = state.chest_pickup_destinations("chest-1");
18293        assert!(matches!(
18294            opts.first().map(|o| &o.kind),
18295            Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
18296        ));
18297        assert!(opts.iter().any(|o| matches!(
18298            &o.kind,
18299            MoveOptionKind::PickupPlaced {
18300                nest_parent_instance_id: None,
18301                ..
18302            }
18303        )));
18304        assert!(opts.iter().any(|o| matches!(
18305            &o.kind,
18306            MoveOptionKind::PickupPlaced {
18307                nest_parent_instance_id: Some(id),
18308                ..
18309            } if *id == back_id
18310        )));
18311        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
18312    }
18313
18314    #[test]
18315    fn placed_container_public_label_hides_owner_custom_name() {
18316        let owner = uuid::Uuid::from_u128(99);
18317        let mut state = sample_state();
18318        state.character_id = Some(uuid::Uuid::from_u128(1));
18319        state.inventory_hints.insert(
18320            "wooden_chest_medium".into(),
18321            InventoryHint {
18322                display_name: "Medium Wooden Chest".into(),
18323                category: "container".into(),
18324                base_mass: None,
18325                base_volume: None,
18326                capacity_volume: None,
18327                stackable: false,
18328                listable: true,
18329                base_value_copper: None,
18330            },
18331        );
18332        let chest = flatland_protocol::PlacedContainerView {
18333            id: "c1".into(),
18334            template_id: "wooden_chest_medium".into(),
18335            display_name: "Barry's Loot #a3f2".into(),
18336            x: 128.0,
18337            y: 128.0,
18338            z: 0.0,
18339            locked: false,
18340            accessible: true,
18341            owner_character_id: Some(owner),
18342            contents: vec![],
18343            lock_id: None,
18344            capacity_volume: None,
18345            item_instance_id: None,
18346            tile_id: None,
18347            worker_lodging_capacity: None,
18348            blocking: false,
18349            blocking_radius_m: 0.0,
18350            building_id: None,
18351        };
18352        assert_eq!(
18353            state.placed_container_public_label(&chest),
18354            "Medium Wooden Chest"
18355        );
18356        state.character_id = Some(owner);
18357        assert_eq!(
18358            state.placed_container_public_label(&chest),
18359            "Barry's Loot #a3f2"
18360        );
18361    }
18362
18363    #[test]
18364    fn location_context_shows_crop_growth_percent_not_depleted() {
18365        let mut state = sample_state();
18366        state.player = state.entities.first().cloned();
18367        state.resource_nodes[0].label = "Carrot (growing)".into();
18368        state.resource_nodes[0].x = 128.2;
18369        state.resource_nodes[0].y = 128.0;
18370        state.resource_nodes[0].state = ResourceNodeState::Cooldown;
18371        state.resource_nodes[0].growth_progress = Some(0.47);
18372        let lines = state.location_context_lines();
18373        let line = lines
18374            .iter()
18375            .find(|l| l.text.contains("Carrot"))
18376            .map(|l| l.text.as_str())
18377            .unwrap_or("");
18378        assert!(
18379            line.contains("(growing, 47%)"),
18380            "expected growth percent, got: {line}"
18381        );
18382        assert!(
18383            !line.contains("depleted"),
18384            "growing crop should not show depleted: {line}"
18385        );
18386    }
18387
18388    #[test]
18389    fn resource_node_near_action_suffix_prefers_growth() {
18390        let node = ResourceNodeView {
18391            id: "crop".into(),
18392            label: "Wheat".into(),
18393            x: 0.0,
18394            y: 0.0,
18395            z: 0.0,
18396            item_template: "wheat".into(),
18397            state: ResourceNodeState::Cooldown,
18398            blocking: false,
18399            blocking_radius_m: 0.0,
18400            harvest_off: false,
18401            tile_id: None,
18402            yaw: 0.0,
18403            pitch: 0.0,
18404            roll: 0.0,
18405            draw_scale: 1.0,
18406            sprite_mode: None,
18407            growth_progress: Some(0.12),
18408            presentation_state: None,
18409            channel_start_tick: None,
18410            channel_end_tick: None,
18411            harvest_drop_templates: vec![],
18412        };
18413        assert_eq!(resource_node_near_action_suffix(&node), " (growing, 12%)");
18414    }
18415
18416    #[test]
18417    fn location_context_lists_nearby_resource_node() {
18418        let mut state = sample_state();
18419        state.player = state.entities.first().cloned();
18420        state.resource_nodes[0].x = 128.2;
18421        state.resource_nodes[0].y = 128.0;
18422        let lines = state.location_context_lines();
18423        assert!(
18424            lines
18425                .iter()
18426                .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
18427            "expected resource node in context: {:?}",
18428            lines
18429        );
18430    }
18431
18432    #[test]
18433    fn quest_board_usable_within_board_radius() {
18434        let mut state = sample_state();
18435        state.player = state.entities.first().cloned();
18436        state.interactables = vec![flatland_protocol::InteractableView {
18437            id: "board-1".into(),
18438            kind: "quest_board".into(),
18439            label: "Town Quest Board".into(),
18440            x: 130.5,
18441            y: 128.0,
18442            z: 0.0,
18443            board_id: Some("starter_town_board".into()),
18444        }];
18445        // ~2.5m away — outside the old 1.5m interact radius, inside the 3.0m board radius.
18446        assert_eq!(
18447            state.nearest_interact_target().as_deref(),
18448            Some("board-1"),
18449            "quest board should be selectable at ~2.5m"
18450        );
18451        let lines = state.location_context_lines();
18452        assert!(
18453            lines
18454                .iter()
18455                .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
18456            "HUD should advertise f when board is in range: {:?}",
18457            lines
18458        );
18459    }
18460
18461    #[test]
18462    fn quest_board_keeps_multiple_offers() {
18463        let mut state = sample_state();
18464        let offer = |id: &str, title: &str| flatland_protocol::QuestOffer {
18465            quest_id: id.into(),
18466            title: title.into(),
18467            description: format!("{title} desc"),
18468            step_count: 2,
18469        };
18470        state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18471        state.push_quest_offer(offer("daily_20695_1", "Town board: Deer threat"));
18472        state.push_quest_offer(offer("ada_goblin_hunt", "Goblin Trouble"));
18473        assert_eq!(state.pending_quest_offers.len(), 2);
18474        assert_eq!(
18475            state.selected_quest_offer().unwrap().quest_id,
18476            "ada_goblin_hunt"
18477        );
18478        state.move_quest_offer_selection(1);
18479        assert_eq!(
18480            state.selected_quest_offer().unwrap().quest_id,
18481            "daily_20695_1"
18482        );
18483        state.remove_quest_offer("daily_20695_1");
18484        assert_eq!(state.pending_quest_offers.len(), 1);
18485        assert!(state.show_quest_offer);
18486        state.remove_quest_offer("ada_goblin_hunt");
18487        assert!(!state.show_quest_offer);
18488        assert!(state.pending_quest_offers.is_empty());
18489    }
18490
18491    #[test]
18492    fn inventory_selectable_rows_excludes_equipped_shells_but_keeps_bag_contents() {
18493        let mut state = sample_state();
18494        state.worn.insert(
18495            BodySlot::Back,
18496            flatland_protocol::ItemStack {
18497                template_id: "travel_backpack".into(),
18498                quantity: 1,
18499                item_instance_id: Some(uuid::Uuid::from_u128(3)),
18500                props: Default::default(),
18501                status_bindings: Vec::new(),
18502                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
18503                display_name: None,
18504                category: None,
18505                base_mass: None,
18506                base_volume: None,
18507                capacity_volume: None,
18508                stackable: None,
18509                world_placeable: None,
18510                worker_lodging_capacity: None,
18511                equip_slot: None,
18512                armor_physical: None,
18513                resists: vec![],
18514                hand_slots: None,
18515                listable: None,
18516                ..Default::default()
18517            },
18518        );
18519        state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
18520        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18521            id: "chest-1".into(),
18522            template_id: "wooden_chest_small".into(),
18523            display_name: "Wooden Chest".into(),
18524            x: 129.0,
18525            y: 128.0,
18526            z: 0.0,
18527            locked: false,
18528            accessible: true,
18529            owner_character_id: None,
18530            contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
18531            lock_id: None,
18532            capacity_volume: None,
18533            item_instance_id: Some(uuid::Uuid::from_u128(4)),
18534            tile_id: None,
18535            worker_lodging_capacity: None,
18536            blocking: false,
18537            blocking_radius_m: 0.0,
18538            building_id: None,
18539        }];
18540
18541        state.inventory_tab = InventoryTab::OnPerson;
18542        let rows = state.inventory_selectable_rows();
18543        let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
18544        assert_eq!(
18545            sections,
18546            vec![
18547                InventorySection::Person, // iron_ore carried in backpack
18548                InventorySection::Person, // lumber
18549            ]
18550        );
18551        assert_eq!(rows[0].stack.template_id, "iron_ore");
18552        assert_eq!(rows[0].depth, 0);
18553        assert!(!rows[0].is_equip_shell);
18554        assert_eq!(rows[1].stack.template_id, "lumber");
18555
18556        let lines = state.inventory_browser_lines();
18557        assert!(lines.iter().any(|l| matches!(
18558            l,
18559            InventoryBrowserLine::Section(s) if s.contains("carried bags")
18560        )));
18561        assert!(lines.iter().any(|l| matches!(
18562            l,
18563            InventoryBrowserLine::Item { text, .. } if text.contains("iron_ore")
18564        )));
18565        assert!(!lines.iter().any(|l| matches!(
18566            l,
18567            InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
18568        )));
18569        assert!(!lines.iter().any(|l| matches!(
18570            l,
18571            InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
18572        )));
18573
18574        state.inventory_tab = InventoryTab::Nearby;
18575        let nearby_rows = state.inventory_selectable_rows();
18576        assert_eq!(nearby_rows.len(), 2);
18577        assert!(nearby_rows[0].is_chest_shell);
18578        assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
18579        let nearby_lines = state.inventory_browser_lines();
18580        assert!(nearby_lines.iter().any(|l| matches!(
18581            l,
18582            InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
18583        )));
18584    }
18585
18586    #[test]
18587    fn give_worker_notice_does_not_put_item_back_in_bag() {
18588        let mut state = sample_state();
18589        let id = uuid::Uuid::from_u128(42);
18590        let mut saw = flatland_protocol::ItemStack::simple("handsaw", 1);
18591        saw.item_instance_id = Some(id);
18592        saw.display_name = Some("Handsaw".into());
18593        state.sync_inventory_from_stacks(&[saw]);
18594        assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 1);
18595
18596        state.remove_carried_instance(id, None);
18597        assert_eq!(state.inventory.get("handsaw").copied().unwrap_or(0), 0);
18598        assert!(state.inventory_stacks.is_empty());
18599
18600        state.apply_interaction_notice(&flatland_protocol::InteractionNotice {
18601            target_id: "worker-1".into(),
18602            message: "Gave 1x Handsaw to Laborer".into(),
18603            coins_delta: 0,
18604            inventory_delta: vec![flatland_protocol::ItemStack::simple("handsaw", 1)],
18605        });
18606        assert_eq!(
18607            state.inventory.get("handsaw").copied().unwrap_or(0),
18608            0,
18609            "Gave notice must not restore the handed stack"
18610        );
18611    }
18612
18613    #[test]
18614    fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
18615        let mut state = sample_state();
18616        let back_id = uuid::Uuid::from_u128(5);
18617        state.worn.insert(
18618            BodySlot::Back,
18619            flatland_protocol::ItemStack {
18620                template_id: "travel_backpack".into(),
18621                quantity: 1,
18622                item_instance_id: Some(back_id),
18623                props: Default::default(),
18624                status_bindings: Vec::new(),
18625                contents: Vec::new(),
18626                display_name: None,
18627                category: Some("container".into()),
18628                base_mass: None,
18629                base_volume: None,
18630                capacity_volume: Some(80.0),
18631                stackable: None,
18632                world_placeable: None,
18633                worker_lodging_capacity: None,
18634                equip_slot: None,
18635                armor_physical: None,
18636                resists: vec![],
18637                hand_slots: None,
18638                listable: None,
18639                ..Default::default()
18640            },
18641        );
18642        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18643            id: "chest-1".into(),
18644            template_id: "wooden_chest_small".into(),
18645            display_name: "Wooden Chest".into(),
18646            x: 129.0,
18647            y: 128.0,
18648            z: 0.0,
18649            locked: false,
18650            accessible: true,
18651            owner_character_id: None,
18652            contents: Vec::new(),
18653            lock_id: None,
18654            capacity_volume: None,
18655            item_instance_id: Some(uuid::Uuid::from_u128(6)),
18656            tile_id: None,
18657            worker_lodging_capacity: None,
18658            blocking: false,
18659            blocking_radius_m: 0.0,
18660            building_id: None,
18661        }];
18662
18663        // Item currently sitting loose on the person (Root): backpack + nearby
18664        // chest should both be offered, plus Drop/Cancel, but not "Root" itself.
18665        let opts = state.move_destinations_for(
18666            &flatland_protocol::InventoryLocation::Root,
18667            None,
18668            None,
18669            "lumber",
18670        );
18671        assert!(!opts.iter().any(|o| matches!(
18672            &o.kind,
18673            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
18674        )));
18675        assert!(opts.iter().any(|o| matches!(
18676            &o.kind,
18677            MoveOptionKind::Move { location, parent_instance_id, .. }
18678                if *location == flatland_protocol::InventoryLocation::Worn {
18679                    slot: BodySlot::Back,
18680                } && *parent_instance_id == Some(back_id)
18681        )));
18682        assert!(opts.iter().any(|o| matches!(
18683            &o.kind,
18684            MoveOptionKind::Move { location, .. }
18685                if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
18686        )));
18687        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
18688        assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
18689
18690        // Item currently inside the worn backpack: the backpack itself must be
18691        // excluded from its own destination list (can't move an item into the
18692        // container it's already in).
18693        let from_backpack = flatland_protocol::InventoryLocation::Worn {
18694            slot: BodySlot::Back,
18695        };
18696        let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
18697        assert!(!opts.iter().any(|o| matches!(
18698            &o.kind,
18699            MoveOptionKind::Move { location, parent_instance_id, .. }
18700                if *location == from_backpack && *parent_instance_id == Some(back_id)
18701        )));
18702        assert!(opts.iter().any(|o| matches!(
18703            &o.kind,
18704            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
18705        )));
18706    }
18707
18708    #[test]
18709    fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
18710        let mut state = sample_state();
18711        // Insert out of display order — BTreeMap iteration must still yield the
18712        // canonical Head/Body/Arms/Legs/Feet/Back/Waist order regardless.
18713        state.worn.insert(
18714            BodySlot::Waist,
18715            flatland_protocol::ItemStack {
18716                template_id: "simple_belt".into(),
18717                quantity: 1,
18718                item_instance_id: Some(uuid::Uuid::from_u128(10)),
18719                props: Default::default(),
18720                status_bindings: Vec::new(),
18721                contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
18722                display_name: None,
18723                category: Some("container".into()),
18724                base_mass: None,
18725                base_volume: None,
18726                capacity_volume: None,
18727                stackable: None,
18728                world_placeable: None,
18729                worker_lodging_capacity: None,
18730                equip_slot: None,
18731                armor_physical: None,
18732                resists: vec![],
18733                hand_slots: None,
18734                listable: None,
18735                ..Default::default()
18736            },
18737        );
18738        state.worn.insert(
18739            BodySlot::Head,
18740            flatland_protocol::ItemStack {
18741                template_id: "cloth_cap".into(),
18742                quantity: 1,
18743                item_instance_id: Some(uuid::Uuid::from_u128(11)),
18744                props: Default::default(),
18745                status_bindings: Vec::new(),
18746                contents: Vec::new(),
18747                display_name: None,
18748                category: Some("armor".into()),
18749                base_mass: None,
18750                base_volume: None,
18751                capacity_volume: None,
18752                stackable: None,
18753                world_placeable: None,
18754                worker_lodging_capacity: None,
18755                equip_slot: None,
18756                armor_physical: None,
18757                resists: vec![],
18758                hand_slots: None,
18759                listable: None,
18760                ..Default::default()
18761            },
18762        );
18763        state.worn.insert(
18764            BodySlot::Back,
18765            flatland_protocol::ItemStack {
18766                template_id: "travel_backpack".into(),
18767                quantity: 1,
18768                item_instance_id: Some(uuid::Uuid::from_u128(12)),
18769                props: Default::default(),
18770                status_bindings: Vec::new(),
18771                contents: Vec::new(),
18772                display_name: None,
18773                category: Some("container".into()),
18774                base_mass: None,
18775                base_volume: None,
18776                capacity_volume: None,
18777                stackable: None,
18778                world_placeable: None,
18779                worker_lodging_capacity: None,
18780                equip_slot: None,
18781                armor_physical: None,
18782                resists: vec![],
18783                hand_slots: None,
18784                listable: None,
18785                ..Default::default()
18786            },
18787        );
18788
18789        let rows = state.worn_rows();
18790        // Head, then Back, then Waist (+ nested pouch) — enum declaration order.
18791        assert_eq!(rows.len(), 4);
18792        assert_eq!(rows[0].stack.template_id, "cloth_cap");
18793        assert!(rows[0].is_equip_shell);
18794        assert_eq!(rows[1].stack.template_id, "travel_backpack");
18795        assert!(rows[1].is_equip_shell);
18796        assert_eq!(rows[2].stack.template_id, "simple_belt");
18797        assert!(rows[2].is_equip_shell);
18798        assert_eq!(rows[3].stack.template_id, "leather_pouch");
18799        assert_eq!(rows[3].depth, 1);
18800        assert!(!rows[3].is_equip_shell);
18801    }
18802
18803    #[test]
18804    fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
18805        let mut state = sample_state();
18806        state.worn.insert(
18807            BodySlot::Waist,
18808            flatland_protocol::ItemStack {
18809                template_id: "simple_belt".into(),
18810                quantity: 1,
18811                item_instance_id: Some(uuid::Uuid::from_u128(20)),
18812                props: Default::default(),
18813                status_bindings: Vec::new(),
18814                contents: Vec::new(),
18815                display_name: Some("Simple Belt".into()),
18816                category: Some("container".into()),
18817                base_mass: None,
18818                base_volume: None,
18819                capacity_volume: None,
18820                stackable: None,
18821                world_placeable: None,
18822                worker_lodging_capacity: None,
18823                equip_slot: None,
18824                armor_physical: None,
18825                resists: vec![],
18826                hand_slots: None,
18827                listable: None,
18828                ..Default::default()
18829            },
18830        );
18831        state.worn.insert(
18832            BodySlot::Head,
18833            flatland_protocol::ItemStack {
18834                template_id: "cloth_cap".into(),
18835                quantity: 1,
18836                item_instance_id: Some(uuid::Uuid::from_u128(21)),
18837                props: Default::default(),
18838                status_bindings: Vec::new(),
18839                contents: Vec::new(),
18840                display_name: Some("Cloth Cap".into()),
18841                category: Some("armor".into()),
18842                base_mass: None,
18843                base_volume: None,
18844                capacity_volume: None,
18845                stackable: None,
18846                world_placeable: None,
18847                worker_lodging_capacity: None,
18848                equip_slot: None,
18849                armor_physical: None,
18850                resists: vec![],
18851                hand_slots: None,
18852                listable: None,
18853                ..Default::default()
18854            },
18855        );
18856
18857        let opts = state.move_destinations_for(
18858            &flatland_protocol::InventoryLocation::Root,
18859            None,
18860            None,
18861            "leather_pouch",
18862        );
18863        assert!(
18864            opts.iter().any(|o| matches!(
18865                &o.kind,
18866                MoveOptionKind::Move { location, .. }
18867                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18868            )),
18869            "belt loop must be offered when moving a pouch"
18870        );
18871        assert!(
18872            !opts.iter().any(|o| matches!(
18873                &o.kind,
18874                MoveOptionKind::Move { location, .. }
18875                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
18876            )),
18877            "armor slots can't hold other items and must not appear as move destinations"
18878        );
18879        let belt_opt = opts
18880            .iter()
18881            .find(|o| matches!(
18882                &o.kind,
18883                MoveOptionKind::Move { location, .. }
18884                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18885            ))
18886            .unwrap();
18887        assert!(belt_opt.label.contains("belt loop"));
18888
18889        let opts = state.move_destinations_for(
18890            &flatland_protocol::InventoryLocation::Root,
18891            None,
18892            None,
18893            "lumber",
18894        );
18895        assert!(
18896            !opts.iter().any(|o| o.label.contains("belt loop")),
18897            "loose materials must not target the belt shell — only nested pouches"
18898        );
18899    }
18900
18901    #[test]
18902    fn move_destinations_for_offers_dimensional_pouch_on_belt() {
18903        let mut state = sample_state();
18904        let belt_id = uuid::Uuid::from_u128(30);
18905        let pouch_id = uuid::Uuid::from_u128(31);
18906        state.worn.insert(
18907            BodySlot::Waist,
18908            flatland_protocol::ItemStack {
18909                template_id: "simple_belt".into(),
18910                quantity: 1,
18911                item_instance_id: Some(belt_id),
18912                props: Default::default(),
18913                status_bindings: Vec::new(),
18914                world_placeable: None,
18915                worker_lodging_capacity: None,
18916                equip_slot: None,
18917                armor_physical: None,
18918                resists: vec![],
18919                hand_slots: None,
18920                contents: vec![flatland_protocol::ItemStack {
18921                    template_id: "dimensional_pouch".into(),
18922                    quantity: 1,
18923                    item_instance_id: Some(pouch_id),
18924                    props: Default::default(),
18925                    status_bindings: Vec::new(),
18926                    contents: Vec::new(),
18927                    display_name: Some("Dimensional Pouch".into()),
18928                    category: Some("container".into()),
18929                    base_mass: None,
18930                    base_volume: None,
18931                    capacity_volume: Some(200.0),
18932                    stackable: None,
18933                    world_placeable: None,
18934                    worker_lodging_capacity: None,
18935                    equip_slot: None,
18936                    armor_physical: None,
18937                    resists: vec![],
18938                    hand_slots: None,
18939                    listable: None,
18940                    ..Default::default()
18941                }],
18942                display_name: Some("Simple Belt".into()),
18943                category: Some("container".into()),
18944                base_mass: None,
18945                base_volume: None,
18946                capacity_volume: None,
18947                stackable: None,
18948                listable: None,
18949                ..Default::default()
18950            },
18951        );
18952
18953        let opts = state.move_destinations_for(
18954            &flatland_protocol::InventoryLocation::Root,
18955            None,
18956            None,
18957            "iron_ore",
18958        );
18959        assert!(
18960            opts.iter().any(|o| matches!(
18961                &o.kind,
18962                MoveOptionKind::Move {
18963                    location,
18964                    parent_instance_id,
18965                    ..
18966                } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
18967                    && *parent_instance_id == Some(pouch_id)
18968            )),
18969            "dimensional pouch clipped on belt must accept loose items"
18970        );
18971        assert!(
18972            opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
18973            "destination label should name the pouch"
18974        );
18975    }
18976
18977    #[test]
18978    fn container_volume_label_on_placed_chest_shell() {
18979        let mut state = sample_state();
18980        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
18981            id: "chest-1".into(),
18982            template_id: "wooden_chest_small".into(),
18983            display_name: "Camp Chest".into(),
18984            x: 129.0,
18985            y: 128.0,
18986            z: 0.0,
18987            locked: false,
18988            accessible: true,
18989            owner_character_id: None,
18990            contents: vec![flatland_protocol::ItemStack {
18991                template_id: "iron_ore".into(),
18992                quantity: 2,
18993                item_instance_id: None,
18994                props: Default::default(),
18995                status_bindings: Vec::new(),
18996                contents: Vec::new(),
18997                display_name: None,
18998                category: None,
18999                base_mass: None,
19000                base_volume: Some(2.0),
19001                capacity_volume: None,
19002                stackable: None,
19003                world_placeable: None,
19004                worker_lodging_capacity: None,
19005                equip_slot: None,
19006                armor_physical: None,
19007                resists: vec![],
19008                hand_slots: None,
19009                listable: None,
19010                ..Default::default()
19011            }],
19012            lock_id: None,
19013            capacity_volume: Some(60.0),
19014            item_instance_id: Some(uuid::Uuid::from_u128(4)),
19015            tile_id: None,
19016            worker_lodging_capacity: None,
19017            blocking: false,
19018            blocking_radius_m: 0.0,
19019            building_id: None,
19020        }];
19021        let nearby = state.nearby_containers();
19022        let label = state.container_volume_label(&nearby[0].rows[0]);
19023        assert!(
19024            label.contains("vol 4/60"),
19025            "expected used/cap in label, got {label}"
19026        );
19027        assert!(
19028            label.contains("56 free"),
19029            "expected free space, got {label}"
19030        );
19031    }
19032
19033    #[test]
19034    fn key_pair_chest_label_from_placed_lock_id() {
19035        let mut state = sample_state();
19036        let owner = uuid::Uuid::from_u128(77);
19037        state.character_id = Some(owner);
19038        let lock = uuid::Uuid::from_u128(99).to_string();
19039        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19040            id: "chest-1".into(),
19041            template_id: "wooden_chest_small".into(),
19042            display_name: "Barry's Loot #a3f2".into(),
19043            x: 129.0,
19044            y: 128.0,
19045            z: 0.0,
19046            locked: true,
19047            accessible: true,
19048            owner_character_id: Some(owner),
19049            contents: Vec::new(),
19050            lock_id: Some(lock.clone()),
19051            capacity_volume: None,
19052            item_instance_id: Some(uuid::Uuid::from_u128(4)),
19053            tile_id: None,
19054            worker_lodging_capacity: None,
19055            blocking: false,
19056            blocking_radius_m: 0.0,
19057            building_id: None,
19058        }];
19059        let key_id = uuid::Uuid::from_u128(5);
19060        let key = flatland_protocol::ItemStack {
19061            template_id: KEY_TEMPLATE.into(),
19062            quantity: 1,
19063            item_instance_id: Some(key_id),
19064            props: BTreeMap::from([
19065                (PROP_OPENS_LOCK_ID.into(), lock),
19066                (
19067                    PROP_OPENS_CONTAINER_NAME.into(),
19068                    "Barry's Loot #a3f2".into(),
19069                ),
19070            ]),
19071            status_bindings: Vec::new(),
19072            contents: Vec::new(),
19073            display_name: Some("Container Key".into()),
19074            category: Some("key".into()),
19075            base_mass: None,
19076            base_volume: None,
19077            capacity_volume: None,
19078            stackable: None,
19079            world_placeable: None,
19080            worker_lodging_capacity: None,
19081            equip_slot: None,
19082            armor_physical: None,
19083            resists: vec![],
19084            hand_slots: None,
19085            listable: None,
19086            ..Default::default()
19087        };
19088        state.inventory_stacks = vec![key.clone()];
19089        assert_eq!(
19090            state.key_pair_chest_label(&key).as_deref(),
19091            Some("Barry's Loot #a3f2")
19092        );
19093        assert!(state.key_drop_blocked(&key));
19094    }
19095
19096    #[test]
19097    fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
19098        let mut state = sample_state();
19099        let lock = uuid::Uuid::from_u128(101).to_string();
19100        let key = flatland_protocol::ItemStack {
19101            template_id: KEY_TEMPLATE.into(),
19102            quantity: 1,
19103            item_instance_id: Some(uuid::Uuid::from_u128(7)),
19104            props: BTreeMap::from([
19105                (PROP_OPENS_LOCK_ID.into(), lock),
19106                (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
19107            ]),
19108            status_bindings: Vec::new(),
19109            contents: Vec::new(),
19110            display_name: None,
19111            category: Some("key".into()),
19112            base_mass: None,
19113            base_volume: None,
19114            capacity_volume: None,
19115            stackable: None,
19116            world_placeable: None,
19117            worker_lodging_capacity: None,
19118            equip_slot: None,
19119            armor_physical: None,
19120            resists: vec![],
19121            hand_slots: None,
19122            listable: None,
19123            ..Default::default()
19124        };
19125        state.placed_containers.clear();
19126        assert_eq!(
19127            state.key_pair_chest_label(&key).as_deref(),
19128            Some("Camp Stash")
19129        );
19130    }
19131
19132    #[test]
19133    fn key_drop_allowed_when_paired_chest_unlocked() {
19134        let mut state = sample_state();
19135        let lock = uuid::Uuid::from_u128(100).to_string();
19136        let key_id = uuid::Uuid::from_u128(6);
19137        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
19138            id: "chest-1".into(),
19139            template_id: "wooden_chest_small".into(),
19140            display_name: "Camp Chest".into(),
19141            x: 129.0,
19142            y: 128.0,
19143            z: 0.0,
19144            locked: false,
19145            accessible: true,
19146            owner_character_id: None,
19147            contents: Vec::new(),
19148            lock_id: Some(lock.clone()),
19149            capacity_volume: None,
19150            item_instance_id: None,
19151            tile_id: None,
19152            worker_lodging_capacity: None,
19153            blocking: false,
19154            blocking_radius_m: 0.0,
19155            building_id: None,
19156        }];
19157        let key = flatland_protocol::ItemStack {
19158            template_id: KEY_TEMPLATE.into(),
19159            quantity: 1,
19160            item_instance_id: Some(key_id),
19161            props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
19162            status_bindings: Vec::new(),
19163            contents: Vec::new(),
19164            display_name: None,
19165            category: Some("key".into()),
19166            base_mass: None,
19167            base_volume: None,
19168            capacity_volume: None,
19169            stackable: None,
19170            world_placeable: None,
19171            worker_lodging_capacity: None,
19172            equip_slot: None,
19173            armor_physical: None,
19174            resists: vec![],
19175            hand_slots: None,
19176            listable: None,
19177            ..Default::default()
19178        };
19179        state.inventory_stacks = vec![key.clone()];
19180        assert!(!state.key_drop_blocked(&key));
19181        let opts = state.move_destinations_for(
19182            &flatland_protocol::InventoryLocation::Root,
19183            None,
19184            Some(key_id),
19185            KEY_TEMPLATE,
19186        );
19187        assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
19188    }
19189
19190    #[test]
19191    fn combat_hud_refreshes_progression_xp_when_entity_stale() {
19192        use flatland_protocol::{CombatHud, ProgressionCurve, ProgressionXp};
19193
19194        let mut state = sample_state();
19195        let curve = ProgressionCurve::default();
19196        let bootstrap =
19197            ProgressionXp::bootstrap_new(curve.baseline_display, curve.xp_base, curve.xp_growth);
19198        let mut fresh = bootstrap.clone();
19199        fresh.strength += 0.08;
19200        if let Some(player) = state.player.as_mut() {
19201            player.progression_xp = Some(bootstrap);
19202        }
19203
19204        let combat = CombatHud {
19205            progression_xp: Some(fresh.clone()),
19206            progression_baseline: curve.baseline_display,
19207            progression_xp_base: curve.xp_base,
19208            progression_xp_growth: curve.xp_growth,
19209            attributes: state.player.as_ref().and_then(|p| p.attributes),
19210            skills: state.player.as_ref().and_then(|p| p.skills.clone()),
19211            ..CombatHud::default()
19212        };
19213        state.apply_combat_hud(&combat);
19214
19215        let xp = state
19216            .player
19217            .as_ref()
19218            .and_then(|p| p.progression_xp.as_ref())
19219            .expect("xp");
19220        assert!((xp.strength - fresh.strength).abs() < 0.001);
19221        assert!(state.progression_curve.is_some());
19222    }
19223
19224    #[test]
19225    fn combat_hud_syncs_known_abilities_and_hotbar() {
19226        use flatland_protocol::CombatHud;
19227
19228        let mut state = sample_state();
19229        let combat = CombatHud {
19230            known_abilities: vec!["unarmed".into(), "fireball".into()],
19231            hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
19232            max_abilities_per_rotation: 4,
19233            ability_id: "short_sword_slash".into(),
19234            ..CombatHud::default()
19235        };
19236        state.apply_combat_hud(&combat);
19237
19238        assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
19239        assert_eq!(state.hotbar_ability(1), Some("fireball"));
19240        assert_eq!(state.hotbar_ability(2), None);
19241        assert_eq!(state.hotbar_ability(3), Some("unarmed"));
19242        assert_eq!(state.max_abilities_per_rotation, 4);
19243        let choices = state.loadout_ability_choices();
19244        assert!(choices.iter().any(|a| a == "short_sword_slash"));
19245        assert!(choices.iter().any(|a| a == "fireball"));
19246    }
19247
19248    #[test]
19249    fn loadout_hotbar_choices_include_inventory_consumables() {
19250        let mut state = sample_state();
19251        state.known_abilities = vec!["unarmed".into()];
19252        state.weapon_ability_id = "unarmed".into();
19253        state.inventory_stacks = vec![flatland_protocol::ItemStack {
19254            template_id: "empty_bottle".into(),
19255            quantity: 1,
19256            item_instance_id: Some(uuid::Uuid::from_u128(9)),
19257            display_name: Some("Glass Bottle of Water".into()),
19258            category: Some("container".into()),
19259            props: [
19260                ("serving".into(), "1".into()),
19261                ("liquid_vessel".into(), "1".into()),
19262                ("serving_holds".into(), "liquid".into()),
19263            ]
19264            .into_iter()
19265            .collect(),
19266            ..Default::default()
19267        }];
19268        state.inventory.insert("empty_bottle".into(), 1);
19269        state.inventory_hints.insert(
19270            "empty_bottle".into(),
19271            InventoryHint {
19272                display_name: "Glass Bottle".into(),
19273                category: "container".into(),
19274                ..Default::default()
19275            },
19276        );
19277
19278        let choices = state.loadout_hotbar_choices();
19279        assert!(choices.iter().any(|c| c.binding == "unarmed"));
19280        let water = choices
19281            .iter()
19282            .find(|c| c.binding == "item:empty_bottle")
19283            .expect("serving bottle binding");
19284        assert_eq!(water.meta.as_deref(), Some("use"));
19285        assert!(water.label.contains("Glass Bottle of Water"));
19286        assert_eq!(state.hotbar_slot_label(1), None, "unbound until set");
19287        state.hotbar = vec![None, None, None, None, Some("item:empty_bottle".into())];
19288        assert_eq!(
19289            state.hotbar_slot_label(5).as_deref(),
19290            Some("Glass Bottle×1")
19291        );
19292    }
19293
19294    #[test]
19295    fn loadout_hotbar_choices_exclude_blueprint_scrolls() {
19296        let mut state = sample_state();
19297        state.known_abilities = vec!["unarmed".into()];
19298        state.weapon_ability_id = "unarmed".into();
19299        state.inventory_stacks = vec![
19300            flatland_protocol::ItemStack {
19301                template_id: "carrot".into(),
19302                quantity: 2,
19303                display_name: Some("Wild Carrot".into()),
19304                category: Some("consumable".into()),
19305                ..Default::default()
19306            },
19307            flatland_protocol::ItemStack {
19308                template_id: "blueprint_dimensional_pouch".into(),
19309                quantity: 1,
19310                display_name: Some("Blueprint — Dimensional Pouch".into()),
19311                category: Some("consumable".into()),
19312                props: [("teaches_blueprint".into(), "craft_dimensional_pouch".into())]
19313                    .into_iter()
19314                    .collect(),
19315                ..Default::default()
19316            },
19317        ];
19318        state.inventory.insert("carrot".into(), 2);
19319        state.inventory.insert("blueprint_dimensional_pouch".into(), 1);
19320        state.inventory_hints.insert(
19321            "carrot".into(),
19322            InventoryHint {
19323                display_name: "Wild Carrot".into(),
19324                category: "consumable".into(),
19325                ..Default::default()
19326            },
19327        );
19328        state.inventory_hints.insert(
19329            "blueprint_dimensional_pouch".into(),
19330            InventoryHint {
19331                display_name: "Blueprint — Dimensional Pouch".into(),
19332                category: "consumable".into(),
19333                ..Default::default()
19334            },
19335        );
19336
19337        let choices = state.loadout_hotbar_choices();
19338        assert!(choices.iter().any(|c| c.binding == "item:carrot"));
19339        assert!(
19340            choices
19341                .iter()
19342                .all(|c| c.binding != "item:blueprint_dimensional_pouch"),
19343            "recipe scrolls must not appear on the hotbar picker: {choices:?}"
19344        );
19345    }
19346
19347    #[test]
19348    fn storage_store_options_excludes_hand_equipped() {
19349        let mut state = sample_state();
19350        let sword_id = uuid::Uuid::from_u128(11);
19351        let ore_id = uuid::Uuid::from_u128(22);
19352        state.inventory_stacks = vec![
19353            flatland_protocol::ItemStack {
19354                template_id: "short_sword".into(),
19355                quantity: 1,
19356                item_instance_id: Some(sword_id),
19357                display_name: Some("Short Sword".into()),
19358                category: Some("weapon".into()),
19359                ..Default::default()
19360            },
19361            flatland_protocol::ItemStack {
19362                template_id: "iron_ore".into(),
19363                quantity: 5,
19364                item_instance_id: Some(ore_id),
19365                display_name: Some("Iron Ore".into()),
19366                category: Some("resource".into()),
19367                ..Default::default()
19368            },
19369        ];
19370        state.mainhand_template_id = Some("short_sword".into());
19371        state.mainhand_instance_id = Some(sword_id);
19372
19373        let opts = state.storage_store_options();
19374        assert_eq!(opts.len(), 1);
19375        assert_eq!(opts[0].item_instance_id, ore_id);
19376        assert!(state.hand_equipped_instance_ids().contains(&sword_id));
19377    }
19378
19379    #[test]
19380    fn loose_consumable_move_picker_offers_use_and_storage() {
19381        let mut state = sample_state();
19382        let inst = uuid::Uuid::from_u128(77);
19383        state.inventory_stacks = vec![flatland_protocol::ItemStack {
19384            template_id: "carrot".into(),
19385            quantity: 2,
19386            item_instance_id: Some(inst),
19387            props: Default::default(),
19388            status_bindings: Vec::new(),
19389            contents: Vec::new(),
19390            display_name: Some("Wild Carrot".into()),
19391            category: Some("consumable".into()),
19392            base_mass: None,
19393            base_volume: None,
19394            capacity_volume: None,
19395            stackable: Some(true),
19396            world_placeable: None,
19397            worker_lodging_capacity: None,
19398            equip_slot: None,
19399            armor_physical: None,
19400            resists: vec![],
19401            hand_slots: None,
19402            listable: None,
19403            ..Default::default()
19404        }];
19405        state.inventory_hints.insert(
19406            "carrot".into(),
19407            InventoryHint {
19408                display_name: "Wild Carrot".into(),
19409                category: "consumable".into(),
19410                base_mass: Some(0.15),
19411                base_volume: Some(0.3),
19412                capacity_volume: None,
19413                stackable: true,
19414                listable: true,
19415                base_value_copper: None,
19416            },
19417        );
19418        state.show_inventory_menu = true;
19419        state.inventory_menu_index = 0;
19420
19421        let row = state.inventory_selected_row().expect("carrot row");
19422        let mut options = state.move_destinations_for(
19423            &row.from,
19424            row.from_parent_instance_id,
19425            row.stack.item_instance_id,
19426            &row.stack.template_id,
19427        );
19428        if row.from == flatland_protocol::InventoryLocation::Root
19429            && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
19430        {
19431            options.insert(
19432                0,
19433                MoveOption {
19434                    label: "Use (eat / drink)".into(),
19435                    kind: MoveOptionKind::Use,
19436                },
19437            );
19438        }
19439
19440        assert_eq!(
19441            options.first().map(|o| &o.label),
19442            Some(&"Use (eat / drink)".into())
19443        );
19444        assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
19445        assert!(options
19446            .iter()
19447            .any(|o| matches!(o.kind, MoveOptionKind::Drop)));
19448    }
19449
19450    #[test]
19451    fn inventory_category_group_order_is_stable() {
19452        assert_eq!(inventory_category_group("weapon").0, "Weapons");
19453        assert_eq!(inventory_category_group("armor").0, "Armor");
19454        assert_eq!(inventory_category_group("consumable").0, "Consumables");
19455        assert_eq!(inventory_category_group("liquid").0, "Consumables");
19456        assert_eq!(inventory_category_group("resource").0, "Resources");
19457        assert_eq!(inventory_category_group("container").0, "Containers");
19458        assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
19459        assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
19460    }
19461
19462    #[test]
19463    fn page_list_index_clamps_without_wrap() {
19464        assert_eq!(page_list_index(0, -1, 25), 0);
19465        assert_eq!(page_list_index(0, 1, 25), 10);
19466        assert_eq!(page_list_index(12, 1, 25), 22);
19467        assert_eq!(page_list_index(22, 1, 25), 24);
19468        assert_eq!(page_list_index(5, 1, 0), 0);
19469        assert_eq!(page_list_index(3, -1, 8), 0);
19470    }
19471
19472    #[test]
19473    fn inventory_filter_hides_non_matching_person_items() {
19474        let mut state = sample_state();
19475        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
19476        sword.display_name = Some("Iron Sword".into());
19477        sword.category = Some("weapon".into());
19478        let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
19479        herb.display_name = Some("Wild Herb".into());
19480        herb.category = Some("consumable".into());
19481        state.inventory_stacks = vec![sword, herb];
19482        state.inventory_tab = InventoryTab::OnPerson;
19483        state.inventory_filter = "sword".into();
19484
19485        let rows = state.inventory_selectable_rows();
19486        assert_eq!(rows.len(), 1);
19487        assert_eq!(rows[0].stack.template_id, "iron_sword");
19488
19489        let lines = state.inventory_browser_lines();
19490        assert!(lines.iter().any(|l| matches!(
19491            l,
19492            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
19493        )));
19494        assert!(!lines.iter().any(|l| matches!(
19495            l,
19496            InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
19497        )));
19498    }
19499
19500    #[test]
19501    fn list_filter_chars_reject_mac_arrow_glyphs() {
19502        assert!(is_list_filter_char('a'));
19503        assert!(is_list_filter_char(' '));
19504        assert!(is_list_filter_char('-'));
19505        assert!(!is_list_filter_char('\u{F700}'));
19506        assert!(!is_list_filter_char('\u{F701}'));
19507        assert!(!is_list_filter_char('\n'));
19508    }
19509
19510    #[test]
19511    fn ready_tab_keeps_in_progress_craft_after_inputs_spent() {
19512        let mut state = sample_state();
19513        state.craft_tab = CraftTab::Ready;
19514        state.blueprints = vec![BlueprintView {
19515            id: "plank".into(),
19516            label: "Plank".into(),
19517            craft_tier: 1,
19518            craft_ticks: 30,
19519            output: "wood_plank".into(),
19520            output_qty: 1,
19521            output_display_name: "Wood Plank".into(),
19522            station: None,
19523            category: None,
19524            inputs: vec![flatland_protocol::BlueprintIngredientView {
19525                template_id: "oak_log".into(),
19526                quantity: 1,
19527                consumed: true,
19528                display_name: "Oak Log".into(),
19529            }],
19530            required_tools: vec![],
19531            skill: None,
19532            failure_chance: 0.0,
19533            worker_train_copper: 0,
19534        }];
19535        // Materials already consumed at craft begin — would drop off Ready without the pin.
19536        state.inventory.clear();
19537        state.craft_channel_blueprint_id = Some("plank".into());
19538        state.timed_channel = Some(flatland_protocol::TimedChannelHud {
19539            label: "Crafting Plank".into(),
19540            channel: flatland_protocol::TimedChannelKind::Craft,
19541            ticks_remaining: 20,
19542            ticks_total: 30,
19543            ..Default::default()
19544        });
19545
19546        let idxs = state.craft_filtered_indices();
19547        assert_eq!(idxs, vec![0]);
19548        assert!(state.craft_blueprint_in_channel("plank"));
19549
19550        // Channel finished → clear pin → Ready empty.
19551        state.timed_channel = None;
19552        state.craft_channel_blueprint_id = None;
19553        assert!(state.craft_filtered_indices().is_empty());
19554    }
19555
19556    #[test]
19557    fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
19558        let mut state = sample_state();
19559        let id_a = uuid::Uuid::from_u128(0xa1);
19560        let id_b = uuid::Uuid::from_u128(0xb2);
19561        let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
19562        sword_a.display_name = Some("Iron Sword".into());
19563        sword_a.category = Some("weapon".into());
19564        sword_a.item_instance_id = Some(id_a);
19565        let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
19566        sword_b.display_name = Some("Iron Sword".into());
19567        sword_b.category = Some("weapon".into());
19568        sword_b.item_instance_id = Some(id_b);
19569        state.inventory_stacks = vec![sword_a, sword_b];
19570        state.inventory_tab = InventoryTab::OnPerson;
19571
19572        let lines = state.inventory_browser_lines();
19573        let items: Vec<_> = lines
19574            .iter()
19575            .filter_map(|l| match l {
19576                InventoryBrowserLine::Item {
19577                    title,
19578                    instance_tooltip,
19579                    ..
19580                } => Some((title.clone(), instance_tooltip.clone())),
19581                _ => None,
19582            })
19583            .collect();
19584        assert_eq!(items.len(), 2);
19585        for (title, tip) in &items {
19586            assert!(
19587                !title.contains('#'),
19588                "title should not show instance suffix: {title}"
19589            );
19590            assert!(
19591                tip.is_some(),
19592                "two identical rows should expose instance on hover"
19593            );
19594        }
19595
19596        state.inventory_stacks.pop();
19597        let lines = state.inventory_browser_lines();
19598        let one = lines.iter().find_map(|l| match l {
19599            InventoryBrowserLine::Item {
19600                title,
19601                instance_tooltip,
19602                ..
19603            } => Some((title.clone(), instance_tooltip.clone())),
19604            _ => None,
19605        });
19606        let (title, tip) = one.expect("one sword row");
19607        assert!(!title.contains('#'));
19608        assert!(tip.is_none(), "single row should not need instance tooltip");
19609    }
19610
19611    #[test]
19612    fn inventory_person_rows_group_by_category() {
19613        let mut state = sample_state();
19614        let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
19615        sword.category = Some("weapon".into());
19616        sword.display_name = Some("Iron Sword".into());
19617        let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
19618        ore.category = Some("resource".into());
19619        ore.display_name = Some("Iron Ore".into());
19620        let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
19621        potion.category = Some("consumable".into());
19622        potion.display_name = Some("Health Potion".into());
19623        state.inventory_stacks = vec![ore, potion, sword];
19624        state.inventory_tab = InventoryTab::OnPerson;
19625
19626        let lines = state.inventory_browser_lines();
19627        let labels: Vec<&str> = lines
19628            .iter()
19629            .filter_map(|l| match l {
19630                InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
19631                _ => None,
19632            })
19633            .collect();
19634        assert!(
19635            labels.iter().any(|s| s.contains("Weapons")),
19636            "expected Weapons group: {labels:?}"
19637        );
19638        assert!(labels.iter().any(|s| s.contains("Consumables")));
19639        assert!(labels.iter().any(|s| s.contains("Resources")));
19640
19641        let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
19642        let consumable_pos = labels
19643            .iter()
19644            .position(|s| s.contains("Consumables"))
19645            .unwrap();
19646        let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
19647        assert!(weapon_pos < consumable_pos);
19648        assert!(consumable_pos < resource_pos);
19649    }
19650
19651    #[test]
19652    fn inventory_tab_cycle_resets_selection() {
19653        let mut state = sample_state();
19654        state.inventory_tab = InventoryTab::OnPerson;
19655        state.inventory_menu_index = 3;
19656        state.inventory_tab = state.inventory_tab.cycle(true);
19657        assert_eq!(state.inventory_tab, InventoryTab::Nearby);
19658        // Client method resets index; enum cycle alone does not — verify cycle labels.
19659        assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
19660        assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
19661        assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
19662        assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
19663    }
19664
19665    #[test]
19666    fn parse_bank_copper_amount_blank_and_zero_mean_all() {
19667        assert_eq!(parse_bank_copper_amount(""), Some(0));
19668        assert_eq!(parse_bank_copper_amount("  "), Some(0));
19669        assert_eq!(parse_bank_copper_amount("0"), Some(0));
19670        assert_eq!(parse_bank_copper_amount("250"), Some(250));
19671        assert_eq!(parse_bank_copper_amount("nope"), None);
19672    }
19673
19674    #[test]
19675    fn parse_storage_quantity_blank_and_zero_mean_all() {
19676        assert_eq!(parse_storage_quantity(""), Some(None));
19677        assert_eq!(parse_storage_quantity("  "), Some(None));
19678        assert_eq!(parse_storage_quantity("0"), Some(None));
19679        assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
19680        assert_eq!(parse_storage_quantity("nope"), None);
19681    }
19682
19683    #[test]
19684    fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
19685        assert!(worker_error_is_hud_noise("path stuck — repathing"));
19686        assert!(worker_error_is_hud_noise(
19687            "path stuck — nudged clear, repathing"
19688        ));
19689        assert!(worker_error_is_hud_noise(
19690            "returned to lodging after path failures"
19691        ));
19692        // Real problem: player may need to place lodging / fix assignment.
19693        assert!(!worker_error_is_hud_noise(
19694            "path stuck — no lodging to reset to"
19695        ));
19696        assert!(!worker_error_is_hud_noise(
19697            "cannot reach Eli — idling"
19698        ));
19699        assert!(worker_error_is_transient(
19700            "storage full; continuing route"
19701        ));
19702        assert!(!worker_error_is_transient(
19703            "storage full (Food Bank) — free chest space or reassign deposit"
19704        ));
19705        assert!(!worker_error_is_hud_noise(
19706            "storage full (Food Bank) — free chest space or reassign deposit"
19707        ));
19708    }
19709
19710    #[test]
19711    fn leaving_building_restores_outdoor_z_bands() {
19712        use flatland_protocol::{InteriorMapView, ZPlatformView};
19713
19714        let mut state = sample_state();
19715        state.z_platforms.clear();
19716        state.z_transitions.clear();
19717        state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
19718        state.interior_map = Some(InteriorMapView {
19719            building_id: "broker_hut".into(),
19720            blueprint_id: "broker_hut".into(),
19721            background_color: "#000".into(),
19722            default_floor_color: None,
19723            floor_height_m: 3.0,
19724            z_platforms: vec![ZPlatformView {
19725                id: "floor_0".into(),
19726                z: 0.0,
19727                x0: 0.0,
19728                y0: 0.0,
19729                x1: 8.0,
19730                y1: 8.0,
19731            }],
19732            z_transitions: vec![],
19733            rooms: vec![],
19734            room_doors: vec![],
19735        });
19736        state.sync_interior_map_context();
19737        assert_eq!(
19738            state.z_platforms.len(),
19739            1,
19740            "indoors installs interior platforms"
19741        );
19742        assert!(state.z_bands_outdoor_backup.is_some());
19743
19744        state.player.as_mut().unwrap().inside_building = None;
19745        state.sync_interior_map_context();
19746        assert!(
19747            state.z_platforms.is_empty(),
19748            "leaving must restore outdoor bands (empty), not leave interior platforms"
19749        );
19750        assert!(state.z_bands_outdoor_backup.is_none());
19751        assert!(state.interior_map.is_none());
19752    }
19753
19754    #[test]
19755    fn resource_node_route_label_prefers_friendly_label_with_suffix() {
19756        let node = ResourceNodeView {
19757            id: "crop-carrot-1_copy10".into(),
19758            label: "crop-carrot-1_copy10".into(),
19759            x: 0.0,
19760            y: 0.0,
19761            z: 0.0,
19762            item_template: "carrot".into(),
19763            state: ResourceNodeState::Available,
19764            blocking: false,
19765            blocking_radius_m: 0.5,
19766            harvest_off: false,
19767            tile_id: None,
19768            yaw: 0.0,
19769            pitch: 0.0,
19770            roll: 0.0,
19771            draw_scale: 1.0,
19772            sprite_mode: None,
19773            growth_progress: None,
19774            presentation_state: None,
19775            channel_start_tick: None,
19776            channel_end_tick: None,
19777            harvest_drop_templates: vec![],
19778        };
19779        let label = super::resource_node_route_label(&node);
19780        assert!(label.starts_with("Carrot ("), "got {label}");
19781        assert!(label.ends_with(')'), "got {label}");
19782
19783        let mut named = node;
19784        named.label = "Sweet Pad".into();
19785        named.id = "crop-carrot-a3f2b1c0".into();
19786        assert_eq!(super::resource_node_route_label(&named), "Sweet Pad (b1c0)");
19787    }
19788
19789    #[test]
19790    fn plot_public_label_uses_owner_zone_and_label() {
19791        let plot = flatland_protocol::PropertyPlotView {
19792            plot_id: uuid::Uuid::nil(),
19793            property_zone_id: "zone_a".into(),
19794            zone_label: Some("Starter Town East 1".into()),
19795            deed_instance_id: uuid::Uuid::nil(),
19796            x0: 0.0,
19797            y0: 0.0,
19798            x1: 4.0,
19799            y1: 4.0,
19800            upkeep_copper_per_day: 1,
19801            arrears_days: 0,
19802            is_mine: true,
19803            may_farm: true,
19804            purchase_basis_copper: 0,
19805            farm_public: false,
19806            public_tax_discount_bps: 0,
19807            farm_allow: vec![],
19808            owner_character_id: None,
19809            owner_label: Some("Madsin".into()),
19810            building_id: None,
19811            plot_code: "xyz1234a".into(),
19812            label: "Food Pad".into(),
19813        };
19814        assert_eq!(
19815            super::plot_public_label(&plot),
19816            "Madsin — Starter Town East 1 — Food Pad"
19817        );
19818    }
19819
19820    #[test]
19821    fn plot_public_label_uses_size_when_label_and_code_blank() {
19822        let plot = flatland_protocol::PropertyPlotView {
19823            plot_id: uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap(),
19824            property_zone_id: String::new(),
19825            zone_label: None,
19826            deed_instance_id: uuid::Uuid::nil(),
19827            x0: 10.0,
19828            y0: 20.0,
19829            x1: 18.0,
19830            y1: 28.0,
19831            upkeep_copper_per_day: 1,
19832            arrears_days: 0,
19833            is_mine: true,
19834            may_farm: true,
19835            purchase_basis_copper: 0,
19836            farm_public: false,
19837            public_tax_discount_bps: 0,
19838            farm_allow: vec![],
19839            owner_character_id: None,
19840            owner_label: None,
19841            building_id: None,
19842            plot_code: String::new(),
19843            label: String::new(),
19844        };
19845        assert_eq!(super::plot_public_label(&plot), "Homestead — Plot (8×8 m)");
19846        assert!(!super::plot_public_label(&plot).contains("19fe35f"));
19847    }
19848
19849    #[test]
19850    fn plot_stop_label_prefers_view_over_hex() {
19851        let plot_id = uuid::Uuid::parse_str("19fe35f0-0000-4000-8000-000000000001").unwrap();
19852        let plot = flatland_protocol::PropertyPlotView {
19853            plot_id,
19854            property_zone_id: "zone_a".into(),
19855            zone_label: Some("Starter Town East".into()),
19856            deed_instance_id: uuid::Uuid::nil(),
19857            x0: 0.0,
19858            y0: 0.0,
19859            x1: 4.0,
19860            y1: 4.0,
19861            upkeep_copper_per_day: 1,
19862            arrears_days: 0,
19863            is_mine: true,
19864            may_farm: true,
19865            purchase_basis_copper: 0,
19866            farm_public: false,
19867            public_tax_discount_bps: 0,
19868            farm_allow: vec![],
19869            owner_character_id: None,
19870            owner_label: Some("Madsin".into()),
19871            building_id: None,
19872            plot_code: "xyz1234a".into(),
19873            label: "Food Pad".into(),
19874        };
19875        assert_eq!(
19876            super::plot_stop_label(&[plot.clone()], plot_id),
19877            "Madsin — Starter Town East — Food Pad"
19878        );
19879        let missing = uuid::Uuid::parse_str("1a001590-0000-4000-8000-000000000002").unwrap();
19880        assert_eq!(super::plot_stop_label(&[plot], missing), "plot 1a001590");
19881    }
19882}