Skip to main content

flatland_client_lib/
game.rs

1use std::collections::{BTreeMap, VecDeque};
2use std::time::{Duration, Instant};
3
4use flatland_protocol::{
5    AbilityCooldownHud, BlueprintView, BodySlot, BuildingView, CastProgressHud, CombatHud,
6    CombatSlotHud, CombatTargetHud, DoorView, EntityId, EntityState, Intent, InteriorMapView,
7    LifeState, NpcView, RotationPreset, Seq, SessionId, TerrainKindView, TerrainZoneView, Tick,
8    ZPlatformView, ZTransitionView,
9};
10
11use crate::session::{PlayConnection, SessionEvent};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum CharacterSheetTab {
15    #[default]
16    Character,
17    Ledger,
18    Career,
19}
20
21impl CharacterSheetTab {
22    pub fn cycle(self) -> Self {
23        match self {
24            Self::Character => Self::Ledger,
25            Self::Ledger => Self::Career,
26            Self::Career => Self::Character,
27        }
28    }
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum LedgerPeriod {
33    #[default]
34    Day,
35    Week,
36    Month,
37    Lifetime,
38}
39
40impl LedgerPeriod {
41    pub fn label(self) -> &'static str {
42        match self {
43            Self::Day => "Day",
44            Self::Week => "Week",
45            Self::Month => "Month",
46            Self::Lifetime => "All",
47        }
48    }
49
50    pub fn cycle(self) -> Self {
51        match self {
52            Self::Day => Self::Week,
53            Self::Week => Self::Month,
54            Self::Month => Self::Lifetime,
55            Self::Lifetime => Self::Day,
56        }
57    }
58
59    pub fn from_digit(c: char) -> Option<Self> {
60        match c {
61            '1' => Some(Self::Day),
62            '2' => Some(Self::Week),
63            '3' => Some(Self::Month),
64            '4' => Some(Self::Lifetime),
65            _ => None,
66        }
67    }
68}
69
70/// Matches `flatland_sim::containers` prop keys (client does not depend on sim).
71const KEY_TEMPLATE: &str = "container_key";
72const PROP_LOCK_ID: &str = "lock_id";
73const PROP_OPENS_LOCK_ID: &str = "opens_lock_id";
74const PROP_OPENS_CONTAINER_NAME: &str = "opens_container_name";
75const PROP_CUSTOM_NAME: &str = "custom_name";
76const PROP_LOCKED: &str = "locked";
77
78fn stack_is_locked(stack: &flatland_protocol::ItemStack) -> bool {
79    stack
80        .props
81        .get(PROP_LOCKED)
82        .is_some_and(|v| v == "true" || v == "1")
83}
84
85const MAX_LOG_LINES: usize = 200;
86const MAX_SHOP_TRADE_LOG_LINES: usize = 40;
87const INTERACTION_RADIUS_M: f32 = 1.5;
88const DOOR_INTERACTION_RADIUS_M: f32 = 3.5;
89const QUEST_BOARD_INTERACTION_RADIUS_M: f32 = 3.0;
90const HARVEST_CLIENT_TIMEOUT: Duration = Duration::from_secs(12);
91/// Matches `assets/config/server-settings.yaml` default for batch-cap UI estimates.
92const CRAFT_STAMINA_COST: f32 = 3.0;
93/// Minimum time the workers-menu `step:` line holds a value before accepting a change.
94const WORKER_STEP_HOLD: Duration = Duration::from_millis(1200);
95
96/// Catalog hints synced from server `ItemStack` wire rows.
97#[derive(Debug, Clone, Default)]
98pub struct InventoryHint {
99    pub display_name: String,
100    pub category: String,
101    pub base_mass: Option<f32>,
102    pub base_volume: Option<f32>,
103    pub capacity_volume: Option<f32>,
104    pub stackable: bool,
105}
106
107/// Rotation editor overlay mode (`plans/26` §C2.5).
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
109pub enum RotationEditorMode {
110    #[default]
111    List,
112    EditSequence,
113    PickAbility,
114    EditLabel,
115}
116
117/// Local rotation editor UI state (not persisted).
118#[derive(Debug, Clone, Default)]
119pub struct RotationEditorState {
120    pub mode: RotationEditorMode,
121    pub list_index: usize,
122    pub ability_index: usize,
123    pub picker_index: usize,
124    pub draft: Option<RotationPreset>,
125    pub label_buffer: String,
126}
127
128impl RotationEditorState {
129    pub fn reset(&mut self) {
130        *self = Self::default();
131    }
132}
133
134/// Max distance (m) a placed chest can be browsed/moved-into from the inventory
135/// UI. Mirrors `flatland_sim::interaction::CONTAINER_INTERACTION_RADIUS_M` so the
136/// client only ever shows chests the server will actually let you use — this is
137/// what makes a chest disappear from the menu as soon as you walk away.
138pub const CONTAINER_RANGE_M: f32 = 3.0;
139
140/// Broad section of the inventory browser a row belongs to (drives the grouped
141/// "Worn" / "On you" / "Nearby chest" headers in the UI).
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum InventorySection {
144    /// Inside a worn body-slot item (backpack, belt w/ clipped pouches, armor).
145    Worn,
146    /// Loose on your person — not worn, not inside a placed chest.
147    Person,
148    /// Inside a placed chest within reach.
149    Nearby,
150}
151
152/// Short display label for a body slot (`plans/08` §4.1) — shared by the inventory
153/// browser section headers and the move-destination picker.
154pub fn body_slot_label(slot: BodySlot) -> &'static str {
155    match slot {
156        BodySlot::Head => "Head",
157        BodySlot::Chest => "Chest",
158        BodySlot::Forearms => "Forearms",
159        BodySlot::Legs => "Legs",
160        BodySlot::Feet => "Feet",
161        BodySlot::Cloak => "Cloak",
162        BodySlot::Back => "Back",
163        BodySlot::Waist => "Waist",
164        BodySlot::Earrings => "Earrings",
165        BodySlot::Necklace => "Necklace",
166        BodySlot::Eyeglasses => "Eyeglasses",
167        BodySlot::RingLeft1 => "Ring L1",
168        BodySlot::RingLeft2 => "Ring L2",
169        BodySlot::RingRight1 => "Ring R1",
170        BodySlot::RingRight2 => "Ring R2",
171    }
172}
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq)]
175pub enum EquipPaperdollRow {
176    Body { slot: BodySlot, filled: bool },
177    Mainhand { filled: bool },
178    Offhand { filled: bool, locked: bool },
179}
180
181pub fn equip_paperdoll_rows(state: &GameState) -> Vec<EquipPaperdollRow> {
182    let mut rows: Vec<EquipPaperdollRow> = BodySlot::ALL
183        .iter()
184        .map(|slot| EquipPaperdollRow::Body {
185            slot: *slot,
186            filled: state.worn.contains_key(slot),
187        })
188        .collect();
189    let two_hand = state.mainhand_hand_slots >= 2;
190    rows.push(EquipPaperdollRow::Mainhand {
191        filled: state.mainhand_template_id.is_some(),
192    });
193    rows.push(EquipPaperdollRow::Offhand {
194        filled: state.offhand_template_id.is_some(),
195        locked: two_hand,
196    });
197    rows
198}
199
200fn first_inventory_for_slot(state: &GameState, slot: BodySlot) -> Option<uuid::Uuid> {
201    for stack in &state.inventory_stacks {
202        let matches = stack
203            .equip_slot
204            .map(|s| s == slot || (is_client_ring(s) && is_client_ring(slot)))
205            .unwrap_or(false)
206            || guess_body_slot(&stack.template_id) == Some(slot);
207        if matches {
208            return stack.item_instance_id;
209        }
210    }
211    None
212}
213
214fn is_client_ring(slot: BodySlot) -> bool {
215    matches!(
216        slot,
217        BodySlot::RingLeft1
218            | BodySlot::RingLeft2
219            | BodySlot::RingRight1
220            | BodySlot::RingRight2
221    )
222}
223
224fn first_inventory_weapon(state: &GameState) -> Option<String> {
225    for stack in &state.inventory_stacks {
226        if stack.category.as_deref() == Some("weapon") {
227            return Some(stack.template_id.clone());
228        }
229    }
230    None
231}
232
233fn first_inventory_offhand(state: &GameState) -> Option<String> {
234    for stack in &state.inventory_stacks {
235        let cat = stack.category.as_deref().unwrap_or("");
236        if matches!(cat, "shield" | "offhand") {
237            return Some(stack.template_id.clone());
238        }
239    }
240    None
241}
242
243/// Client-side naming heuristic for "Enter equips this" — prefers catalog `equip_slot`
244/// on the stack when present; otherwise guesses from `template_id`.
245fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
246    if template_id.contains("backpack") {
247        Some(BodySlot::Back)
248    } else if template_id.contains("belt") {
249        Some(BodySlot::Waist)
250    } else if template_id.contains("cloak") || template_id.contains("cape") {
251        Some(BodySlot::Cloak)
252    } else if template_id.contains("cap")
253        || template_id.contains("hat")
254        || template_id.contains("helm")
255    {
256        Some(BodySlot::Head)
257    } else if template_id.contains("shirt")
258        || template_id.contains("robe")
259        || template_id.contains("vest")
260        || template_id.contains("chest")
261        || template_id.contains("jerkin")
262    {
263        Some(BodySlot::Chest)
264    } else if template_id.contains("sleeves")
265        || template_id.contains("gloves")
266        || template_id.contains("gauntlets")
267    {
268        Some(BodySlot::Forearms)
269    } else if template_id.contains("pants") || template_id.contains("leggings") {
270        Some(BodySlot::Legs)
271    } else if template_id.contains("boots") || template_id.contains("shoes") {
272        Some(BodySlot::Feet)
273    } else if template_id.contains("earring") {
274        Some(BodySlot::Earrings)
275    } else if template_id.contains("necklace") || template_id.contains("amulet") {
276        Some(BodySlot::Necklace)
277    } else if template_id.contains("glass")
278        || template_id.contains("spectacles")
279        || template_id.contains("goggles")
280    {
281        Some(BodySlot::Eyeglasses)
282    } else if template_id.contains("ring") {
283        Some(BodySlot::RingLeft1)
284    } else {
285        None
286    }
287}
288
289/// One row in the inventory browser tree.
290#[derive(Debug, Clone)]
291pub struct InventoryRow {
292    pub depth: usize,
293    pub stack: flatland_protocol::ItemStack,
294    /// `MoveItem` source location for this stack.
295    pub from: flatland_protocol::InventoryLocation,
296    /// Parent container instance when nested (belt shell, backpack, chest, pouch).
297    pub from_parent_instance_id: Option<uuid::Uuid>,
298    /// Equipped bag/chest shell — unequip via Enter instead of the move picker.
299    pub is_equip_shell: bool,
300    /// Placed world chest shell — lock/unlock via Enter or `l`.
301    pub is_chest_shell: bool,
302    pub section: InventorySection,
303}
304
305/// Formatted inventory row text shared by TUI and gfx browsers.
306#[derive(Debug, Clone)]
307pub struct InventoryRowView {
308    pub depth: usize,
309    /// Dense single-line label (legacy / TUI).
310    pub text: String,
311    /// Primary label for redesigned gfx rows (name, qty, optional #id / slot).
312    pub title: String,
313    pub mass_kg: Option<f32>,
314    pub volume: Option<(f32, f32)>,
315}
316
317/// One line in the sectioned inventory browser (headers are non-selectable).
318#[derive(Debug, Clone)]
319pub enum InventoryBrowserLine {
320    Section(String),
321    SlotLabel(String),
322    Hint(String),
323    Blank,
324    Item {
325        selectable_index: usize,
326        selected: bool,
327        depth: usize,
328        text: String,
329        title: String,
330        mass_kg: Option<f32>,
331        volume: Option<(f32, f32)>,
332    },
333}
334
335/// A placed chest within `CONTAINER_RANGE_M`, with its contents pre-flattened for
336/// the browser (empty when locked without the matching key).
337#[derive(Debug, Clone)]
338pub struct NearbyContainer {
339    pub view: flatland_protocol::PlacedContainerView,
340    pub distance_m: f32,
341    pub rows: Vec<InventoryRow>,
342}
343
344/// One key row in the keychain overlay (carried vs stowed).
345#[derive(Debug, Clone)]
346pub struct KeychainEntry {
347    pub stack: flatland_protocol::ItemStack,
348    pub stowed: bool,
349}
350
351/// A destination the currently-picked item could be moved to.
352#[derive(Debug, Clone)]
353pub struct MoveOption {
354    pub label: String,
355    pub kind: MoveOptionKind,
356}
357
358#[derive(Debug, Clone, PartialEq)]
359pub enum MoveOptionKind {
360    Move {
361        location: flatland_protocol::InventoryLocation,
362        parent_instance_id: Option<uuid::Uuid>,
363    },
364    /// Pick up a placed chest/crate; optionally nest into a worn bag afterward.
365    PickupPlaced {
366        container_id: String,
367        nest_location: flatland_protocol::InventoryLocation,
368        nest_parent_instance_id: Option<uuid::Uuid>,
369    },
370    /// Eat/drink a loose consumable (one unit per use).
371    Use,
372    Drop,
373    Cancel,
374}
375
376/// Active "move to…" destination picker state for the selected inventory item.
377#[derive(Debug, Clone)]
378pub struct MovePicker {
379    pub item_instance_id: uuid::Uuid,
380    pub from: flatland_protocol::InventoryLocation,
381    pub item_label: String,
382    pub template_id: String,
383    pub stack_quantity: u32,
384    pub quantity: u32,
385    pub options: Vec<MoveOption>,
386}
387
388/// Active permanent-delete picker for the selected inventory item.
389#[derive(Debug, Clone)]
390pub struct DestroyPicker {
391    pub item_instance_id: uuid::Uuid,
392    pub from: flatland_protocol::InventoryLocation,
393    pub item_label: String,
394    pub stack_quantity: u32,
395    pub quantity: u32,
396}
397
398/// One giveable stack in the workers-menu give picker.
399#[derive(Debug, Clone)]
400pub struct WorkerGiveOption {
401    pub item_instance_id: uuid::Uuid,
402    pub label: String,
403    pub quantity: u32,
404    pub template_id: String,
405}
406
407/// Give an on-person inventory stack to the selected hired worker.
408#[derive(Debug, Clone)]
409pub struct WorkerGivePicker {
410    pub worker_instance_id: String,
411    pub worker_label: String,
412    pub options: Vec<WorkerGiveOption>,
413}
414
415/// Nearby hired worker choice when giving a selected inventory stack (`g`).
416#[derive(Debug, Clone)]
417pub struct WorkerGiveTargetOption {
418    pub instance_id: String,
419    pub label: String,
420    pub distance_m: f32,
421}
422
423/// Pick which nearby worker receives the selected inventory item.
424#[derive(Debug, Clone)]
425pub struct WorkerGiveTargetPicker {
426    pub item_instance_id: uuid::Uuid,
427    pub item_label: String,
428    pub quantity: Option<u32>,
429    pub options: Vec<WorkerGiveTargetOption>,
430}
431
432/// Take an item from a hired worker back into the employer's inventory.
433#[derive(Debug, Clone)]
434pub struct WorkerTakePicker {
435    pub worker_instance_id: String,
436    pub worker_label: String,
437    pub options: Vec<WorkerGiveOption>,
438}
439
440/// Max distance (m) to hand an item to a hired worker.
441pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
442
443/// One teachable blueprint in the workers-menu teach picker.
444#[derive(Debug, Clone)]
445pub struct WorkerTeachOption {
446    pub blueprint_id: String,
447    pub label: String,
448    pub cost_copper: u64,
449    pub min_level: u32,
450    pub worker_level: u32,
451    pub can_afford: bool,
452    pub level_ok: bool,
453}
454
455/// Teach a known blueprint to the selected hired worker.
456#[derive(Debug, Clone)]
457pub struct WorkerTeachPicker {
458    pub worker_instance_id: String,
459    pub worker_label: String,
460    pub worker_level: u32,
461    pub options: Vec<WorkerTeachOption>,
462}
463
464/// Sticky workers-menu step line — holds a coarse label so travel/harvest ticks
465/// do not thrash the UI.
466#[derive(Debug, Clone, Default)]
467pub struct StickyWorkerStep {
468    shown: String,
469    pending: String,
470    pending_since: Option<Instant>,
471}
472
473impl StickyWorkerStep {
474    fn from_label(label: String) -> Self {
475        Self {
476            shown: label.clone(),
477            pending: label,
478            pending_since: Some(Instant::now()),
479        }
480    }
481
482    fn observe(&mut self, label: &str, now: Instant) {
483        let pending_since = self.pending_since.unwrap_or(now);
484        if label == self.pending {
485            if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD
486            {
487                self.shown = self.pending.clone();
488            }
489            return;
490        }
491        self.pending = label.to_string();
492        self.pending_since = Some(now);
493        // Empty → first value, or first observation: show immediately.
494        if self.shown.is_empty() {
495            self.shown = self.pending.clone();
496        }
497    }
498}
499
500/// True when a worker `last_error` is transient noise (path retries / soft-skip).
501pub fn worker_error_is_transient(err: &str) -> bool {
502    let e = err.to_ascii_lowercase();
503    e.contains("continuing route")
504        || e.contains("no path")
505        || e.contains("storage full")
506        || e.starts_with("nothing to withdraw")
507}
508
509/// In-flight `SetWorkerJob` waiting for IntentAck (or a reject Interaction).
510#[derive(Debug, Clone)]
511pub struct PendingWorkerJobAck {
512    pub seq: u32,
513    pub worker_instance_id: String,
514    pub worker_label: String,
515    pub idle: bool,
516    pub stop_count: usize,
517    pub prev_route: Option<flatland_protocol::WorkerRouteView>,
518    pub prev_mode: flatland_protocol::WorkerModeView,
519    pub prev_step_label: String,
520    pub prev_last_error: Option<String>,
521}
522
523fn push_inventory_rows(
524    rows: &mut Vec<InventoryRow>,
525    depth: usize,
526    stack: &flatland_protocol::ItemStack,
527    from: &flatland_protocol::InventoryLocation,
528    from_parent_instance_id: Option<uuid::Uuid>,
529    section: InventorySection,
530) {
531    rows.push(InventoryRow {
532        depth,
533        stack: stack.clone(),
534        from: from.clone(),
535        from_parent_instance_id,
536        is_equip_shell: false,
537        is_chest_shell: false,
538        section,
539    });
540    for child in &stack.contents {
541        push_inventory_rows(
542            rows,
543            depth + 1,
544            child,
545            from,
546            stack.item_instance_id,
547            section,
548        );
549    }
550}
551
552#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
553pub enum ShopTab {
554    #[default]
555    Buy,
556    Sell,
557}
558
559#[derive(Debug, Clone)]
560pub struct NpcChatState {
561    pub npc_id: String,
562    pub npc_label: String,
563    pub lines: Vec<String>,
564    pub input: String,
565    pub pending: bool,
566    pub talk_depth: flatland_protocol::NpcTalkDepth,
567    pub trade_allowed: bool,
568    pub banner: Option<String>,
569}
570
571impl Default for NpcChatState {
572    fn default() -> Self {
573        Self {
574            npc_id: String::new(),
575            npc_label: String::new(),
576            lines: Vec::new(),
577            input: String::new(),
578            pending: false,
579            talk_depth: flatland_protocol::NpcTalkDepth::Full,
580            trade_allowed: true,
581            banner: None,
582        }
583    }
584}
585
586#[derive(Debug, Clone)]
587pub struct GameState {
588    pub session_id: SessionId,
589    pub entity_id: EntityId,
590    /// Logged-in character — used to show owner-only container labels.
591    pub character_id: Option<uuid::Uuid>,
592    pub tick: Tick,
593    pub chunk_rev: u64,
594    pub content_rev: u64,
595    pub publish_rev: u64,
596    pub entities: Vec<EntityState>,
597    pub player: Option<EntityState>,
598    pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
599    pub ground_drops: Vec<flatland_protocol::GroundDropView>,
600    pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
601    pub buildings: Vec<BuildingView>,
602    pub doors: Vec<DoorView>,
603    pub interior_map: Option<InteriorMapView>,
604    pub npcs: Vec<NpcView>,
605    pub blueprints: Vec<BlueprintView>,
606    pub world_width_m: f32,
607    pub world_height_m: f32,
608    pub terrain_zones: Vec<TerrainZoneView>,
609    pub z_platforms: Vec<ZPlatformView>,
610    pub z_transitions: Vec<ZTransitionView>,
611    pub world_clock: flatland_protocol::WorldClock,
612    pub inventory: std::collections::HashMap<String, u32>,
613    pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
614    pub logs: VecDeque<String>,
615    pub intents_sent: u64,
616    pub ticks_received: u64,
617    pub connected: bool,
618    pub disconnect_reason: Option<String>,
619    pub show_stats: bool,
620    pub show_equip_menu: bool,
621    pub equip_menu_index: usize,
622    pub show_craft_menu: bool,
623    pub craft_menu_index: usize,
624    /// How many timed crafts to queue when confirming the craft menu.
625    pub craft_batch_quantity: u32,
626    pub show_shop_menu: bool,
627    pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
628    pub shop_tab: ShopTab,
629    pub shop_menu_index: usize,
630    pub shop_quantity: u32,
631    /// Recent buy/sell lines while the shop panel is open (gfx dock).
632    pub shop_trade_log: VecDeque<String>,
633    pub show_npc_verb_menu: bool,
634    pub npc_verb_target: Option<String>,
635    pub npc_verb_index: usize,
636    pub show_npc_chat: bool,
637    pub npc_chat: Option<NpcChatState>,
638    pub show_inventory_menu: bool,
639    pub inventory_menu_index: usize,
640    pub show_move_picker: bool,
641    pub move_picker_index: usize,
642    pub move_picker: Option<MovePicker>,
643    pub show_destroy_picker: bool,
644    pub destroy_confirm_pending: bool,
645    pub destroy_picker: Option<DestroyPicker>,
646    /// Rename prompt for a selected container (`n` in inventory).
647    pub show_rename_prompt: bool,
648    /// Rename prompt for a hired worker (`n` in workers menu).
649    pub show_worker_rename: bool,
650    pub rename_buffer: String,
651    /// Slot-1 combat target (mirrors server after SetTarget).
652    pub combat_target: Option<EntityId>,
653    pub combat_target_label: Option<String>,
654    pub in_combat: bool,
655    pub auto_attack: bool,
656    pub combat_has_los: bool,
657    pub attack_cd_ticks: u64,
658    pub gcd_ticks: u64,
659    pub weapon_ability_id: String,
660    pub mainhand_template_id: Option<String>,
661    pub mainhand_label: Option<String>,
662    pub offhand_template_id: Option<String>,
663    pub offhand_label: Option<String>,
664    pub mainhand_hand_slots: u8,
665    pub defense: Option<flatland_protocol::DefenseHud>,
666    /// Worn body-slot items — armor, cloak, jewelry, backpack, belt.
667    pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
668    pub carry_mass: f32,
669    pub carry_mass_max: f32,
670    pub encumbrance: flatland_protocol::EncumbranceState,
671    /// Full nested inventory stacks from the server (root only; worn are separate).
672    pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
673    /// Keys stowed on the virtual keychain (zero carry mass).
674    pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
675    pub combat_target_detail: Option<CombatTargetHud>,
676    pub cast_progress: Option<CastProgressHud>,
677    pub ability_cooldowns: Vec<AbilityCooldownHud>,
678    pub blocking_active: bool,
679    pub max_target_slots: u8,
680    pub combat_slots: Vec<CombatSlotHud>,
681    pub rotation_presets: Vec<RotationPreset>,
682    pub show_loadout_menu: bool,
683    pub show_keychain_menu: bool,
684    pub keychain_menu_index: usize,
685    pub show_rotation_editor: bool,
686    pub loadout_menu_index: usize,
687    pub rotation_editor: RotationEditorState,
688    /// True after a harvest intent is accepted until result/reject/disconnect.
689    pub harvest_in_progress: bool,
690    /// Wall-clock start of the current harvest; clears stale client state on timeout.
691    pub harvest_started_at: Option<Instant>,
692    /// Craft log deferred until the server acks the craft intent.
693    pub pending_craft_ack: Option<(u32, String, u32)>,
694    pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
695    pub interactables: Vec<flatland_protocol::InteractableView>,
696    pub ledger: Option<flatland_protocol::PlayerLedgerView>,
697    pub career: Option<flatland_protocol::PlayerCareerView>,
698    pub character_sheet_tab: CharacterSheetTab,
699    pub ledger_period: LedgerPeriod,
700    pub show_quest_offer: bool,
701    pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
702    pub show_quest_menu: bool,
703    pub quest_menu_index: usize,
704    pub quest_withdraw_confirm: bool,
705    pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
706    pub show_workers_menu: bool,
707    pub workers_menu_index: usize,
708    /// Workers panel: one-line rows instead of full cards (more on screen).
709    pub workers_menu_compact: bool,
710    /// Coarse `step:` line held per worker so AOI ticks cannot thrash the menu.
711    /// Public so out-of-crate tests can construct [`GameState`].
712    pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
713    /// Give-item sheet opened from the workers menu (`g`) — pick which item to give.
714    pub show_worker_give_picker: bool,
715    pub worker_give_picker_index: usize,
716    pub worker_give_picker: Option<WorkerGivePicker>,
717    /// Inventory `g` — pick which nearby worker receives the selected stack.
718    pub show_worker_give_target_picker: bool,
719    pub worker_give_target_picker_index: usize,
720    pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
721    /// Take-item sheet opened from the workers menu (`i`) — pick which worker stack to take.
722    pub show_worker_take_picker: bool,
723    pub worker_take_picker_index: usize,
724    pub worker_take_picker: Option<WorkerTakePicker>,
725    /// Teach-blueprint sheet opened from the workers menu (`t`).
726    pub show_worker_teach_picker: bool,
727    pub worker_teach_picker_index: usize,
728    pub worker_teach_picker: Option<WorkerTeachPicker>,
729    /// Active harvest-route editor (`h` → `e` on a worker).
730    pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
731    /// Awaiting IntentAck for the last route save (`SetWorkerJob`).
732    pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
733    /// Server progression curve from the latest combat HUD (matches server-settings.yaml).
734    pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
735}
736
737impl GameState {
738    pub fn push_log(&mut self, line: impl Into<String>) {
739        self.logs.push_back(line.into());
740        while self.logs.len() > MAX_LOG_LINES {
741            self.logs.pop_front();
742        }
743    }
744
745    pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
746        self.shop_trade_log.push_back(line.into());
747        while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
748            self.shop_trade_log.pop_front();
749        }
750    }
751
752    pub fn clear_shop_trade_log(&mut self) {
753        self.shop_trade_log.clear();
754    }
755
756    fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
757        if !self.show_shop_menu {
758            return;
759        }
760        let msg = notice.message.trim();
761        if msg.is_empty() {
762            return;
763        }
764        if notice.coins_delta != 0
765            || msg.starts_with("Bought ")
766            || msg.starts_with("Sold ")
767            || msg.contains("taught you how to craft")
768            || msg.starts_with("need ")
769        {
770            self.push_shop_trade_log(msg);
771        }
772    }
773
774    pub fn is_alive(&self) -> bool {
775        self.player
776            .as_ref()
777            .and_then(|p| p.vitals)
778            .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
779            .unwrap_or(true)
780    }
781
782    /// Verb menu entries for the current `npc_verb_target` (Talk, and Trade when applicable).
783    pub fn npc_verb_options(&self) -> Vec<&'static str> {
784        let Some(ref id) = self.npc_verb_target else {
785            return vec![];
786        };
787        let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
788            return vec!["Talk"];
789        };
790        if npc.can_trade || Self::npc_role_can_trade(npc.role.as_str()) {
791            vec!["Talk", "Trade"]
792        } else {
793            vec!["Talk"]
794        }
795    }
796
797    fn npc_role_can_trade(role: &str) -> bool {
798        matches!(role, "broker" | "cook" | "farmer" | "merchant")
799    }
800
801    pub fn clear_harvest_state(&mut self) {
802        self.harvest_in_progress = false;
803        self.harvest_started_at = None;
804    }
805
806    fn harvest_state_stale(&self) -> bool {
807        match self.harvest_started_at {
808            Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
809            None => self.harvest_in_progress,
810        }
811    }
812
813    pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
814        self.player.as_ref().and_then(|p| p.vitals)
815    }
816
817    pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
818        let materials_ok = blueprint.inputs.iter().all(|input| {
819            self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
820        });
821        let tools_ok = blueprint
822            .required_tools
823            .iter()
824            .all(|tool| self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1);
825        let station_ok = match blueprint.station.as_deref() {
826            None | Some("hand") => true,
827            Some(tag) => self.player_at_station_tag(tag),
828        };
829        materials_ok && tools_ok && station_ok
830    }
831
832    pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
833        if !self.can_craft_blueprint(blueprint) {
834            return 0;
835        }
836        let mut limit = u32::MAX;
837        for input in &blueprint.inputs {
838            if input.quantity == 0 {
839                continue;
840            }
841            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
842            limit = limit.min(have / input.quantity);
843        }
844        for tool in &blueprint.required_tools {
845            if tool.consumed {
846                let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
847                limit = limit.min(have);
848            }
849        }
850        let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
851        if CRAFT_STAMINA_COST > 0.0 {
852            limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
853        }
854        limit
855    }
856
857    pub fn clamp_craft_batch_quantity(&mut self) {
858        let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
859            self.craft_batch_quantity = 1;
860            return;
861        };
862        let max = self.max_craft_batches(bp).max(1);
863        self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
864    }
865
866    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
867        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
868            return;
869        };
870        let max = self.max_craft_batches(&bp).max(1);
871        let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
872        self.craft_batch_quantity = next as u32;
873    }
874
875    pub fn craft_batch_set_max(&mut self) {
876        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
877            return;
878        };
879        let max = self.max_craft_batches(&bp);
880        self.craft_batch_quantity = if max == 0 { 1 } else { max };
881    }
882
883    pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
884        let preserve_ui = self.show_shop_menu;
885        let tab = self.shop_tab;
886        let index = self.shop_menu_index;
887        let qty = self.shop_quantity;
888
889        self.show_shop_menu = true;
890        self.show_craft_menu = false;
891        self.show_inventory_menu = false;
892        self.show_stats = false;
893        if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
894            self.npc_verb_target = Some(catalog.npc_id.clone());
895        }
896        self.shop_catalog = Some(catalog);
897
898        if preserve_ui {
899            self.shop_tab = tab;
900            self.shop_menu_index = index;
901            self.shop_quantity = qty;
902        } else {
903            self.shop_tab = ShopTab::Buy;
904            self.shop_menu_index = 0;
905            self.shop_quantity = 1;
906            self.clear_shop_trade_log();
907        }
908        self.show_npc_verb_menu = false;
909        self.clamp_shop_selection();
910    }
911
912    pub fn shop_list_len(&self) -> usize {
913        let Some(catalog) = &self.shop_catalog else {
914            return 0;
915        };
916        match self.shop_tab {
917            ShopTab::Buy => catalog.sells.len(),
918            ShopTab::Sell => catalog.buys.len(),
919        }
920    }
921
922    pub fn shop_menu_move(&mut self, delta: i32) {
923        let n = self.shop_list_len();
924        if n == 0 {
925            return;
926        }
927        let idx = self.shop_menu_index as i32;
928        let next = (idx + delta).rem_euclid(n as i32);
929        self.shop_menu_index = next as usize;
930        self.clamp_shop_quantity();
931    }
932
933    pub fn shop_quantity_adjust(&mut self, delta: i32) {
934        let max = self.shop_quantity_max();
935        if max == 0 {
936            self.shop_quantity = 0;
937            return;
938        }
939        let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
940        self.shop_quantity = next as u32;
941    }
942
943    pub(crate) fn clamp_shop_selection(&mut self) {
944        let n = self.shop_list_len();
945        if n == 0 {
946            self.shop_menu_index = 0;
947        } else {
948            self.shop_menu_index = self.shop_menu_index.min(n - 1);
949        }
950        self.clamp_shop_quantity();
951    }
952
953    fn shop_quantity_max(&self) -> u32 {
954        let Some(catalog) = &self.shop_catalog else {
955            return 1;
956        };
957        match self.shop_tab {
958            ShopTab::Buy => {
959                if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
960                    if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
961                        return 1;
962                    }
963                }
964                99
965            }
966            ShopTab::Sell => catalog
967                .buys
968                .get(self.shop_menu_index)
969                .map(|l| l.quantity)
970                .unwrap_or(0),
971        }
972    }
973
974    pub fn shop_quantity_set_max(&mut self) {
975        self.shop_quantity = self.shop_quantity_max();
976    }
977
978    fn clamp_shop_quantity(&mut self) {
979        let max = self.shop_quantity_max();
980        if max == 0 {
981            self.shop_quantity = 0;
982        } else {
983            self.shop_quantity = self.shop_quantity.max(1).min(max);
984        }
985    }
986
987    pub fn player_at_station_tag(&self, tag: &str) -> bool {
988        let Some(id) = self.effective_inside_building() else {
989            return false;
990        };
991        self.buildings
992            .iter()
993            .find(|b| b.id == id)
994            .is_some_and(|b| b.tags.iter().any(|t| t == tag))
995    }
996
997    /// Short hint for UI when a recipe cannot be started.
998    pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
999        if self.can_craft_blueprint(blueprint) {
1000            return None;
1001        }
1002        let mut missing = Vec::new();
1003        for input in &blueprint.inputs {
1004            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
1005            if have < input.quantity {
1006                missing.push(format!(
1007                    "{}×{} (have {have})",
1008                    input.quantity, input.template_id
1009                ));
1010            }
1011        }
1012        for tool in &blueprint.required_tools {
1013            let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
1014            if have < 1 {
1015                missing.push(format!("tool: {}", tool.item));
1016            }
1017        }
1018        if let Some(station) = blueprint.station.as_deref() {
1019            if station != "hand" && !self.player_at_station_tag(station) {
1020                missing.push(format!("station: {station} (enter building)"));
1021            }
1022        }
1023        if missing.is_empty() {
1024            None
1025        } else {
1026            Some(missing.join(", "))
1027        }
1028    }
1029
1030    pub fn player_entity(&self) -> Option<&EntityState> {
1031        self.player
1032            .as_ref()
1033            .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
1034    }
1035
1036    pub fn player_position(&self) -> (f32, f32) {
1037        let (x, y, _) = self.player_position_with_z();
1038        (x, y)
1039    }
1040
1041    pub fn player_position_with_z(&self) -> (f32, f32, f32) {
1042        if let Some(p) = self.player_entity() {
1043            (
1044                p.transform.position.x,
1045                p.transform.position.y,
1046                p.transform.position.z,
1047            )
1048        } else {
1049            (0.0, 0.0, 0.0)
1050        }
1051    }
1052
1053    pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
1054        let mut rows: Vec<(String, u32, String)> = self
1055            .inventory
1056            .iter()
1057            .filter(|(_, q)| **q > 0)
1058            .map(|(id, qty)| {
1059                let label = self
1060                    .inventory_hints
1061                    .get(id)
1062                    .map(|h| h.display_name.clone())
1063                    .unwrap_or_else(|| id.clone());
1064                (id.clone(), *qty, label)
1065            })
1066            .collect();
1067        rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
1068        rows
1069    }
1070
1071    pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
1072        self.inventory_hints
1073            .get(template_id)
1074            .map(|h| h.category.as_str())
1075            .filter(|c| !c.is_empty())
1076    }
1077
1078    pub fn item_base_mass(&self, template_id: &str) -> f32 {
1079        self.inventory_hints
1080            .get(template_id)
1081            .and_then(|h| h.base_mass)
1082            .unwrap_or(0.5)
1083    }
1084
1085    pub fn item_base_volume(&self, template_id: &str) -> f32 {
1086        self.inventory_hints
1087            .get(template_id)
1088            .and_then(|h| h.base_volume)
1089            .unwrap_or(1.0)
1090    }
1091
1092    pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
1093        let unit = stack
1094            .base_mass
1095            .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
1096        unit * stack.quantity as f32
1097    }
1098
1099    fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
1100        let unit = stack.base_volume.unwrap_or(1.0);
1101        unit * stack.quantity as f32
1102            + stack
1103                .contents
1104                .iter()
1105                .map(Self::stack_tree_volume)
1106                .sum::<f32>()
1107    }
1108
1109    fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
1110        contents.iter().map(Self::stack_tree_volume).sum()
1111    }
1112
1113    fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
1114        self.inventory_hints
1115            .get(template_id)
1116            .and_then(|h| h.capacity_volume)
1117            .filter(|c| *c > 0.0)
1118    }
1119
1120    fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
1121        stack
1122            .capacity_volume
1123            .filter(|c| *c > 0.0)
1124            .or_else(|| self.template_capacity_volume(&stack.template_id))
1125    }
1126
1127    /// Volume used / capacity / free space label for storage containers in the inventory UI.
1128    pub fn container_volume_label(&self, row: &InventoryRow) -> String {
1129        let Some((used, cap)) = self.container_volume_stats(row) else {
1130            return String::new();
1131        };
1132        let free = (cap - used).max(0.0);
1133        format!("  vol {used:.0}/{cap:.0} ({free:.0} free)")
1134    }
1135
1136    fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
1137        if row.is_chest_shell {
1138            let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
1139                return None;
1140            };
1141            let chest = self
1142                .placed_containers
1143                .iter()
1144                .find(|c| c.id == *container_id)?;
1145            let cap = self
1146                .stack_capacity_volume(&row.stack)
1147                .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
1148            let used = if chest.accessible {
1149                Self::contents_used_volume(&chest.contents)
1150            } else {
1151                0.0
1152            };
1153            return Some((used, cap));
1154        }
1155
1156        let cap = self.stack_capacity_volume(&row.stack)?;
1157        let used = Self::contents_used_volume(&row.stack.contents);
1158        Some((used, cap))
1159    }
1160
1161    pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
1162        if row.is_chest_shell {
1163            return true;
1164        }
1165        if row.is_equip_shell {
1166            return self.inventory_item_category(&row.stack.template_id) == Some("container");
1167        }
1168        self.inventory_item_category(&row.stack.template_id) == Some("container")
1169            || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
1170    }
1171
1172    fn container_stack_for(
1173        &self,
1174        location: &flatland_protocol::InventoryLocation,
1175        parent_instance_id: Option<uuid::Uuid>,
1176    ) -> Option<flatland_protocol::ItemStack> {
1177        match location {
1178            flatland_protocol::InventoryLocation::Root => {
1179                let pid = parent_instance_id?;
1180                self.find_stack_by_instance(&self.inventory_stacks, pid)
1181            }
1182            flatland_protocol::InventoryLocation::Worn { slot } => {
1183                let worn = self.worn.get(slot)?;
1184                if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
1185                    Some(worn.clone())
1186                } else {
1187                    self.find_stack_by_instance(&worn.contents, parent_instance_id?)
1188                }
1189            }
1190            flatland_protocol::InventoryLocation::Placed { container_id } => {
1191                let chest = self
1192                    .placed_containers
1193                    .iter()
1194                    .find(|c| c.id == *container_id)?;
1195                if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
1196                    Some(flatland_protocol::ItemStack {
1197                        template_id: chest.template_id.clone(),
1198                        quantity: 1,
1199                        item_instance_id: chest.item_instance_id,
1200                        props: Default::default(),
1201                        contents: chest.contents.clone(),
1202                        display_name: Some(chest.display_name.clone()),
1203                        category: Some("container".into()),
1204                        capacity_volume: self
1205                            .inventory_hints
1206                            .get(&chest.template_id)
1207                            .and_then(|h| h.capacity_volume),
1208                        worker_lodging_capacity: chest.worker_lodging_capacity,
1209                        ..Default::default()
1210                    })
1211                } else {
1212                    self.find_stack_by_instance(&chest.contents, parent_instance_id?)
1213                }
1214            }
1215            flatland_protocol::InventoryLocation::Keychain => None,
1216        }
1217    }
1218
1219    fn find_stack_by_instance(
1220        &self,
1221        stacks: &[flatland_protocol::ItemStack],
1222        instance_id: uuid::Uuid,
1223    ) -> Option<flatland_protocol::ItemStack> {
1224        for stack in stacks {
1225            if stack.item_instance_id == Some(instance_id) {
1226                return Some(stack.clone());
1227            }
1228            if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
1229                return Some(found);
1230            }
1231        }
1232        None
1233    }
1234
1235    /// Client-side estimate of how many units can move to `to` (server clamps authoritatively).
1236    pub fn max_movable_to(
1237        &self,
1238        template_id: &str,
1239        stack_qty: u32,
1240        from: &flatland_protocol::InventoryLocation,
1241        to: &flatland_protocol::InventoryLocation,
1242        parent_instance_id: Option<uuid::Uuid>,
1243    ) -> u32 {
1244        let unit_vol = self.item_base_volume(template_id);
1245        let unit_mass = self.item_base_mass(template_id);
1246        let mut limit = stack_qty;
1247
1248        if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
1249            let cap = parent
1250                .capacity_volume
1251                .or_else(|| {
1252                    self.inventory_hints
1253                        .get(&parent.template_id)
1254                        .and_then(|h| h.capacity_volume)
1255                })
1256                .unwrap_or(0.0);
1257            if cap > 0.0 && unit_vol > 0.0 {
1258                let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
1259                limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
1260            }
1261        }
1262
1263        let to_person = matches!(
1264            to,
1265            flatland_protocol::InventoryLocation::Root
1266                | flatland_protocol::InventoryLocation::Worn { .. }
1267        );
1268        let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
1269        if to_person && from_placed && unit_mass > 0.0 {
1270            let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
1271            if self.encumbrance == flatland_protocol::EncumbranceState::Over {
1272                limit = 0;
1273            } else {
1274                limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
1275            }
1276        }
1277
1278        limit.max(0).min(stack_qty)
1279    }
1280
1281    pub fn move_picker_max_at_selection(&self) -> u32 {
1282        let Some(picker) = &self.move_picker else {
1283            return 1;
1284        };
1285        let Some(opt) = picker.options.get(self.move_picker_index) else {
1286            return picker.stack_quantity;
1287        };
1288        match &opt.kind {
1289            MoveOptionKind::Cancel
1290            | MoveOptionKind::Drop
1291            | MoveOptionKind::Use
1292            | MoveOptionKind::PickupPlaced { .. } => picker.stack_quantity,
1293            MoveOptionKind::Move {
1294                location,
1295                parent_instance_id,
1296            } => self.max_movable_to(
1297                &picker.template_id,
1298                picker.stack_quantity,
1299                &picker.from,
1300                location,
1301                *parent_instance_id,
1302            ),
1303        }
1304    }
1305
1306    pub fn clamp_move_picker_quantity(&mut self) {
1307        let max = self.move_picker_max_at_selection();
1308        if let Some(picker) = &mut self.move_picker {
1309            if max == 0 {
1310                picker.quantity = 1;
1311            } else {
1312                picker.quantity = picker.quantity.clamp(1, max);
1313            }
1314        }
1315    }
1316
1317    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
1318        let max = self.move_picker_max_at_selection().max(1);
1319        if let Some(picker) = &mut self.move_picker {
1320            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
1321            picker.quantity = next as u32;
1322        }
1323    }
1324
1325    pub fn move_picker_set_quantity_max(&mut self) {
1326        let max = self.move_picker_max_at_selection();
1327        if let Some(picker) = &mut self.move_picker {
1328            picker.quantity = if max == 0 {
1329                1
1330            } else {
1331                max.min(picker.stack_quantity)
1332            };
1333        }
1334    }
1335
1336    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
1337        if let Some(picker) = &mut self.destroy_picker {
1338            let max = picker.stack_quantity.max(1);
1339            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
1340            picker.quantity = next as u32;
1341        }
1342    }
1343
1344    pub fn destroy_picker_set_quantity_max(&mut self) {
1345        if let Some(picker) = &mut self.destroy_picker {
1346            picker.quantity = picker.stack_quantity.max(1);
1347        }
1348    }
1349
1350    pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
1351        let have = self.inventory.get(template_id).copied().unwrap_or(0);
1352        (have, have >= need)
1353    }
1354
1355    pub fn currency_display(&self) -> String {
1356        crate::currency::currency_line(&self.inventory)
1357    }
1358
1359    /// True when standing in a shallow-water terrain zone from the segment snapshot.
1360    pub fn in_shallow_water(&self) -> bool {
1361        let (px, py) = self.player_position();
1362        self.terrain_at(px, py)
1363            .is_some_and(|k| k == TerrainKindView::ShallowWater)
1364    }
1365
1366    pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
1367        self.terrain_zone_at(x, y).map(|z| z.kind)
1368    }
1369
1370    /// First terrain zone containing `(x, y)` — highest `z_order` wins.
1371    pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
1372        self.terrain_zones
1373            .iter()
1374            .enumerate()
1375            .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
1376            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
1377            .map(|(_, z)| z)
1378    }
1379
1380    /// Ground elevation from terrain zones (m).
1381    pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
1382        self.terrain_zone_at(x, y)
1383            .map(|z| z.elevation)
1384            .unwrap_or(0.0)
1385    }
1386
1387    /// Walkable z levels at a map column (terrain + platforms).
1388    pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
1389        const TOL: f32 = 0.35;
1390        let mut levels = vec![self.elevation_at(x, y)];
1391        for p in &self.z_platforms {
1392            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
1393                levels.push(p.z);
1394            }
1395        }
1396        levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1397        levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
1398        levels
1399    }
1400
1401    pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
1402        const TOL: f32 = 0.35;
1403        self.walkable_levels_at(x, y)
1404            .iter()
1405            .any(|&l| (l - z).abs() <= TOL)
1406    }
1407
1408    pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
1409        let mut top = self.elevation_at(x, y);
1410        for p in &self.z_platforms {
1411            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
1412                top = top.max(p.z);
1413            }
1414        }
1415        top
1416    }
1417
1418    /// Authoritative interior context from the server (`inside_building` flag).
1419    pub fn effective_inside_building(&self) -> Option<String> {
1420        self.player_entity().and_then(|p| p.inside_building.clone())
1421    }
1422
1423    pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
1424        self.inventory_stacks = stacks.to_vec();
1425        self.inventory.clear();
1426        self.inventory_hints.clear();
1427        fn walk(
1428            stacks: &[flatland_protocol::ItemStack],
1429            inventory: &mut std::collections::HashMap<String, u32>,
1430            hints: &mut std::collections::HashMap<String, InventoryHint>,
1431        ) {
1432            for stack in stacks {
1433                *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
1434                if stack.display_name.is_some()
1435                    || stack.category.is_some()
1436                    || stack.base_mass.is_some()
1437                    || stack.base_volume.is_some()
1438                {
1439                    hints.insert(
1440                        stack.template_id.clone(),
1441                        InventoryHint {
1442                            display_name: stack
1443                                .display_name
1444                                .clone()
1445                                .unwrap_or_else(|| stack.template_id.clone()),
1446                            category: stack.category.clone().unwrap_or_default(),
1447                            base_mass: stack.base_mass,
1448                            base_volume: stack.base_volume,
1449                            capacity_volume: stack.capacity_volume,
1450                            stackable: stack.stackable.unwrap_or(true),
1451                        },
1452                    );
1453                }
1454                walk(&stack.contents, inventory, hints);
1455            }
1456        }
1457        walk(stacks, &mut self.inventory, &mut self.inventory_hints);
1458        // Include worn items (and nested contents, e.g. belt-clipped pouches) in craft counts.
1459        for item in self.worn.values() {
1460            walk(
1461                std::slice::from_ref(item),
1462                &mut self.inventory,
1463                &mut self.inventory_hints,
1464            );
1465        }
1466    }
1467
1468    /// Apply server interaction deltas immediately (quest rewards, shop, etc.) so the
1469    /// inventory UI updates before the next tick snapshot arrives.
1470    pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1471        let subtract_items =
1472            notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
1473        for stack in &notice.inventory_delta {
1474            if stack.quantity == 0 {
1475                continue;
1476            }
1477            if subtract_items {
1478                crate::currency::drain_template_stacks(
1479                    &mut self.inventory_stacks,
1480                    &stack.template_id,
1481                    stack.quantity,
1482                );
1483                continue;
1484            }
1485            let stackable = self
1486                .inventory_hints
1487                .get(&stack.template_id)
1488                .map(|h| h.stackable)
1489                .or(stack.stackable)
1490                .unwrap_or(true);
1491            if stackable {
1492                if let Some(existing) = self
1493                    .inventory_stacks
1494                    .iter_mut()
1495                    .find(|s| s.template_id == stack.template_id)
1496                {
1497                    existing.quantity = existing.quantity.saturating_add(stack.quantity);
1498                    if stack.display_name.is_some() {
1499                        existing.display_name = stack.display_name.clone();
1500                    }
1501                    if stack.category.is_some() {
1502                        existing.category = stack.category.clone();
1503                    }
1504                    continue;
1505                }
1506            }
1507            self.inventory_stacks.push(stack.clone());
1508        }
1509        if notice.coins_delta != 0 {
1510            crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
1511        }
1512        if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
1513            let stacks = self.inventory_stacks.clone();
1514            self.sync_inventory_from_stacks(&stacks);
1515        }
1516        self.record_shop_trade_notice(notice);
1517    }
1518
1519    /// Worn body-slot items — each shown as a shell row (unequip via Enter) followed by
1520    /// its nested contents (e.g. pouches clipped onto a worn belt). `BodySlot` derives
1521    /// `Ord` in display order (Head/Body/Arms/Legs/Feet/Back/Waist), so `BTreeMap`
1522    /// iteration alone gives a stable row order.
1523    pub fn worn_rows(&self) -> Vec<InventoryRow> {
1524        let mut rows = Vec::new();
1525        for (slot, item) in &self.worn {
1526            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
1527            rows.push(InventoryRow {
1528                depth: 0,
1529                stack: item.clone(),
1530                from: from.clone(),
1531                from_parent_instance_id: None,
1532                is_equip_shell: true,
1533                is_chest_shell: false,
1534                section: InventorySection::Worn,
1535            });
1536            for child in &item.contents {
1537                push_inventory_rows(
1538                    &mut rows,
1539                    1,
1540                    child,
1541                    &from,
1542                    item.item_instance_id,
1543                    InventorySection::Worn,
1544                );
1545            }
1546        }
1547        rows
1548    }
1549
1550    /// Root on-person stacks that can be handed to a hired worker.
1551    pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
1552        self.inventory_stacks
1553            .iter()
1554            .filter_map(|stack| {
1555                let item_instance_id = stack.item_instance_id?;
1556                let label = stack
1557                    .display_name
1558                    .clone()
1559                    .unwrap_or_else(|| stack.template_id.clone());
1560                let label = if stack.quantity > 1 {
1561                    format!("{label} ×{}", stack.quantity)
1562                } else {
1563                    label
1564                };
1565                Some(WorkerGiveOption {
1566                    item_instance_id,
1567                    label,
1568                    quantity: stack.quantity,
1569                    template_id: stack.template_id.clone(),
1570                })
1571            })
1572            .collect()
1573    }
1574
1575    /// Employer-known blueprints the selected worker does not yet know.
1576    pub fn teachable_blueprint_options(
1577        &self,
1578        worker: &flatland_protocol::HiredWorkerView,
1579    ) -> Vec<WorkerTeachOption> {
1580        let copper = crate::currency::copper_from_counts(&self.inventory);
1581        let mut options: Vec<WorkerTeachOption> = self
1582            .blueprints
1583            .iter()
1584            .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
1585            .map(|bp| {
1586                let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
1587                let cost = bp.worker_train_copper;
1588                WorkerTeachOption {
1589                    blueprint_id: bp.id.clone(),
1590                    label: if bp.label.is_empty() {
1591                        bp.id.clone()
1592                    } else {
1593                        bp.label.clone()
1594                    },
1595                    cost_copper: cost,
1596                    min_level,
1597                    worker_level: worker.level,
1598                    can_afford: copper >= cost,
1599                    level_ok: worker.level >= min_level,
1600                }
1601            })
1602            .collect();
1603        options.sort_by(|a, b| a.label.cmp(&b.label));
1604        options
1605    }
1606
1607    /// Loose on-person inventory (not worn, not inside a placed chest).
1608    pub fn person_rows(&self) -> Vec<InventoryRow> {
1609        let mut rows = Vec::new();
1610        for stack in &self.inventory_stacks {
1611            push_inventory_rows(
1612                &mut rows,
1613                0,
1614                stack,
1615                &flatland_protocol::InventoryLocation::Root,
1616                None,
1617                InventorySection::Person,
1618            );
1619        }
1620        rows
1621    }
1622
1623    /// Legacy alias used by the HUD sidebar summary (worn + on-person, unchanged).
1624    pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
1625        let mut rows = self.worn_rows();
1626        rows.extend(self.person_rows());
1627        rows.into_iter().map(|r| (r.depth, r.stack)).collect()
1628    }
1629
1630    /// Placed chests within `CONTAINER_RANGE_M`, nearest first. Contents are only
1631    /// populated when `accessible` — this is what makes a chest's contents
1632    /// disappear the moment you walk away or it's locked without your key.
1633    pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
1634        let (px, py) = self.player_position();
1635        let mut list: Vec<NearbyContainer> = self
1636            .placed_containers
1637            .iter()
1638            .filter_map(|c| {
1639                let distance_m = (c.x - px).hypot(c.y - py);
1640                if distance_m > CONTAINER_RANGE_M {
1641                    return None;
1642                }
1643                let mut rows = Vec::new();
1644                let from = flatland_protocol::InventoryLocation::Placed {
1645                    container_id: c.id.clone(),
1646                };
1647                rows.push(InventoryRow {
1648                    depth: 0,
1649                    stack: flatland_protocol::ItemStack {
1650                        template_id: c.template_id.clone(),
1651                        quantity: 1,
1652                        item_instance_id: c.item_instance_id,
1653                        props: Default::default(),
1654                        contents: Vec::new(),
1655                        display_name: Some(c.display_name.clone()),
1656                        category: Some("container".into()),
1657                        capacity_volume: c.capacity_volume,
1658                        worker_lodging_capacity: c.worker_lodging_capacity,
1659                        ..Default::default()
1660                    },
1661                    from: from.clone(),
1662                    from_parent_instance_id: None,
1663                    is_equip_shell: false,
1664                    is_chest_shell: true,
1665                    section: InventorySection::Nearby,
1666                });
1667                if c.accessible {
1668                    for child in &c.contents {
1669                        push_inventory_rows(
1670                            &mut rows,
1671                            1,
1672                            child,
1673                            &from,
1674                            c.item_instance_id,
1675                            InventorySection::Nearby,
1676                        );
1677                    }
1678                }
1679                Some(NearbyContainer {
1680                    view: c.clone(),
1681                    distance_m,
1682                    rows,
1683                })
1684            })
1685            .collect();
1686        list.sort_by(|a, b| {
1687            a.distance_m
1688                .partial_cmp(&b.distance_m)
1689                .unwrap_or(std::cmp::Ordering::Equal)
1690        });
1691        list
1692    }
1693
1694    /// Nearest placed chest within `max_dist`, regardless of accessibility.
1695    pub fn nearest_placed_container(
1696        &self,
1697        max_dist: f32,
1698    ) -> Option<flatland_protocol::PlacedContainerView> {
1699        let (px, py) = self.player_position();
1700        self.placed_containers
1701            .iter()
1702            .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
1703            .min_by(|a, b| {
1704                let da = (a.x - px).hypot(a.y - py);
1705                let db = (b.x - px).hypot(b.y - py);
1706                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
1707            })
1708            .cloned()
1709    }
1710
1711    /// Full ordered list of *selectable* rows: worn ++ on-person ++ each nearby
1712    /// accessible chest's contents (nearest chest first). This single order drives
1713    /// `inventory_menu_index`; the renderer must build its grouped headers by
1714    /// walking `worn_rows()` / `person_rows()` / `nearby_containers()` in the same
1715    /// sequence so the highlighted row always matches.
1716    pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
1717        let mut rows = self.worn_rows();
1718        rows.extend(self.person_rows());
1719        for nc in self.nearby_containers() {
1720            rows.extend(nc.rows);
1721        }
1722        rows
1723    }
1724
1725    pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
1726        self.inventory_selectable_rows()
1727            .into_iter()
1728            .nth(self.inventory_menu_index)
1729    }
1730
1731    /// Format one selectable inventory row for TUI/gfx (label + hints + mass/volume).
1732    pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
1733        let cat = self
1734            .inventory_item_category(&row.stack.template_id)
1735            .unwrap_or("");
1736        let label = if cat == "key" {
1737            self.key_inventory_label(&row.stack)
1738        } else {
1739            row.stack
1740                .display_name
1741                .clone()
1742                .unwrap_or_else(|| row.stack.template_id.clone())
1743        };
1744        let hint: String = if row.is_equip_shell {
1745            " [worn — Enter to unequip]".into()
1746        } else if row.is_chest_shell {
1747            let (locked, lodging_note) = match &row.from {
1748                flatland_protocol::InventoryLocation::Placed { container_id } => {
1749                    let locked = self
1750                        .placed_containers
1751                        .iter()
1752                        .find(|c| c.id == *container_id)
1753                        .map(|c| c.locked)
1754                        .unwrap_or(false);
1755                    let lodging_note = self
1756                        .lodging_occupancy_label(container_id)
1757                        .map(|who| format!(" [lodging: {who}]"))
1758                        .unwrap_or_default();
1759                    (locked, lodging_note)
1760                }
1761                _ => (false, String::new()),
1762            };
1763            if locked {
1764                format!(" [locked — Enter pick up · l unlock]{lodging_note}")
1765            } else {
1766                format!(" [Enter pick up · l lock]{lodging_note}")
1767            }
1768        } else if cat == "key" {
1769            self.key_inventory_hint(&row.stack)
1770        } else {
1771            match cat {
1772                "weapon" => " [weapon]".into(),
1773                "container" => " [bag/chest/belt]".into(),
1774                "lodging" => " [worker lodging]".into(),
1775                "armor" => " [armor]".into(),
1776                _ => String::new(),
1777            }
1778        };
1779        let qty = if row.stack.quantity > 1 {
1780            format!(" ×{}", row.stack.quantity)
1781        } else {
1782            String::new()
1783        };
1784        let mass = self.stack_mass(&row.stack);
1785        let mass_kg = (mass >= 0.05).then_some(mass);
1786        let mass_str = mass_kg
1787            .map(|m| format!("  {m:.1} kg"))
1788            .unwrap_or_default();
1789        let volume = self.container_volume_stats(row);
1790        let vol_str = self.container_volume_label(row);
1791
1792        let mut title = label.clone();
1793        if let Some(id) = row.stack.item_instance_id {
1794            let hex: String = id
1795                .as_simple()
1796                .to_string()
1797                .chars()
1798                .filter(|c| c.is_ascii_hexdigit())
1799                .collect();
1800            if hex.len() >= 4 {
1801                title.push_str(&format!(" #{}", &hex[hex.len() - 4..]));
1802            }
1803        }
1804        title.push_str(&qty);
1805        if row.is_equip_shell {
1806            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
1807                title.push_str(&format!(" ({})", body_slot_label(slot)));
1808            }
1809        }
1810
1811        InventoryRowView {
1812            depth: row.depth,
1813            text: format!("{label}{hint}{qty}{mass_str}{vol_str}"),
1814            title,
1815            mass_kg,
1816            volume,
1817        }
1818    }
1819
1820    /// Sectioned inventory browser lines for gfx/TUI. Selectable rows carry
1821    /// `selectable_index` matching `inventory_menu_index`.
1822    pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
1823        let mut lines = Vec::new();
1824        let target = self.inventory_menu_index;
1825        let highlight = !self.show_move_picker;
1826        let mut global_idx = 0usize;
1827
1828        lines.push(InventoryBrowserLine::Section("— Worn —".into()));
1829        let worn = self.worn_rows();
1830        if worn.is_empty() {
1831            lines.push(InventoryBrowserLine::Hint(
1832                "  (nothing equipped — wear a backpack/belt from \"On you\" below)".into(),
1833            ));
1834        } else {
1835            for row in &worn {
1836                if row.is_equip_shell {
1837                    if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
1838                        lines.push(InventoryBrowserLine::SlotLabel(format!(
1839                            "  {}:",
1840                            body_slot_label(slot)
1841                        )));
1842                    }
1843                }
1844                let view = self.format_inventory_row(row);
1845                lines.push(InventoryBrowserLine::Item {
1846                    selectable_index: global_idx,
1847                    selected: highlight && global_idx == target,
1848                    depth: view.depth,
1849                    text: view.text,
1850                    title: view.title,
1851                    mass_kg: view.mass_kg,
1852                    volume: view.volume,
1853                });
1854                global_idx += 1;
1855            }
1856        }
1857
1858        lines.push(InventoryBrowserLine::Blank);
1859        lines.push(InventoryBrowserLine::Section(
1860            "— On you (loose, not worn) —".into(),
1861        ));
1862        let person = self.person_rows();
1863        if person.is_empty() {
1864            lines.push(InventoryBrowserLine::Hint("  (empty)".into()));
1865        } else {
1866            for row in &person {
1867                let view = self.format_inventory_row(row);
1868                lines.push(InventoryBrowserLine::Item {
1869                    selectable_index: global_idx,
1870                    selected: highlight && global_idx == target,
1871                    depth: view.depth,
1872                    text: view.text,
1873                    title: view.title,
1874                    mass_kg: view.mass_kg,
1875                    volume: view.volume,
1876                });
1877                global_idx += 1;
1878            }
1879        }
1880
1881        let nearby = self.nearby_containers();
1882        if nearby.is_empty() {
1883            lines.push(InventoryBrowserLine::Blank);
1884            lines.push(InventoryBrowserLine::Section("— Nearby chests —".into()));
1885            lines.push(InventoryBrowserLine::Hint(
1886                "  (none within reach — walk up to a chest)".into(),
1887            ));
1888        } else {
1889            for nc in &nearby {
1890                lines.push(InventoryBrowserLine::Blank);
1891                let lock_note = if nc.view.locked && nc.view.accessible {
1892                    "  unlocked with your key"
1893                } else if nc.view.locked {
1894                    "  locked"
1895                } else {
1896                    ""
1897                };
1898                lines.push(InventoryBrowserLine::Section(format!(
1899                    "— {} ({:.0}m away){lock_note} —",
1900                    nc.view.display_name, nc.distance_m
1901                )));
1902                if !nc.view.accessible {
1903                    lines.push(InventoryBrowserLine::Hint(
1904                        "  locked — need the matching key (l to try)".into(),
1905                    ));
1906                } else if nc.rows.is_empty() {
1907                    lines.push(InventoryBrowserLine::Hint(
1908                        "  (empty — select chest row above, m to move items in)".into(),
1909                    ));
1910                } else {
1911                    for row in &nc.rows {
1912                        let view = self.format_inventory_row(row);
1913                        lines.push(InventoryBrowserLine::Item {
1914                            selectable_index: global_idx,
1915                            selected: highlight && global_idx == target,
1916                            depth: view.depth,
1917                            text: view.text,
1918                            title: view.title,
1919                            mass_kg: view.mass_kg,
1920                            volume: view.volume,
1921                        });
1922                        global_idx += 1;
1923                    }
1924                }
1925            }
1926        }
1927        lines
1928    }
1929
1930    /// Destinations for picking up a placed chest/crate into inventory.
1931    pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
1932        let mut opts = Vec::new();
1933        opts.push(MoveOption {
1934            label: "On your person (loose)".into(),
1935            kind: MoveOptionKind::PickupPlaced {
1936                container_id: container_id.to_string(),
1937                nest_location: flatland_protocol::InventoryLocation::Root,
1938                nest_parent_instance_id: None,
1939            },
1940        });
1941        for (slot, item) in &self.worn {
1942            if item.category.as_deref() != Some("container") {
1943                continue;
1944            }
1945            if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
1946                continue;
1947            }
1948            let Some(parent_id) = item.item_instance_id else {
1949                continue;
1950            };
1951            let shell_name = item
1952                .display_name
1953                .clone()
1954                .unwrap_or_else(|| item.template_id.clone());
1955            opts.push(MoveOption {
1956                label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
1957                kind: MoveOptionKind::PickupPlaced {
1958                    container_id: container_id.to_string(),
1959                    nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
1960                    nest_parent_instance_id: Some(parent_id),
1961                },
1962            });
1963            // Nested pouches inside the worn bag.
1964            Self::append_chest_pickup_nested(
1965                &mut opts,
1966                container_id,
1967                flatland_protocol::InventoryLocation::Worn { slot: *slot },
1968                item,
1969                &format!("in {shell_name}"),
1970            );
1971        }
1972        opts.push(MoveOption {
1973            label: "Cancel".into(),
1974            kind: MoveOptionKind::Cancel,
1975        });
1976        opts
1977    }
1978
1979    fn append_chest_pickup_nested(
1980        opts: &mut Vec<MoveOption>,
1981        container_id: &str,
1982        location: flatland_protocol::InventoryLocation,
1983        parent: &flatland_protocol::ItemStack,
1984        context: &str,
1985    ) {
1986        for child in &parent.contents {
1987            if child.category.as_deref() != Some("container") {
1988                continue;
1989            }
1990            if !Self::is_volume_container_stack(child) {
1991                continue;
1992            }
1993            // Skip placeable nested chests — relocating into another chest is not useful.
1994            if child.world_placeable == Some(true) {
1995                continue;
1996            }
1997            let Some(child_id) = child.item_instance_id else {
1998                continue;
1999            };
2000            let name = child
2001                .display_name
2002                .clone()
2003                .unwrap_or_else(|| child.template_id.clone());
2004            opts.push(MoveOption {
2005                label: format!("{name} ({context})"),
2006                kind: MoveOptionKind::PickupPlaced {
2007                    container_id: container_id.to_string(),
2008                    nest_location: location.clone(),
2009                    nest_parent_instance_id: Some(child_id),
2010                },
2011            });
2012            Self::append_chest_pickup_nested(
2013                opts,
2014                container_id,
2015                location.clone(),
2016                child,
2017                &format!("in {name}"),
2018            );
2019        }
2020    }
2021
2022    /// Build the "move to…" destination list for an item currently at `from`.
2023    pub fn move_destinations_for(
2024        &self,
2025        from: &flatland_protocol::InventoryLocation,
2026        from_parent_instance_id: Option<uuid::Uuid>,
2027        moving_instance_id: Option<uuid::Uuid>,
2028        moving_template_id: &str,
2029    ) -> Vec<MoveOption> {
2030        let mut opts = Vec::new();
2031        if *from != flatland_protocol::InventoryLocation::Root {
2032            opts.push(MoveOption {
2033                label: "On your person (loose)".into(),
2034                kind: MoveOptionKind::Move {
2035                    location: flatland_protocol::InventoryLocation::Root,
2036                    parent_instance_id: None,
2037                },
2038            });
2039        }
2040        for (slot, item) in &self.worn {
2041            if item.category.as_deref() != Some("container") {
2042                continue;
2043            }
2044            let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
2045            let shell_name = item
2046                .display_name
2047                .clone()
2048                .unwrap_or_else(|| item.template_id.clone());
2049
2050            // Backpack and other worn volume containers — store directly inside the shell.
2051            if *slot != BodySlot::Waist
2052                && item.item_instance_id != moving_instance_id
2053                && Self::is_volume_container_stack(item)
2054            {
2055                Self::push_move_destination(
2056                    &mut opts,
2057                    format!("{shell_name} (worn {})", body_slot_label(*slot)),
2058                    location.clone(),
2059                    item.item_instance_id,
2060                    from,
2061                    from_parent_instance_id,
2062                );
2063            }
2064
2065            // Belt loops only accept pouch attachments — not loose materials.
2066            if *slot == BodySlot::Waist
2067                && Self::attaches_to_belt_loop(moving_template_id)
2068                && item.item_instance_id != moving_instance_id
2069            {
2070                Self::push_move_destination(
2071                    &mut opts,
2072                    format!("{shell_name} (belt loop)"),
2073                    location.clone(),
2074                    item.item_instance_id,
2075                    from,
2076                    from_parent_instance_id,
2077                );
2078            }
2079
2080            let context = if *slot == BodySlot::Waist {
2081                format!("on {shell_name}")
2082            } else {
2083                format!("in {shell_name}")
2084            };
2085            Self::append_nested_container_destinations(
2086                &mut opts,
2087                location,
2088                item,
2089                &context,
2090                from,
2091                from_parent_instance_id,
2092                moving_instance_id,
2093            );
2094        }
2095        for nc in self.nearby_containers() {
2096            if !nc.view.accessible {
2097                continue;
2098            }
2099            let location = flatland_protocol::InventoryLocation::Placed {
2100                container_id: nc.view.id.clone(),
2101            };
2102            Self::push_move_destination(
2103                &mut opts,
2104                format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
2105                location,
2106                nc.view.item_instance_id,
2107                from,
2108                from_parent_instance_id,
2109            );
2110        }
2111        let allow_drop = moving_instance_id
2112            .and_then(|id| self.stack_for_instance(id))
2113            .map(|stack| !self.key_drop_blocked(&stack))
2114            .unwrap_or(moving_template_id != KEY_TEMPLATE);
2115        if allow_drop {
2116            opts.push(MoveOption {
2117                label: "Drop on the ground".into(),
2118                kind: MoveOptionKind::Drop,
2119            });
2120        }
2121        opts.push(MoveOption {
2122            label: "Cancel".into(),
2123            kind: MoveOptionKind::Cancel,
2124        });
2125        opts
2126    }
2127
2128    fn is_same_container_dest(
2129        dest_location: &flatland_protocol::InventoryLocation,
2130        dest_parent: Option<uuid::Uuid>,
2131        from: &flatland_protocol::InventoryLocation,
2132        from_parent: Option<uuid::Uuid>,
2133    ) -> bool {
2134        dest_location == from && dest_parent == from_parent
2135    }
2136
2137    fn push_move_destination(
2138        opts: &mut Vec<MoveOption>,
2139        label: String,
2140        location: flatland_protocol::InventoryLocation,
2141        parent_instance_id: Option<uuid::Uuid>,
2142        from: &flatland_protocol::InventoryLocation,
2143        from_parent_instance_id: Option<uuid::Uuid>,
2144    ) {
2145        if Self::is_same_container_dest(
2146            &location,
2147            parent_instance_id,
2148            from,
2149            from_parent_instance_id,
2150        ) {
2151            return;
2152        }
2153        opts.push(MoveOption {
2154            label,
2155            kind: MoveOptionKind::Move {
2156                location,
2157                parent_instance_id,
2158            },
2159        });
2160    }
2161
2162    fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
2163        stack.capacity_volume.is_some_and(|c| c > 0.0)
2164    }
2165
2166    fn attaches_to_belt_loop(template_id: &str) -> bool {
2167        matches!(template_id, "leather_pouch" | "dimensional_pouch")
2168    }
2169
2170    fn append_nested_container_destinations(
2171        opts: &mut Vec<MoveOption>,
2172        location: flatland_protocol::InventoryLocation,
2173        container: &flatland_protocol::ItemStack,
2174        context: &str,
2175        from: &flatland_protocol::InventoryLocation,
2176        from_parent_instance_id: Option<uuid::Uuid>,
2177        moving_instance_id: Option<uuid::Uuid>,
2178    ) {
2179        for child in &container.contents {
2180            if Self::is_volume_container_stack(child)
2181                && child.item_instance_id != moving_instance_id
2182            {
2183                let name = child
2184                    .display_name
2185                    .clone()
2186                    .unwrap_or_else(|| child.template_id.clone());
2187                Self::push_move_destination(
2188                    opts,
2189                    format!("{name} ({context})"),
2190                    location.clone(),
2191                    child.item_instance_id,
2192                    from,
2193                    from_parent_instance_id,
2194                );
2195            }
2196            let nested_context = format!(
2197                "in {}",
2198                child.display_name.as_deref().unwrap_or(&child.template_id)
2199            );
2200            Self::append_nested_container_destinations(
2201                opts,
2202                location.clone(),
2203                child,
2204                &nested_context,
2205                from,
2206                from_parent_instance_id,
2207                moving_instance_id,
2208            );
2209        }
2210    }
2211
2212    fn clamp_inventory_indices(&mut self) {
2213        let n = self.inventory_selectable_rows().len();
2214        self.inventory_menu_index = if n == 0 {
2215            0
2216        } else {
2217            self.inventory_menu_index.min(n - 1)
2218        };
2219        if let Some(picker) = &self.move_picker {
2220            let pn = picker.options.len();
2221            self.move_picker_index = if pn == 0 {
2222                0
2223            } else {
2224                self.move_picker_index.min(pn - 1)
2225            };
2226        }
2227    }
2228
2229    /// Drop interior map layers whenever the player is outdoors.
2230    fn sync_interior_map_context(&mut self) {
2231        if self.effective_inside_building().is_none() {
2232            self.interior_map = None;
2233        }
2234    }
2235
2236    fn apply_snapshot_fields(
2237        &mut self,
2238        snapshot: &flatland_protocol::Snapshot,
2239        entity_id: EntityId,
2240    ) {
2241        self.tick = snapshot.tick;
2242        self.chunk_rev = snapshot.chunk_rev;
2243        self.content_rev = snapshot.content_rev;
2244        self.publish_rev = snapshot.publish_rev;
2245        self.resource_nodes = snapshot.resource_nodes.clone();
2246        self.ground_drops = snapshot.ground_drops.clone();
2247        self.placed_containers = snapshot.placed_containers.clone();
2248        self.world_width_m = snapshot.world_width_m;
2249        self.world_height_m = snapshot.world_height_m;
2250        self.world_clock = snapshot.world_clock;
2251        self.terrain_zones = snapshot.terrain_zones.clone();
2252        self.z_platforms = snapshot.z_platforms.clone();
2253        self.z_transitions = snapshot.z_transitions.clone();
2254        self.buildings = snapshot.buildings.clone();
2255        self.doors = snapshot.doors.clone();
2256        self.interior_map = snapshot.interior_map.clone();
2257        self.npcs = snapshot.npcs.clone();
2258        self.blueprints = snapshot.blueprints.clone();
2259        self.sync_inventory_from_stacks(&snapshot.inventory);
2260        self.player = snapshot
2261            .entities
2262            .iter()
2263            .find(|e| e.id == entity_id)
2264            .cloned();
2265        self.entities = snapshot.entities.clone();
2266        self.quest_log = snapshot.quest_log.clone();
2267        self.apply_hired_workers(snapshot.hired_workers.clone());
2268        self.interactables = snapshot.interactables.clone();
2269        self.ledger = snapshot.ledger.clone();
2270        self.career = snapshot.career.clone();
2271        self.sync_interior_map_context();
2272    }
2273
2274    /// Re-derive inventory selection state after a snapshot/tick — chests that
2275    /// went out of range or got locked simply vanish from the row list, and the
2276    /// move picker (if any) closes once its item is no longer reachable.
2277    fn refresh_inventory_ui(&mut self) {
2278        if let Some(picker) = &self.move_picker {
2279            let instance_id = picker.item_instance_id;
2280            let still_exists = self
2281                .inventory_selectable_rows()
2282                .iter()
2283                .any(|r| r.stack.item_instance_id == Some(instance_id));
2284            if !still_exists {
2285                self.move_picker = None;
2286                self.show_move_picker = false;
2287            }
2288        }
2289        if let Some(picker) = &self.destroy_picker {
2290            let instance_id = picker.item_instance_id;
2291            let still_exists = self
2292                .inventory_selectable_rows()
2293                .iter()
2294                .any(|r| r.stack.item_instance_id == Some(instance_id));
2295            if !still_exists {
2296                self.destroy_picker = None;
2297                self.show_destroy_picker = false;
2298                self.destroy_confirm_pending = false;
2299            }
2300        }
2301        self.clamp_inventory_indices();
2302    }
2303
2304    /// Replace the hired-worker list from a snapshot/delta.
2305    ///
2306    /// Keeps a stable sort and remaps `workers_menu_index` by instance id so the
2307    /// selected worker (and its step line) does not jump when the server rebuilds
2308    /// the list.
2309    fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
2310        let selected_id = self
2311            .hired_workers
2312            .get(self.workers_menu_index)
2313            .map(|w| w.instance_id.clone());
2314        workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
2315        let now = Instant::now();
2316        let mut next_display = BTreeMap::new();
2317        for w in &workers {
2318            let mut sticky = self
2319                .worker_step_display
2320                .remove(&w.instance_id)
2321                .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
2322            sticky.observe(&w.step_label, now);
2323            next_display.insert(w.instance_id.clone(), sticky);
2324        }
2325        self.worker_step_display = next_display;
2326        self.hired_workers = workers;
2327        if let Some(id) = selected_id {
2328            if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
2329                self.workers_menu_index = idx;
2330                return;
2331            }
2332        }
2333        if self.workers_menu_index >= self.hired_workers.len() {
2334            self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
2335        }
2336    }
2337
2338    /// Held coarse step label for the workers menu (`step:` line).
2339    pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
2340        self.worker_step_display
2341            .get(worker_instance_id)
2342            .map(|s| s.shown.as_str())
2343            .or_else(|| {
2344                self.hired_workers
2345                    .iter()
2346                    .find(|w| w.instance_id == worker_instance_id)
2347                    .map(|w| w.step_label.as_str())
2348            })
2349            .unwrap_or("")
2350    }
2351
2352    fn apply_combat_hud(&mut self, combat: &CombatHud) {
2353        self.in_combat = combat.in_combat;
2354        self.auto_attack = combat.auto_attack;
2355        self.combat_has_los = combat.has_los;
2356        self.attack_cd_ticks = combat.attack_cd_ticks;
2357        self.gcd_ticks = combat.gcd_ticks;
2358        self.weapon_ability_id = combat.ability_id.clone();
2359        self.mainhand_template_id = combat.mainhand_template_id.clone();
2360        self.mainhand_label = combat.mainhand_label.clone();
2361        self.offhand_template_id = combat.offhand_template_id.clone();
2362        self.offhand_label = combat.offhand_label.clone();
2363        self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
2364            1
2365        } else {
2366            combat.mainhand_hand_slots
2367        };
2368        self.defense = combat.defense.clone();
2369        self.worn = combat.worn.iter().cloned().collect();
2370        self.carry_mass = combat.carry_mass;
2371        self.carry_mass_max = combat.carry_mass_max;
2372        self.encumbrance = combat.encumbrance;
2373        self.cast_progress = combat.cast.clone();
2374        self.ability_cooldowns = combat.ability_cooldowns.clone();
2375        self.blocking_active = combat.blocking_active;
2376        self.max_target_slots = combat.max_target_slots.max(1);
2377        self.combat_slots = combat.slots.clone();
2378        self.rotation_presets = combat.rotation_presets.clone();
2379        self.keychain_stacks = combat.keychain.clone();
2380        self.combat_target_detail = combat.target.clone();
2381        self.combat_target = combat.target_entity_id;
2382        if combat.progression_xp_base > 0.0 {
2383            self.progression_curve = Some(flatland_protocol::ProgressionCurve {
2384                baseline_display: combat.progression_baseline,
2385                xp_base: combat.progression_xp_base,
2386                xp_growth: combat.progression_xp_growth,
2387            });
2388        }
2389        if let Some(xp) = &combat.progression_xp {
2390            if let Some(player) = &mut self.player {
2391                player.progression_xp = Some(xp.clone());
2392                if let Some(attrs) = combat.attributes {
2393                    player.attributes = Some(attrs);
2394                }
2395                if let Some(skills) = &combat.skills {
2396                    player.skills = Some(skills.clone());
2397                }
2398            }
2399        }
2400        if let Some(label) = &combat.target_label {
2401            self.combat_target_label = Some(label.clone());
2402        } else if let Some(id) = combat.target_entity_id {
2403            self.combat_target_label = self
2404                .entities
2405                .iter()
2406                .find(|e| e.id == id)
2407                .map(|e| e.label.clone())
2408                .or_else(|| self.combat_target_label.clone());
2409        }
2410        self.refresh_inventory_ui();
2411    }
2412
2413    /// Entity id assigned to a combat slot (from server HUD).
2414    pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
2415        self.combat_slots
2416            .iter()
2417            .find(|s| s.slot_index == slot)
2418            .and_then(|s| s.target_entity_id)
2419            .or_else(|| if slot == 1 { self.combat_target } else { None })
2420    }
2421
2422    /// Hostile wildlife / monsters for T1 (`Tab`).
2423    pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
2424        self.combat_candidates()
2425    }
2426
2427    /// Allies first, then monsters, for T2 (`Shift+Tab`). Includes self for heals.
2428    pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
2429        let (px, py) = self.player_position();
2430        let dist = |id: EntityId| {
2431            self.entities
2432                .iter()
2433                .find(|e| e.id == id)
2434                .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
2435                .unwrap_or(f32::MAX)
2436        };
2437
2438        let mut allies = Vec::new();
2439        // Self first — heal_touch on T2.
2440        if let Some(me) = self.player.as_ref() {
2441            let alive = me
2442                .vitals
2443                .as_ref()
2444                .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
2445                .unwrap_or(true);
2446            if alive {
2447                allies.push((self.entity_id, "Yourself".into()));
2448            }
2449        }
2450        for entity in &self.entities {
2451            if entity.id == self.entity_id {
2452                continue;
2453            }
2454            if entity.vitals.is_some() {
2455                let alive = entity
2456                    .vitals
2457                    .as_ref()
2458                    .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
2459                    .unwrap_or(true);
2460                if alive {
2461                    allies.push((entity.id, entity.label.clone()));
2462                }
2463            }
2464        }
2465        allies.sort_by(|(a, _), (b, _)| {
2466            if *a == self.entity_id {
2467                return std::cmp::Ordering::Less;
2468            }
2469            if *b == self.entity_id {
2470                return std::cmp::Ordering::Greater;
2471            }
2472            dist(*a)
2473                .partial_cmp(&dist(*b))
2474                .unwrap_or(std::cmp::Ordering::Equal)
2475        });
2476
2477        let mut monsters = self.combat_candidates();
2478        monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
2479        allies.into_iter().chain(monsters).collect()
2480    }
2481
2482    fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
2483        match slot_index {
2484            2 => self.t2_candidates(),
2485            _ => self.t1_candidates(),
2486        }
2487    }
2488
2489    /// Nearest combat candidate within `radius_m` of world click (gfx click-to-target).
2490    pub fn pick_combat_target_at(
2491        &self,
2492        wx: f32,
2493        wy: f32,
2494        slot_index: u8,
2495        radius_m: f32,
2496    ) -> Option<(EntityId, String)> {
2497        let mut best: Option<(f32, EntityId, String)> = None;
2498        for (id, label) in self.candidates_for_slot(slot_index) {
2499            let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
2500                // NPCs may only appear in NpcView — fall back to npc list coords.
2501                if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
2502                    let d = distance(wx, wy, npc.x, npc.y);
2503                    if d <= radius_m {
2504                        best = match best {
2505                            Some((bd, _, _)) if bd <= d => best,
2506                            _ => Some((d, id, label)),
2507                        };
2508                    }
2509                }
2510                continue;
2511            };
2512            let d = distance(
2513                wx,
2514                wy,
2515                entity.transform.position.x,
2516                entity.transform.position.y,
2517            );
2518            if d <= radius_m {
2519                best = match best {
2520                    Some((bd, _, _)) if bd <= d => best,
2521                    _ => Some((d, id, label)),
2522                };
2523            }
2524        }
2525        best.map(|(_, id, label)| (id, label))
2526    }
2527
2528    /// Full client reset after Welcome (initial connect or reconnect).
2529    pub(crate) fn restore_from_welcome(
2530        &mut self,
2531        session_id: SessionId,
2532        entity_id: EntityId,
2533        snapshot: &flatland_protocol::Snapshot,
2534    ) {
2535        self.clear_harvest_state();
2536        self.disconnect_reason = None;
2537        self.show_stats = false;
2538        self.show_craft_menu = false;
2539        self.show_shop_menu = false;
2540        self.shop_catalog = None;
2541        self.show_inventory_menu = false;
2542        self.session_id = session_id;
2543        self.entity_id = entity_id;
2544        self.connected = true;
2545        self.apply_snapshot_fields(snapshot, entity_id);
2546        if let Some(combat) = &snapshot.combat {
2547            self.apply_combat_hud(combat);
2548            let stacks = self.inventory_stacks.clone();
2549            self.sync_inventory_from_stacks(&stacks);
2550        }
2551    }
2552
2553    fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
2554        self.tick = delta.tick;
2555        self.world_clock = delta.world_clock;
2556
2557        // Degenerate AOI tick (observer missing server-side): keep welcome snapshot layers.
2558        if delta.entities.is_empty() {
2559            if let Some(combat) = &delta.combat {
2560                self.apply_combat_hud(combat);
2561                let stacks = self.inventory_stacks.clone();
2562                self.sync_inventory_from_stacks(&stacks);
2563            }
2564            return;
2565        }
2566        if !delta.buildings.is_empty() {
2567            self.buildings = delta.buildings.clone();
2568        }
2569        if !delta.blueprints.is_empty() {
2570            self.blueprints = delta.blueprints.clone();
2571        }
2572        self.sync_inventory_from_stacks(&delta.inventory);
2573
2574        if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
2575            self.player = Some(updated.clone());
2576        }
2577        self.entities = delta.entities.clone();
2578        if self.player.is_none() {
2579            self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
2580        }
2581
2582        self.sync_interior_map_context();
2583
2584        // Static world layers: ticks often omit these (indoors, or unchanged).
2585        // Never wipe the welcome snapshot with an empty vec.
2586        if !delta.resource_nodes.is_empty() {
2587            self.resource_nodes = delta.resource_nodes.clone();
2588        }
2589        if self
2590            .player
2591            .as_ref()
2592            .is_none_or(|p| p.inside_building.is_none())
2593        {
2594            self.ground_drops = delta.ground_drops.clone();
2595            self.placed_containers = delta.placed_containers.clone();
2596        }
2597        if !delta.doors.is_empty() {
2598            self.doors = delta.doors.clone();
2599        }
2600        if self.effective_inside_building().is_some() {
2601            if let Some(map) = &delta.interior_map {
2602                self.interior_map = Some(map.clone());
2603            }
2604        } else {
2605            self.interior_map = None;
2606        }
2607        if !delta.npcs.is_empty() {
2608            self.npcs = delta.npcs.clone();
2609        }
2610        if !delta.quest_log.is_empty() {
2611            self.quest_log = delta.quest_log.clone();
2612        }
2613        self.apply_hired_workers(delta.hired_workers.clone());
2614        if !delta.interactables.is_empty() {
2615            self.interactables = delta.interactables.clone();
2616        }
2617        if delta.ledger.is_some() {
2618            self.ledger = delta.ledger.clone();
2619        }
2620        if delta.career.is_some() {
2621            self.career = delta.career.clone();
2622        }
2623        if let Some(combat) = &delta.combat {
2624            self.apply_combat_hud(combat);
2625            let stacks = self.inventory_stacks.clone();
2626            self.sync_inventory_from_stacks(&stacks);
2627        } else {
2628            self.refresh_inventory_ui();
2629        }
2630    }
2631
2632    /// Wildlife and other combat targets visible in AOI (`NpcView.entity_id`).
2633    pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
2634        let (px, py) = self.player_position();
2635        let mut out = Vec::new();
2636        for npc in &self.npcs {
2637            let Some(eid) = npc.entity_id else {
2638                continue;
2639            };
2640            let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
2641            let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
2642            if alive && has_hp {
2643                out.push((eid, npc.label.clone()));
2644            }
2645        }
2646        out.sort_by(|(a_id, a_label), (b_id, b_label)| {
2647            let dist = |id: EntityId| {
2648                self.entities
2649                    .iter()
2650                    .find(|e| e.id == id)
2651                    .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
2652                    .unwrap_or(f32::MAX)
2653            };
2654            dist(*a_id)
2655                .partial_cmp(&dist(*b_id))
2656                .unwrap_or(std::cmp::Ordering::Equal)
2657                .then_with(|| a_label.cmp(b_label))
2658                .then_with(|| a_id.cmp(b_id))
2659        });
2660        out
2661    }
2662
2663    pub fn refresh_combat_target_label(&mut self) {
2664        let Some(id) = self.combat_target else {
2665            return;
2666        };
2667        if let Some((_, label)) = self
2668            .combat_candidates()
2669            .into_iter()
2670            .find(|(eid, _)| *eid == id)
2671        {
2672            self.combat_target_label = Some(label);
2673        } else if let Some(label) = self
2674            .entities
2675            .iter()
2676            .find(|e| e.id == id)
2677            .map(|e| e.label.clone())
2678        {
2679            self.combat_target_label = Some(label);
2680        }
2681    }
2682
2683    pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
2684        self.quest_log
2685            .iter()
2686            .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
2687            .collect()
2688    }
2689
2690    /// True when the observer has at least one free lodging slot (max workers = beds).
2691    pub fn has_worker_lodging(&self) -> bool {
2692        self.free_worker_lodging_slots() > 0
2693    }
2694
2695    /// Owned lodging capacity minus currently hired workers.
2696    pub fn free_worker_lodging_slots(&self) -> i64 {
2697        let slots: u32 = self
2698            .placed_containers
2699            .iter()
2700            .filter(|c| match (self.character_id, c.owner_character_id) {
2701                (Some(me), Some(owner)) => me == owner,
2702                (Some(_), None) => false,
2703                (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
2704            })
2705            .map(|c| c.worker_lodging_capacity.unwrap_or(0))
2706            .sum();
2707        let used = self.hired_workers.len() as u32;
2708        slots as i64 - used as i64
2709    }
2710
2711    /// Display names of hired workers assigned to this lodging container.
2712    pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
2713        let mut names: Vec<String> = self
2714            .hired_workers
2715            .iter()
2716            .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
2717            .map(|w| w.label.clone())
2718            .collect();
2719        names.sort();
2720        names
2721    }
2722
2723    /// Compact lodging occupancy for labels: `"Elda, Ana"`, `"vacant"`, or `""` if not lodging.
2724    pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
2725        let is_lodging = self
2726            .placed_containers
2727            .iter()
2728            .find(|c| c.id == container_id)
2729            .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
2730        if !is_lodging {
2731            return None;
2732        }
2733        let names = self.lodging_occupant_labels(container_id);
2734        Some(if names.is_empty() {
2735            "vacant".into()
2736        } else {
2737            names.join(", ")
2738        })
2739    }
2740
2741    pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
2742        self.quest_log
2743            .iter()
2744            .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
2745            .or_else(|| {
2746                self.quest_log
2747                    .iter()
2748                    .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
2749            })
2750    }
2751
2752    /// Nearest interactable target for `f` (doors, NPCs, well, shallow water).
2753    pub fn nearest_interact_target(&self) -> Option<String> {
2754        let (px, py) = self.player_position();
2755        let inside = self.effective_inside_building();
2756
2757        #[derive(Clone, Copy, PartialEq, Eq)]
2758        enum Kind {
2759            Npc,
2760            QuestBoard,
2761            ExitDoor,
2762            EnterDoor,
2763            Well,
2764            Water,
2765        }
2766
2767        fn kind_priority(kind: Kind) -> u8 {
2768            match kind {
2769                Kind::Npc => 0,
2770                Kind::QuestBoard => 1,
2771                Kind::ExitDoor => 2,
2772                Kind::EnterDoor => 3,
2773                Kind::Well => 4,
2774                Kind::Water => 5,
2775            }
2776        }
2777
2778        let mut best: Option<(f32, Kind, String)> = None;
2779
2780        let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
2781            if dist > max {
2782                return;
2783            }
2784            let replace = match best {
2785                None => true,
2786                Some((bd, _bk, _)) if dist < bd - 0.05 => true,
2787                Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
2788                    kind_priority(kind) < kind_priority(bk)
2789                }
2790                _ => false,
2791            };
2792            if replace {
2793                best = Some((dist, kind, id));
2794            }
2795        };
2796
2797        for npc in &self.npcs {
2798            consider(
2799                distance(px, py, npc.x, npc.y),
2800                INTERACTION_RADIUS_M,
2801                Kind::Npc,
2802                npc.id.clone(),
2803            );
2804        }
2805
2806        for door in &self.doors {
2807            if let Some(ref bid) = inside {
2808                if door.building_id != *bid {
2809                    continue;
2810                }
2811                let is_exit = door.portal.is_some();
2812                let max = if is_exit {
2813                    INTERACTION_RADIUS_M
2814                } else {
2815                    DOOR_INTERACTION_RADIUS_M
2816                };
2817                let kind = if is_exit {
2818                    Kind::ExitDoor
2819                } else {
2820                    Kind::EnterDoor
2821                };
2822                consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
2823                continue;
2824            }
2825            consider(
2826                distance(px, py, door.x, door.y),
2827                DOOR_INTERACTION_RADIUS_M,
2828                Kind::EnterDoor,
2829                door.id.clone(),
2830            );
2831        }
2832
2833        if inside.is_none() {
2834            for inter in &self.interactables {
2835                if inter.kind == "quest_board" {
2836                    consider(
2837                        distance(px, py, inter.x, inter.y),
2838                        QUEST_BOARD_INTERACTION_RADIUS_M,
2839                        Kind::QuestBoard,
2840                        inter.id.clone(),
2841                    );
2842                }
2843            }
2844            for building in &self.buildings {
2845                if !building.tags.iter().any(|t| t == "well") {
2846                    continue;
2847                }
2848                consider(
2849                    distance(px, py, building.x, building.y),
2850                    INTERACTION_RADIUS_M,
2851                    Kind::Well,
2852                    building.id.clone(),
2853                );
2854            }
2855            if self.in_shallow_water() {
2856                consider(
2857                    0.0,
2858                    INTERACTION_RADIUS_M,
2859                    Kind::Water,
2860                    "water_source".into(),
2861                );
2862            }
2863        }
2864
2865        best.map(|(_, _, id)| id)
2866    }
2867
2868    /// Nearest quest board and distance (any distance), for out-of-range feedback.
2869    pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
2870        if self.effective_inside_building().is_some() {
2871            return None;
2872        }
2873        let (px, py) = self.player_position();
2874        self.interactables
2875            .iter()
2876            .filter(|i| i.kind == "quest_board")
2877            .map(|i| {
2878                let label = if i.label.is_empty() {
2879                    "Quest board".to_string()
2880                } else {
2881                    i.label.clone()
2882                };
2883                (label, distance(px, py, i.x, i.y))
2884            })
2885            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
2886    }
2887
2888    /// Human-readable template name from synced catalog hints.
2889    pub fn template_display_name(&self, template_id: &str) -> String {
2890        self.inventory_hints
2891            .get(template_id)
2892            .map(|h| h.display_name.clone())
2893            .filter(|n| !n.is_empty())
2894            .unwrap_or_else(|| humanize_template_id(template_id))
2895    }
2896
2897    /// Chest label for the location panel — generic type unless this player owns it.
2898    pub fn placed_container_public_label(
2899        &self,
2900        c: &flatland_protocol::PlacedContainerView,
2901    ) -> String {
2902        let is_owner = match (self.character_id, c.owner_character_id) {
2903            (Some(me), Some(owner)) => me == owner,
2904            _ => false,
2905        };
2906        if is_owner {
2907            c.display_name.clone()
2908        } else {
2909            self.template_display_name(&c.template_id)
2910        }
2911    }
2912
2913    /// Keys on person and stowed on the keychain for the keychain overlay.
2914    pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
2915        let mut out = Vec::new();
2916        for stack in &self.inventory_stacks {
2917            if stack.template_id == KEY_TEMPLATE {
2918                out.push(KeychainEntry {
2919                    stack: stack.clone(),
2920                    stowed: false,
2921                });
2922            }
2923        }
2924        for stack in &self.keychain_stacks {
2925            if stack.template_id == KEY_TEMPLATE {
2926                out.push(KeychainEntry {
2927                    stack: stack.clone(),
2928                    stowed: true,
2929                });
2930            }
2931        }
2932        out
2933    }
2934
2935    /// Display name of the chest a `container_key` opens, when known on the client.
2936    pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
2937        if stack.template_id != KEY_TEMPLATE {
2938            return None;
2939        }
2940        if let Some(name) = stack
2941            .props
2942            .get(PROP_OPENS_CONTAINER_NAME)
2943            .filter(|n| !n.is_empty())
2944        {
2945            return Some(name.clone());
2946        }
2947        let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
2948        self.container_name_for_lock_id(opens)
2949    }
2950
2951    /// Keys always show the catalog name — never a chest rename or stray `custom_name`.
2952    pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
2953        if stack.template_id == KEY_TEMPLATE {
2954            self.template_display_name(KEY_TEMPLATE)
2955        } else {
2956            stack
2957                .display_name
2958                .clone()
2959                .unwrap_or_else(|| stack.template_id.clone())
2960        }
2961    }
2962
2963    /// Hint suffix for a key row (`[key for …]` or `[key — unpaired]`).
2964    pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
2965        if stack.template_id != KEY_TEMPLATE {
2966            return String::new();
2967        }
2968        match self.key_pair_chest_label(stack) {
2969            Some(chest) if self.key_drop_blocked(stack) => {
2970                format!(" [key for {chest} — can't drop while locked]")
2971            }
2972            Some(chest) => format!(" [key for {chest}]"),
2973            None => " [key — unpaired]".into(),
2974        }
2975    }
2976
2977    /// Resolve a lock id to a container label (placed chest or one still on your person).
2978    pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
2979        for c in &self.placed_containers {
2980            if c.lock_id.as_deref() == Some(lock) {
2981                return Some(c.display_name.clone());
2982            }
2983        }
2984        Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
2985            self.worn
2986                .values()
2987                .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
2988        })
2989    }
2990
2991    /// Keys cannot be dropped while their paired chest is locked.
2992    pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
2993        if stack.template_id != KEY_TEMPLATE {
2994            return false;
2995        }
2996        let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
2997            return false;
2998        };
2999        for c in &self.placed_containers {
3000            if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
3001                return true;
3002            }
3003        }
3004        if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
3005            return true;
3006        }
3007        self.worn
3008            .values()
3009            .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
3010    }
3011
3012    fn container_name_in_stacks(
3013        stacks: &[flatland_protocol::ItemStack],
3014        lock: &str,
3015    ) -> Option<String> {
3016        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
3017            for s in stacks {
3018                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
3019                    return Some(GameState::stack_container_label(s));
3020                }
3021                if let Some(name) = walk(&s.contents, lock) {
3022                    return Some(name);
3023                }
3024            }
3025            None
3026        }
3027        walk(stacks, lock)
3028    }
3029
3030    fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
3031        stack
3032            .props
3033            .get(PROP_CUSTOM_NAME)
3034            .cloned()
3035            .or_else(|| stack.display_name.clone())
3036            .unwrap_or_else(|| stack.template_id.clone())
3037    }
3038
3039    fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
3040        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
3041            for s in stacks {
3042                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
3043                    return true;
3044                }
3045                if walk(&s.contents, lock) {
3046                    return true;
3047                }
3048            }
3049            false
3050        }
3051        walk(stacks, lock)
3052    }
3053
3054    fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
3055        if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
3056            return Some(stack.clone());
3057        }
3058        for worn in self.worn.values() {
3059            if worn.item_instance_id == Some(instance_id) {
3060                return Some(worn.clone());
3061            }
3062            if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
3063                return Some(stack.clone());
3064            }
3065        }
3066        None
3067    }
3068
3069    /// Terrain, interactables, and map objects near the player for the HUD location panel.
3070    pub fn location_context_lines(&self) -> Vec<ContextLine> {
3071        let (px, py) = self.player_position();
3072        let inside = self.effective_inside_building();
3073        let mut lines = Vec::new();
3074
3075        if let Some(kind) = self.terrain_at(px, py) {
3076            lines.push(ContextLine {
3077                on_top: true,
3078                text: format!("Terrain: {}", terrain_kind_label(kind)),
3079            });
3080        }
3081
3082        if let Some(id) = inside.as_ref() {
3083            if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
3084                lines.push(ContextLine {
3085                    on_top: true,
3086                    text: format!("Inside: {}", b.label),
3087                });
3088            }
3089        }
3090
3091        let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
3092
3093        for node in &self.resource_nodes {
3094            let dist = distance(px, py, node.x, node.y);
3095            if dist > NEARBY_SCAN_M {
3096                continue;
3097            }
3098            let on_top = dist <= ON_TOP_RADIUS_M;
3099            let state = match node.state {
3100                flatland_protocol::ResourceNodeState::Available => " — f harvest",
3101                flatland_protocol::ResourceNodeState::Harvesting => " (being harvested)",
3102                flatland_protocol::ResourceNodeState::Cooldown => " (depleted)",
3103            };
3104            let prefix = if on_top { "On" } else { "Near" };
3105            nearby.push((
3106                dist,
3107                ContextLine {
3108                    on_top,
3109                    text: format!("{prefix}: {} ({dist:.1}m){state}", node.label),
3110                },
3111            ));
3112        }
3113
3114        for drop in &self.ground_drops {
3115            let dist = distance(px, py, drop.x, drop.y);
3116            if dist > INTERACTION_RADIUS_M {
3117                continue;
3118            }
3119            let on_top = dist <= ON_TOP_RADIUS_M;
3120            let name = self.template_display_name(&drop.template_id);
3121            let prefix = if on_top { "On" } else { "Near" };
3122            let qty = if drop.quantity > 1 {
3123                format!(" ×{}", drop.quantity)
3124            } else {
3125                String::new()
3126            };
3127            nearby.push((
3128                dist,
3129                ContextLine {
3130                    on_top,
3131                    text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
3132                },
3133            ));
3134        }
3135
3136        for c in &self.placed_containers {
3137            let dist = distance(px, py, c.x, c.y);
3138            if dist > CONTAINER_RANGE_M {
3139                continue;
3140            }
3141            let on_top = dist <= ON_TOP_RADIUS_M;
3142            let name = self.placed_container_public_label(c);
3143            let lock = if c.locked { " [locked]" } else { "" };
3144            let prefix = if on_top { "On" } else { "Near" };
3145            nearby.push((
3146                dist,
3147                ContextLine {
3148                    on_top,
3149                    text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
3150                },
3151            ));
3152        }
3153
3154        for npc in &self.npcs {
3155            let dist = distance(px, py, npc.x, npc.y);
3156            if dist > NEARBY_SCAN_M {
3157                continue;
3158            }
3159            let on_top = dist <= ON_TOP_RADIUS_M;
3160            let prefix = if on_top { "On" } else { "Near" };
3161            nearby.push((
3162                dist,
3163                ContextLine {
3164                    on_top,
3165                    text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
3166                },
3167            ));
3168        }
3169
3170        for door in &self.doors {
3171            let dist = distance(px, py, door.x, door.y);
3172            if dist > DOOR_INTERACTION_RADIUS_M {
3173                continue;
3174            }
3175            let building = self
3176                .buildings
3177                .iter()
3178                .find(|b| b.id == door.building_id)
3179                .map(|b| b.label.as_str())
3180                .unwrap_or(door.building_id.as_str());
3181            let action = if inside.is_some() && door.portal.is_some() {
3182                "exit"
3183            } else {
3184                "enter"
3185            };
3186            nearby.push((
3187                dist,
3188                ContextLine {
3189                    on_top: dist <= ON_TOP_RADIUS_M,
3190                    text: format!("{building} door ({dist:.1}m) — f {action}"),
3191                },
3192            ));
3193        }
3194
3195        if inside.is_none() {
3196            for inter in &self.interactables {
3197                if inter.kind != "quest_board" {
3198                    continue;
3199                }
3200                let dist = distance(px, py, inter.x, inter.y);
3201                if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
3202                    continue;
3203                }
3204                let on_top = dist <= ON_TOP_RADIUS_M;
3205                let prefix = if on_top { "On" } else { "Near" };
3206                let label = if inter.label.is_empty() {
3207                    "Quest board".to_string()
3208                } else {
3209                    inter.label.clone()
3210                };
3211                nearby.push((
3212                    dist,
3213                    ContextLine {
3214                        on_top,
3215                        text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
3216                    },
3217                ));
3218            }
3219        }
3220
3221        if self.in_shallow_water() {
3222            let already = self
3223                .terrain_at(px, py)
3224                .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
3225            if !already {
3226                nearby.push((
3227                    0.0,
3228                    ContextLine {
3229                        on_top: true,
3230                        text: "Shallow water — f fill bottle".into(),
3231                    },
3232                ));
3233            } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
3234                line.text.push_str(" — f fill bottle");
3235            }
3236        }
3237
3238        for entity in &self.entities {
3239            if entity.id == self.entity_id {
3240                continue;
3241            }
3242            let dist = distance(
3243                px,
3244                py,
3245                entity.transform.position.x,
3246                entity.transform.position.y,
3247            );
3248            if dist > NEARBY_SCAN_M {
3249                continue;
3250            }
3251            let label = if entity.label.is_empty() {
3252                format!("entity {}", entity.id)
3253            } else {
3254                entity.label.clone()
3255            };
3256            nearby.push((
3257                dist,
3258                ContextLine {
3259                    on_top: dist <= ON_TOP_RADIUS_M,
3260                    text: format!("Near: {label} ({dist:.1}m)"),
3261                },
3262            ));
3263        }
3264
3265        nearby.sort_by(|a, b| {
3266            a.0.partial_cmp(&b.0)
3267                .unwrap_or(std::cmp::Ordering::Equal)
3268                .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
3269        });
3270        lines.extend(nearby.into_iter().map(|(_, l)| l));
3271
3272        if lines.is_empty() {
3273            lines.push(ContextLine {
3274                on_top: false,
3275                text: "(nothing notable nearby)".into(),
3276            });
3277        }
3278
3279        lines
3280    }
3281}
3282
3283/// HUD line for the location / nearby panel.
3284#[derive(Debug, Clone)]
3285pub struct ContextLine {
3286    pub on_top: bool,
3287    pub text: String,
3288}
3289
3290const ON_TOP_RADIUS_M: f32 = 0.65;
3291const NEARBY_SCAN_M: f32 = 5.0;
3292
3293fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
3294    use flatland_protocol::TerrainKindView;
3295    match kind {
3296        TerrainKindView::Grass => "Grass",
3297        TerrainKindView::Hill => "Hills",
3298        TerrainKindView::Bog => "Bog",
3299        TerrainKindView::ShallowWater => "Shallow water",
3300        TerrainKindView::DeepWater => "Deep water",
3301        TerrainKindView::Trail => "Trail",
3302        TerrainKindView::Rock => "Rock",
3303    }
3304}
3305
3306fn humanize_template_id(template_id: &str) -> String {
3307    template_id
3308        .split('_')
3309        .map(|word| {
3310            let mut chars = word.chars();
3311            match chars.next() {
3312                None => String::new(),
3313                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
3314            }
3315        })
3316        .collect::<Vec<_>>()
3317        .join(" ")
3318}
3319
3320/// Keep in sync with `flatland_sim::INTERACTION_RADIUS_M`.
3321const HARVEST_RANGE_M: f32 = 1.5;
3322
3323pub struct GameClient<S: PlayConnection> {
3324    session: S,
3325    seq: Seq,
3326    pub state: GameState,
3327    last_move_forward: f32,
3328    last_move_strafe: f32,
3329}
3330
3331impl<S: PlayConnection> GameClient<S> {
3332    pub fn new(session: S) -> Self {
3333        let session_id = session.session_id();
3334        let entity_id = session.entity_id();
3335        Self {
3336            session,
3337            seq: 0,
3338            last_move_forward: 0.0,
3339            last_move_strafe: 0.0,
3340            state: GameState {
3341                session_id,
3342                entity_id,
3343                character_id: None,
3344                tick: 0,
3345                chunk_rev: 0,
3346                content_rev: 0,
3347                publish_rev: 0,
3348                entities: Vec::new(),
3349                player: None,
3350                resource_nodes: Vec::new(),
3351                ground_drops: Vec::new(),
3352                placed_containers: Vec::new(),
3353                buildings: Vec::new(),
3354                doors: Vec::new(),
3355                interior_map: None,
3356                npcs: Vec::new(),
3357                blueprints: Vec::new(),
3358                world_width_m: 0.0,
3359                world_height_m: 0.0,
3360                terrain_zones: Vec::new(),
3361                z_platforms: Vec::new(),
3362                z_transitions: Vec::new(),
3363                world_clock: flatland_protocol::WorldClock::default(),
3364                inventory: std::collections::HashMap::new(),
3365                inventory_hints: std::collections::HashMap::new(),
3366                logs: VecDeque::new(),
3367                intents_sent: 0,
3368                ticks_received: 0,
3369                connected: false,
3370                disconnect_reason: None,
3371                show_stats: false,
3372                show_equip_menu: false,
3373                equip_menu_index: 0,
3374                show_craft_menu: false,
3375                craft_menu_index: 0,
3376                craft_batch_quantity: 1,
3377                show_shop_menu: false,
3378                shop_catalog: None,
3379                shop_tab: ShopTab::default(),
3380                shop_menu_index: 0,
3381                shop_quantity: 1,
3382                shop_trade_log: VecDeque::new(),
3383                show_npc_verb_menu: false,
3384                npc_verb_target: None,
3385                npc_verb_index: 0,
3386                show_npc_chat: false,
3387                npc_chat: None,
3388                show_inventory_menu: false,
3389                inventory_menu_index: 0,
3390                show_move_picker: false,
3391                show_rename_prompt: false,
3392                show_worker_rename: false,
3393                rename_buffer: String::new(),
3394                move_picker_index: 0,
3395                move_picker: None,
3396                show_destroy_picker: false,
3397                destroy_confirm_pending: false,
3398                destroy_picker: None,
3399                combat_target: None,
3400                combat_target_label: None,
3401                in_combat: false,
3402                auto_attack: true,
3403                combat_has_los: false,
3404                attack_cd_ticks: 0,
3405                gcd_ticks: 0,
3406                weapon_ability_id: "unarmed".into(),
3407                mainhand_template_id: None,
3408                mainhand_label: None,
3409                offhand_template_id: None,
3410                offhand_label: None,
3411                mainhand_hand_slots: 1,
3412                defense: None,
3413                worn: BTreeMap::new(),
3414                carry_mass: 0.0,
3415                carry_mass_max: 0.0,
3416                encumbrance: flatland_protocol::EncumbranceState::Light,
3417                inventory_stacks: Vec::new(),
3418                keychain_stacks: Vec::new(),
3419                combat_target_detail: None,
3420                cast_progress: None,
3421                ability_cooldowns: Vec::new(),
3422                blocking_active: false,
3423                max_target_slots: 1,
3424                combat_slots: Vec::new(),
3425                rotation_presets: Vec::new(),
3426                show_loadout_menu: false,
3427                show_keychain_menu: false,
3428                keychain_menu_index: 0,
3429                show_rotation_editor: false,
3430                loadout_menu_index: 0,
3431                rotation_editor: RotationEditorState::default(),
3432                harvest_in_progress: false,
3433                harvest_started_at: None,
3434                pending_craft_ack: None,
3435                pending_worker_job_ack: None,
3436                quest_log: Vec::new(),
3437                interactables: Vec::new(),
3438                ledger: None,
3439                career: None,
3440                character_sheet_tab: CharacterSheetTab::Character,
3441                ledger_period: LedgerPeriod::Day,
3442                show_quest_offer: false,
3443                pending_quest_offer: None,
3444                show_quest_menu: false,
3445                quest_menu_index: 0,
3446                quest_withdraw_confirm: false,
3447                hired_workers: Vec::new(),
3448                show_workers_menu: false,
3449                workers_menu_index: 0,
3450                workers_menu_compact: false,
3451                worker_step_display: BTreeMap::new(),
3452                show_worker_give_picker: false,
3453                worker_give_picker_index: 0,
3454                worker_give_picker: None,
3455                show_worker_give_target_picker: false,
3456                worker_give_target_picker_index: 0,
3457                worker_give_target_picker: None,
3458                show_worker_take_picker: false,
3459                worker_take_picker_index: 0,
3460                worker_take_picker: None,
3461                show_worker_teach_picker: false,
3462                worker_teach_picker_index: 0,
3463                worker_teach_picker: None,
3464                worker_route_editor: None,
3465                progression_curve: None,
3466            },
3467        }
3468    }
3469
3470    pub fn entity_id(&self) -> EntityId {
3471        self.state.entity_id
3472    }
3473
3474    pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
3475        if self.state.connected {
3476            return Ok(());
3477        }
3478
3479        loop {
3480            match self.session.next_event().await {
3481                Some(SessionEvent::Welcome {
3482                    session_id,
3483                    entity_id,
3484                    snapshot,
3485                }) => {
3486                    self.state
3487                        .restore_from_welcome(session_id, entity_id, &snapshot);
3488                    self.state.push_log(format!(
3489                        "Connected — session {session_id}, entity {entity_id}"
3490                    ));
3491                    return Ok(());
3492                }
3493                Some(SessionEvent::Disconnected { .. }) => {
3494                    anyhow::bail!("disconnected before welcome");
3495                }
3496                Some(_) => continue,
3497                None => anyhow::bail!("session closed before welcome"),
3498            }
3499        }
3500    }
3501
3502    /// Drain all pending server events (non-blocking).
3503    pub fn drain_events(&mut self) {
3504        while let Some(event) = self.session.try_next_event() {
3505            if self.handle_event_sync(event).is_err() {
3506                break;
3507            }
3508        }
3509    }
3510
3511    /// Wait for the next server event.
3512    pub async fn next_event(&mut self) -> Option<SessionEvent> {
3513        self.session.next_event().await
3514    }
3515
3516    pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
3517        self.handle_event_sync(event)
3518    }
3519
3520    fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
3521        match event {
3522            SessionEvent::Welcome {
3523                session_id,
3524                entity_id,
3525                snapshot,
3526            } => {
3527                let resumed = self.state.connected;
3528                self.state
3529                    .restore_from_welcome(session_id, entity_id, &snapshot);
3530                if resumed {
3531                    self.state.push_log(format!(
3532                        "Session restored — session {session_id}, entity {entity_id}"
3533                    ));
3534                }
3535            }
3536            SessionEvent::ContentUpdated { snapshot } => {
3537                self.state
3538                    .apply_snapshot_fields(&snapshot, self.state.entity_id);
3539                self.state.push_log(format!(
3540                    "World updated (content rev {})",
3541                    snapshot.content_rev
3542                ));
3543            }
3544            SessionEvent::Tick(delta) => {
3545                self.state.apply_tick_fields(&delta, self.state.entity_id);
3546                self.state.ticks_received += 1;
3547            }
3548            SessionEvent::IntentAck {
3549                entity_id,
3550                seq,
3551                tick,
3552            } => {
3553                crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
3554                if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
3555                    if *craft_seq == seq {
3556                        let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
3557                        if batches > 1 {
3558                            self.state.push_log(format!("Crafting {label} ×{batches}…"));
3559                        } else {
3560                            self.state.push_log(format!("Crafting {label}…"));
3561                        }
3562                    }
3563                }
3564                if self
3565                    .state
3566                    .pending_worker_job_ack
3567                    .as_ref()
3568                    .is_some_and(|p| p.seq == seq)
3569                {
3570                    let pending = self.state.pending_worker_job_ack.take().unwrap();
3571                    if pending.idle {
3572                        self.state.push_log(format!(
3573                            "Route cleared for {} — worker idle",
3574                            pending.worker_label
3575                        ));
3576                    } else {
3577                        self.state.push_log(format!(
3578                            "Route saved for {} — {} stop(s), job loop active",
3579                            pending.worker_label, pending.stop_count
3580                        ));
3581                    }
3582                    if self
3583                        .state
3584                        .worker_route_editor
3585                        .as_ref()
3586                        .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
3587                    {
3588                        self.close_worker_route_editor();
3589                    }
3590                }
3591            }
3592            SessionEvent::Chat(msg) => {
3593                let label = match msg.channel {
3594                    flatland_protocol::ChatChannel::Nearby => "nearby",
3595                    flatland_protocol::ChatChannel::WhisperStone => "whisper",
3596                };
3597                self.state
3598                    .push_log(format!("[{label}] {}: {}", msg.from_name, msg.text));
3599            }
3600            SessionEvent::HarvestResult(result) => {
3601                self.state.clear_harvest_state();
3602                crate::harvest_trace!(
3603                    entity_id = self.state.entity_id,
3604                    node_id = %result.node_id,
3605                    template = %result.item_template,
3606                    quantity = result.quantity,
3607                    client_tick = self.state.tick,
3608                    "client applied harvest result"
3609                );
3610                self.state.push_log(format!(
3611                    "Harvested {} x{} (on the ground — press P to pick up)",
3612                    result.item_template, result.quantity
3613                ));
3614            }
3615            SessionEvent::CraftResult(result) => {
3616                for stack in &result.consumed {
3617                    if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
3618                        *qty = qty.saturating_sub(stack.quantity);
3619                        if *qty == 0 {
3620                            self.state.inventory.remove(&stack.template_id);
3621                        }
3622                    }
3623                }
3624                for stack in &result.outputs {
3625                    *self
3626                        .state
3627                        .inventory
3628                        .entry(stack.template_id.clone())
3629                        .or_insert(0) += stack.quantity;
3630                }
3631                if let Some(output) = result.outputs.first() {
3632                    if result.batch_total > 1 {
3633                        self.state.push_log(format!(
3634                            "Crafted {} x{} ({}/{})",
3635                            output.template_id,
3636                            output.quantity,
3637                            result.batch_index,
3638                            result.batch_total
3639                        ));
3640                    } else {
3641                        self.state.push_log(format!(
3642                            "Crafted {} x{}",
3643                            output.template_id, output.quantity
3644                        ));
3645                    }
3646                } else {
3647                    self.state
3648                        .push_log(format!("Craft finished: {}", result.blueprint_id));
3649                }
3650            }
3651            SessionEvent::Death(notice) => {
3652                self.state.clear_harvest_state();
3653                self.state.push_log(notice.message.clone());
3654                self.state.push_log(format!(
3655                    "Respawned at ({:.1}, {:.1})",
3656                    notice.respawn_x, notice.respawn_y
3657                ));
3658            }
3659            SessionEvent::Interaction(notice) => {
3660                if notice.message.starts_with("Harvest failed:") {
3661                    self.state.clear_harvest_state();
3662                }
3663                if notice.message.starts_with("Can't do that:") {
3664                    self.state.pending_craft_ack = None;
3665                    if let Some(pending) = self.state.pending_worker_job_ack.take() {
3666                        if let Some(w) = self
3667                            .state
3668                            .hired_workers
3669                            .iter_mut()
3670                            .find(|w| w.instance_id == pending.worker_instance_id)
3671                        {
3672                            w.route = pending.prev_route;
3673                            w.mode = pending.prev_mode;
3674                            w.step_label = pending.prev_step_label;
3675                            w.last_error = pending.prev_last_error;
3676                        }
3677                        let reason = notice
3678                            .message
3679                            .strip_prefix("Can't do that:")
3680                            .unwrap_or(&notice.message)
3681                            .trim();
3682                        self.state.push_log(format!(
3683                            "Route save failed for {}: {reason}",
3684                            pending.worker_label
3685                        ));
3686                    }
3687                }
3688                if notice.message.starts_with("Cast failed:") {
3689                    self.state.cast_progress = None;
3690                }
3691                if notice.message.contains("slain the") {
3692                    self.state.combat_target = None;
3693                    self.state.combat_target_label = None;
3694                }
3695                self.state.apply_interaction_notice(&notice);
3696                self.state.push_log(notice.message.clone());
3697            }
3698            SessionEvent::ShopOpened(catalog) => {
3699                self.state.apply_shop_catalog(catalog);
3700            }
3701            SessionEvent::NpcTalkOpened(opened) => {
3702                self.state.show_npc_verb_menu = false;
3703                if self.state.npc_verb_target.is_none() {
3704                    self.state.npc_verb_target = Some(opened.npc_id.clone());
3705                }
3706                let label = opened.npc_label.clone();
3707                let banner = if !opened.trade_allowed {
3708                    Some("Trade is unavailable right now.".to_string())
3709                } else {
3710                    None
3711                };
3712                self.state.show_npc_chat = true;
3713                self.state.npc_chat = Some(NpcChatState {
3714                    npc_id: opened.npc_id,
3715                    npc_label: opened.npc_label,
3716                    lines: if opened.greeting.is_empty() {
3717                        vec![]
3718                    } else {
3719                        vec![format!("{label}: {}", opened.greeting)]
3720                    },
3721                    input: String::new(),
3722                    pending: opened.greeting.is_empty(),
3723                    talk_depth: opened.talk_depth,
3724                    trade_allowed: opened.trade_allowed,
3725                    banner,
3726                });
3727            }
3728            SessionEvent::NpcTalkPending(_) => {
3729                if let Some(chat) = self.state.npc_chat.as_mut() {
3730                    chat.pending = true;
3731                }
3732            }
3733            SessionEvent::NpcTalkReply(reply) => {
3734                if let Some(chat) = self.state.npc_chat.as_mut() {
3735                    if chat.npc_id == reply.npc_id {
3736                        chat.pending = false;
3737                        if reply.trade_disabled {
3738                            chat.trade_allowed = false;
3739                            chat.banner = Some("Trade is unavailable right now.".to_string());
3740                        }
3741                        if reply.wind_down {
3742                            chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
3743                            if chat.banner.is_none() {
3744                                chat.banner =
3745                                    Some("They're wrapping up — keep it brief.".to_string());
3746                            }
3747                        }
3748                        chat.lines
3749                            .push(format!("{}: {}", chat.npc_label, reply.line));
3750                    }
3751                }
3752            }
3753            SessionEvent::NpcTalkClosed(closed) => {
3754                if self
3755                    .state
3756                    .npc_chat
3757                    .as_ref()
3758                    .is_some_and(|c| c.npc_id == closed.npc_id)
3759                {
3760                    self.state.show_npc_chat = false;
3761                    self.state.npc_chat = None;
3762                }
3763            }
3764            SessionEvent::NpcTalkError(err) => {
3765                self.state.push_log(format!("Talk failed: {}", err.reason));
3766                if let Some(chat) = self.state.npc_chat.as_mut() {
3767                    chat.pending = false;
3768                }
3769            }
3770            SessionEvent::UseResult(result) => {
3771                // Inventory count hint only — the Interaction notice already logs
3772                // "Consumed …" (and drives the gfx toast). Logging again here doubled toasts.
3773                if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
3774                    *qty = qty.saturating_sub(1);
3775                    if *qty == 0 {
3776                        self.state.inventory.remove(&result.template_id);
3777                    }
3778                }
3779            }
3780            SessionEvent::QuestOffer(offer) => {
3781                self.state.pending_quest_offer = Some(offer.clone());
3782                self.state.show_quest_offer = true;
3783                self.state
3784                    .push_log(format!("Quest offered: {}", offer.title));
3785            }
3786            SessionEvent::QuestAccepted(notice) => {
3787                self.state.show_quest_offer = false;
3788                self.state.pending_quest_offer = None;
3789                self.state.push_log(notice.message);
3790            }
3791            SessionEvent::QuestWithdrawn(notice) => {
3792                self.state.show_quest_menu = false;
3793                self.state.quest_withdraw_confirm = false;
3794                self.state.push_log(notice.message);
3795            }
3796            SessionEvent::QuestStepCompleted(notice) => {
3797                self.state.push_log(notice.message);
3798            }
3799            SessionEvent::QuestCompleted(notice) => {
3800                self.state.push_log(notice.message);
3801            }
3802            SessionEvent::Disconnected { reason } => {
3803                self.state.clear_harvest_state();
3804                self.state.connected = false;
3805                self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
3806                if let Some(r) = &self.state.disconnect_reason {
3807                    self.state.push_log(format!("Disconnected: {r}"));
3808                } else {
3809                    self.state.push_log("Disconnected from server");
3810                }
3811            }
3812        }
3813        Ok(())
3814    }
3815
3816    pub fn is_connected(&self) -> bool {
3817        self.state.connected
3818    }
3819
3820    pub fn close_overlays(&mut self) {
3821        self.state.show_stats = false;
3822        self.state.show_craft_menu = false;
3823        self.state.show_shop_menu = false;
3824        self.state.shop_catalog = None;
3825        self.state.show_npc_verb_menu = false;
3826        self.state.npc_verb_target = None;
3827        self.state.show_npc_chat = false;
3828        self.state.npc_chat = None;
3829        self.state.show_inventory_menu = false;
3830        self.state.show_loadout_menu = false;
3831        self.state.show_rotation_editor = false;
3832        self.state.rotation_editor.reset();
3833        self.state.show_rename_prompt = false;
3834        self.state.show_worker_rename = false;
3835        self.state.rename_buffer.clear();
3836        self.state.show_move_picker = false;
3837        self.state.move_picker = None;
3838        self.state.show_destroy_picker = false;
3839        self.state.destroy_confirm_pending = false;
3840        self.state.destroy_picker = None;
3841        self.state.show_quest_offer = false;
3842        self.state.pending_quest_offer = None;
3843        self.state.show_quest_menu = false;
3844        self.state.quest_withdraw_confirm = false;
3845        self.state.show_workers_menu = false;
3846        self.close_worker_give_picker();
3847        self.close_worker_give_target_picker();
3848        self.close_worker_take_picker();
3849        self.close_worker_teach_picker();
3850        self.state.worker_route_editor = None;
3851    }
3852
3853    /// Esc / back — pop one UI layer instead of closing every overlay at once.
3854    pub fn back_on_esc(&mut self) -> bool {
3855        if self.state.show_rename_prompt {
3856            self.cancel_rename_prompt();
3857            return true;
3858        }
3859        if self.state.show_worker_rename {
3860            self.cancel_worker_rename();
3861            return true;
3862        }
3863        if self.state.show_destroy_picker {
3864            if self.state.destroy_confirm_pending {
3865                self.cancel_destroy_confirm();
3866            } else {
3867                self.close_destroy_picker();
3868            }
3869            return true;
3870        }
3871        if self.state.show_move_picker {
3872            self.close_move_picker();
3873            return true;
3874        }
3875        if self.state.show_rotation_editor {
3876            match self.state.rotation_editor.mode {
3877                RotationEditorMode::List => {
3878                    self.state.show_rotation_editor = false;
3879                    self.state.rotation_editor.reset();
3880                }
3881                RotationEditorMode::EditLabel => {
3882                    self.state.rotation_editor.label_buffer.clear();
3883                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
3884                }
3885                RotationEditorMode::PickAbility => {
3886                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
3887                }
3888                RotationEditorMode::EditSequence => {
3889                    self.state.rotation_editor.draft = None;
3890                    self.state.rotation_editor.mode = RotationEditorMode::List;
3891                }
3892            }
3893            return true;
3894        }
3895        if self.state.show_inventory_menu {
3896            self.close_inventory_menu();
3897            return true;
3898        }
3899        if self.state.show_craft_menu {
3900            self.close_craft_menu();
3901            return true;
3902        }
3903        if self.state.show_quest_offer {
3904            self.quest_offer_decline();
3905            return true;
3906        }
3907        if self.state.show_shop_menu {
3908            self.back_from_shop_menu();
3909            return true;
3910        }
3911        if self.state.show_npc_chat {
3912            // play.rs calls npc_interaction_back().await on Esc
3913            return false;
3914        }
3915        if self.state.show_npc_verb_menu {
3916            self.state.show_npc_verb_menu = false;
3917            self.state.npc_verb_target = None;
3918            return true;
3919        }
3920        if self.state.show_quest_menu {
3921            if self.state.quest_withdraw_confirm {
3922                self.state.quest_withdraw_confirm = false;
3923            } else {
3924                self.state.show_quest_menu = false;
3925            }
3926            return true;
3927        }
3928        if self.state.worker_route_editor.is_some() {
3929            // Pop one sheet level; only close the editor at the root stop list.
3930            if self.re_at_root_sheet() {
3931                self.close_worker_route_editor();
3932            } else {
3933                self.re_sheet_back();
3934            }
3935            return true;
3936        }
3937        if self.state.show_worker_give_picker {
3938            self.close_worker_give_picker();
3939            return true;
3940        }
3941        if self.state.show_worker_give_target_picker {
3942            self.close_worker_give_target_picker();
3943            return true;
3944        }
3945        if self.state.show_worker_take_picker {
3946            self.close_worker_take_picker();
3947            return true;
3948        }
3949        if self.state.show_worker_teach_picker {
3950            self.close_worker_teach_picker();
3951            return true;
3952        }
3953        if self.state.show_workers_menu {
3954            self.state.show_workers_menu = false;
3955            return true;
3956        }
3957        if self.state.show_loadout_menu {
3958            self.state.show_loadout_menu = false;
3959            return true;
3960        }
3961        if self.state.show_stats {
3962            self.state.show_stats = false;
3963            return true;
3964        }
3965        if self.state.show_equip_menu {
3966            self.state.show_equip_menu = false;
3967            return true;
3968        }
3969        false
3970    }
3971
3972    pub fn toggle_stats(&mut self) {
3973        self.state.show_stats = !self.state.show_stats;
3974        if self.state.show_stats {
3975            self.state.character_sheet_tab = CharacterSheetTab::Character;
3976            self.state.show_craft_menu = false;
3977            self.state.show_shop_menu = false;
3978            self.state.shop_catalog = None;
3979            self.state.show_inventory_menu = false;
3980            self.state.show_equip_menu = false;
3981        }
3982    }
3983
3984    pub fn toggle_equip_menu(&mut self) {
3985        self.state.show_equip_menu = !self.state.show_equip_menu;
3986        if self.state.show_equip_menu {
3987            self.state.show_stats = false;
3988            self.state.show_craft_menu = false;
3989            self.state.show_shop_menu = false;
3990            self.state.shop_catalog = None;
3991            self.state.show_inventory_menu = false;
3992            self.state.show_loadout_menu = false;
3993        }
3994    }
3995
3996    pub fn cycle_character_sheet_tab(&mut self) {
3997        if self.state.show_stats {
3998            self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
3999        }
4000    }
4001
4002    pub fn set_ledger_period_digit(&mut self, c: char) {
4003        if self.state.show_stats {
4004            if let Some(p) = LedgerPeriod::from_digit(c) {
4005                self.state.ledger_period = p;
4006                self.state.character_sheet_tab = CharacterSheetTab::Ledger;
4007            }
4008        }
4009    }
4010
4011    pub fn cycle_ledger_period(&mut self) {
4012        if self.state.show_stats
4013            && self.state.character_sheet_tab == CharacterSheetTab::Ledger
4014        {
4015            self.state.ledger_period = self.state.ledger_period.cycle();
4016        }
4017    }
4018
4019    pub fn open_inventory_menu(&mut self) {
4020        self.state.show_inventory_menu = true;
4021        self.state.show_craft_menu = false;
4022        self.state.show_shop_menu = false;
4023        self.state.shop_catalog = None;
4024        self.state.show_stats = false;
4025        self.state.show_move_picker = false;
4026        self.state.move_picker = None;
4027        self.state.show_destroy_picker = false;
4028        self.state.destroy_confirm_pending = false;
4029        self.state.destroy_picker = None;
4030        self.state.show_rename_prompt = false;
4031        self.state.rename_buffer.clear();
4032        self.state.clamp_inventory_indices();
4033    }
4034
4035    pub fn close_inventory_menu(&mut self) {
4036        self.state.show_inventory_menu = false;
4037        self.state.show_move_picker = false;
4038        self.state.move_picker = None;
4039        self.state.show_destroy_picker = false;
4040        self.state.destroy_confirm_pending = false;
4041        self.state.destroy_picker = None;
4042        self.state.show_rename_prompt = false;
4043        self.state.rename_buffer.clear();
4044    }
4045
4046    pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
4047        let Some(row) = self.state.inventory_selected_row() else {
4048            anyhow::bail!("inventory empty");
4049        };
4050        if !self.state.row_is_renameable_container(&row) {
4051            anyhow::bail!("only storage containers can be renamed");
4052        }
4053        let current = row
4054            .stack
4055            .display_name
4056            .clone()
4057            .unwrap_or_else(|| row.stack.template_id.clone());
4058        self.state.rename_buffer = current;
4059        self.state.show_rename_prompt = true;
4060        self.state.show_worker_rename = false;
4061        self.state.show_move_picker = false;
4062        self.state.show_destroy_picker = false;
4063        self.state.destroy_confirm_pending = false;
4064        Ok(())
4065    }
4066
4067    pub fn cancel_rename_prompt(&mut self) {
4068        self.state.show_rename_prompt = false;
4069        self.state.rename_buffer.clear();
4070    }
4071
4072    pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
4073        let name = self.state.rename_buffer.trim().to_string();
4074        if name.is_empty() {
4075            anyhow::bail!("name cannot be empty");
4076        }
4077        let Some(row) = self.state.inventory_selected_row() else {
4078            anyhow::bail!("inventory empty");
4079        };
4080        let Some(instance_id) = row.stack.item_instance_id else {
4081            anyhow::bail!("item has no instance id");
4082        };
4083        self.seq += 1;
4084        self.session
4085            .submit_intent(Intent::RenameContainer {
4086                entity_id: self.state.entity_id,
4087                item_instance_id: instance_id,
4088                location: row.from.clone(),
4089                name,
4090                seq: self.seq,
4091            })
4092            .await?;
4093        self.state.intents_sent += 1;
4094        self.state.show_rename_prompt = false;
4095        self.state.rename_buffer.clear();
4096        Ok(())
4097    }
4098
4099    pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
4100        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
4101            anyhow::bail!("no worker selected");
4102        };
4103        self.state.rename_buffer = worker.label.clone();
4104        self.state.show_worker_rename = true;
4105        self.state.show_rename_prompt = false;
4106        Ok(())
4107    }
4108
4109    pub fn cancel_worker_rename(&mut self) {
4110        self.state.show_worker_rename = false;
4111        self.state.rename_buffer.clear();
4112    }
4113
4114    pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
4115        let name = self.state.rename_buffer.trim().to_string();
4116        if name.is_empty() {
4117            anyhow::bail!("name cannot be empty");
4118        }
4119        if name.chars().count() > 32 {
4120            anyhow::bail!("name must be 1–32 characters");
4121        }
4122        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
4123            anyhow::bail!("no worker selected");
4124        };
4125        let worker_instance_id = worker.instance_id.clone();
4126        self.seq += 1;
4127        self.session
4128            .submit_intent(Intent::RenameHiredWorker {
4129                entity_id: self.state.entity_id,
4130                worker_instance_id: worker_instance_id.clone(),
4131                name: name.clone(),
4132                seq: self.seq,
4133            })
4134            .await?;
4135        self.state.intents_sent += 1;
4136        if let Some(w) = self
4137            .state
4138            .hired_workers
4139            .iter_mut()
4140            .find(|w| w.instance_id == worker_instance_id)
4141        {
4142            w.label = name.clone();
4143        }
4144        if let Some(ed) = self.state.worker_route_editor.as_mut() {
4145            if ed.worker_instance_id == worker_instance_id {
4146                ed.worker_label = name.clone();
4147            }
4148        }
4149        self.state.show_worker_rename = false;
4150        self.state.rename_buffer.clear();
4151        self.state.push_log(format!("Renamed worker to \"{name}\""));
4152        Ok(())
4153    }
4154
4155    pub fn toggle_inventory_menu(&mut self) {
4156        if self.state.show_inventory_menu {
4157            self.close_inventory_menu();
4158        } else {
4159            self.open_inventory_menu();
4160        }
4161    }
4162
4163    /// ↑/↓ in the inventory browser, or within the "move to…" picker when open.
4164    pub fn inventory_menu_move(&mut self, delta: i32) {
4165        if self.state.show_move_picker {
4166            let n = self
4167                .state
4168                .move_picker
4169                .as_ref()
4170                .map(|p| p.options.len())
4171                .unwrap_or(0);
4172            if n == 0 {
4173                return;
4174            }
4175            let idx = self.state.move_picker_index as i32;
4176            self.state.move_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
4177            self.state.clamp_move_picker_quantity();
4178            return;
4179        }
4180        let n = self.state.inventory_selectable_rows().len();
4181        if n == 0 {
4182            return;
4183        }
4184        let idx = self.state.inventory_menu_index as i32;
4185        self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
4186    }
4187
4188    /// Enter: unequip a worn bag, equip a weapon, wear/place a loose bag or
4189    /// chest — or fall back to the "move to…" destination picker (including
4190    /// loose consumables; pick "Use" in that list or press `e` to eat/drink).
4191    /// Placed chest shells open a pick-up destination picker (`l` still locks).
4192    pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
4193        if self.state.show_destroy_picker {
4194            if self.state.destroy_confirm_pending {
4195                return self.confirm_destroy_item().await;
4196            }
4197            return self.request_destroy_confirm();
4198        }
4199        if self.state.show_move_picker {
4200            return self.confirm_move_picker().await;
4201        }
4202        let Some(row) = self.state.inventory_selected_row() else {
4203            anyhow::bail!("inventory empty");
4204        };
4205        if row.is_equip_shell {
4206            let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
4207                anyhow::bail!("not a worn item");
4208            };
4209            return self.equip_worn(slot, None).await;
4210        }
4211        if row.is_chest_shell {
4212            return self.open_chest_pickup_picker();
4213        }
4214        let template_id = row.stack.template_id.clone();
4215        let instance_id = row.stack.item_instance_id;
4216        let category = self.state.inventory_item_category(&template_id);
4217        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
4218
4219        if category == Some("weapon") {
4220            return self.equip_mainhand(Some(template_id)).await;
4221        }
4222        if category == Some("lodging") && on_person {
4223            if let Some(inst) = instance_id {
4224                return self.place_container(inst).await;
4225            }
4226        }
4227        if (category == Some("container") || category == Some("armor")) && on_person {
4228            if let Some(inst) = instance_id {
4229                let world_placeable = row.stack.world_placeable == Some(true)
4230                    || template_id.contains("chest");
4231                if world_placeable {
4232                    return self.place_container(inst).await;
4233                }
4234                // Pouches no longer equip directly — they clip onto a worn belt's loops
4235                // instead, so fall through to the move picker (offers "belt loop" when a
4236                // belt is worn). Backpacks/belts/armor equip straight to their body slot.
4237                if let Some(slot) = guess_body_slot(&template_id) {
4238                    return self.equip_worn(slot, Some(inst)).await;
4239                }
4240            }
4241        }
4242        // Anything else (materials, consumables, pouches, items nested in a bag/chest,
4243        // weapons you'd rather stash than wield, ...) — offer explicit places to move it
4244        // instead of guessing.
4245        self.open_move_picker()
4246    }
4247
4248    /// `e`: eat/drink the selected loose consumable on your person.
4249    pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
4250        let Some(row) = self.state.inventory_selected_row() else {
4251            anyhow::bail!("inventory empty");
4252        };
4253        if row.from != flatland_protocol::InventoryLocation::Root {
4254            anyhow::bail!("select a consumable on your person");
4255        }
4256        let category = self
4257            .state
4258            .inventory_item_category(&row.stack.template_id);
4259        if category != Some("consumable") {
4260            anyhow::bail!("selected item is not consumable");
4261        }
4262        self.use_item(&row.stack.template_id).await
4263    }
4264
4265    /// `m`: always open the "move to…" picker for the selected item, even for
4266    /// weapons/wearables that Enter would otherwise equip/wear directly.
4267    /// Placed chests open the pick-up destination picker instead.
4268    pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
4269        let Some(row) = self.state.inventory_selected_row() else {
4270            anyhow::bail!("inventory empty");
4271        };
4272        if row.is_equip_shell {
4273            anyhow::bail!("this is a worn bag — press Enter to unequip it");
4274        }
4275        if row.is_chest_shell {
4276            return self.open_chest_pickup_picker();
4277        }
4278        let Some(instance_id) = row.stack.item_instance_id else {
4279            anyhow::bail!("item has no instance id");
4280        };
4281        let mut options = self.state.move_destinations_for(
4282            &row.from,
4283            row.from_parent_instance_id,
4284            row.stack.item_instance_id,
4285            &row.stack.template_id,
4286        );
4287        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
4288        let category = self.state.inventory_item_category(&row.stack.template_id);
4289        if on_person && category == Some("consumable") {
4290            options.insert(
4291                0,
4292                MoveOption {
4293                    label: "Use (eat / drink)".into(),
4294                    kind: MoveOptionKind::Use,
4295                },
4296            );
4297        }
4298        let item_label = row
4299            .stack
4300            .display_name
4301            .clone()
4302            .unwrap_or_else(|| row.stack.template_id.clone());
4303        // Default to 1 so withdrawing from storage is partial unless the player
4304        // presses `a` for max fit (full stack when the destination allows it).
4305        let initial_qty = if row.stack.quantity > 1 { 1 } else { row.stack.quantity };
4306        self.state.move_picker = Some(MovePicker {
4307            item_instance_id: instance_id,
4308            from: row.from,
4309            item_label,
4310            template_id: row.stack.template_id.clone(),
4311            stack_quantity: row.stack.quantity,
4312            quantity: initial_qty.max(1),
4313            options,
4314        });
4315        self.state.move_picker_index = 0;
4316        self.state.show_move_picker = true;
4317        self.state.show_destroy_picker = false;
4318        self.state.destroy_confirm_pending = false;
4319        self.state.destroy_picker = None;
4320        self.state.clamp_move_picker_quantity();
4321        Ok(())
4322    }
4323
4324    /// Enter/`m` on a placed chest shell: pick destinations to take it into inventory.
4325    pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
4326        let Some(row) = self.state.inventory_selected_row() else {
4327            anyhow::bail!("inventory empty");
4328        };
4329        if !row.is_chest_shell {
4330            anyhow::bail!("not a placed chest");
4331        }
4332        let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
4333            anyhow::bail!("not a placed chest");
4334        };
4335        let Some(instance_id) = row.stack.item_instance_id else {
4336            anyhow::bail!("chest has no instance id");
4337        };
4338        let chest = self
4339            .state
4340            .placed_containers
4341            .iter()
4342            .find(|c| c.id == *container_id)
4343            .cloned()
4344            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
4345        let (px, py) = self.state.player_position();
4346        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
4347            anyhow::bail!("too far from {}", chest.display_name);
4348        }
4349        if chest.locked && !chest.accessible {
4350            anyhow::bail!(
4351                "need the matching key for {} before picking it up",
4352                chest.display_name
4353            );
4354        }
4355        let options = self.state.chest_pickup_destinations(container_id);
4356        let item_label = row
4357            .stack
4358            .display_name
4359            .clone()
4360            .unwrap_or_else(|| row.stack.template_id.clone());
4361        self.state.move_picker = Some(MovePicker {
4362            item_instance_id: instance_id,
4363            from: row.from.clone(),
4364            item_label,
4365            template_id: row.stack.template_id.clone(),
4366            stack_quantity: 1,
4367            quantity: 1,
4368            options,
4369        });
4370        self.state.move_picker_index = 0;
4371        self.state.show_move_picker = true;
4372        self.state.show_destroy_picker = false;
4373        self.state.destroy_confirm_pending = false;
4374        self.state.destroy_picker = None;
4375        Ok(())
4376    }
4377
4378    pub fn close_move_picker(&mut self) {
4379        self.state.show_move_picker = false;
4380        self.state.move_picker = None;
4381    }
4382
4383    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
4384        self.state.move_picker_adjust_quantity(delta);
4385    }
4386
4387    pub fn move_picker_set_quantity_max(&mut self) {
4388        self.state.move_picker_set_quantity_max();
4389    }
4390
4391    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
4392        self.state.destroy_picker_adjust_quantity(delta);
4393    }
4394
4395    pub fn destroy_picker_set_quantity_max(&mut self) {
4396        self.state.destroy_picker_set_quantity_max();
4397    }
4398
4399    async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
4400        let Some(picker) = self.state.move_picker.clone() else {
4401            self.close_move_picker();
4402            return Ok(());
4403        };
4404        let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
4405            self.close_move_picker();
4406            return Ok(());
4407        };
4408        match option.kind {
4409            MoveOptionKind::Cancel => {
4410                self.close_move_picker();
4411            }
4412            MoveOptionKind::Use => {
4413                self.close_move_picker();
4414                self.use_item(&picker.template_id).await?;
4415            }
4416            MoveOptionKind::Drop => {
4417                self.close_move_picker();
4418                if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
4419                    if self.state.key_drop_blocked(&stack) {
4420                        anyhow::bail!("cannot drop the key while its chest is locked");
4421                    }
4422                }
4423                self.drop_item(picker.item_instance_id, picker.from).await?;
4424                self.state
4425                    .push_log(format!("Dropped {}", picker.item_label));
4426            }
4427            MoveOptionKind::PickupPlaced {
4428                container_id,
4429                nest_location,
4430                nest_parent_instance_id,
4431            } => {
4432                self.close_move_picker();
4433                self.pickup_container(container_id.clone()).await?;
4434                let nest_into_bag = nest_parent_instance_id.is_some()
4435                    || !matches!(
4436                        nest_location,
4437                        flatland_protocol::InventoryLocation::Root
4438                    );
4439                if nest_into_bag {
4440                    self.move_item(
4441                        picker.item_instance_id,
4442                        flatland_protocol::InventoryLocation::Root,
4443                        nest_location,
4444                        nest_parent_instance_id,
4445                        None,
4446                    )
4447                    .await?;
4448                    self.state
4449                        .push_log(format!("Picked up {} into bag", picker.item_label));
4450                } else {
4451                    self.state
4452                        .push_log(format!("Picked up {}", picker.item_label));
4453                }
4454            }
4455            MoveOptionKind::Move {
4456                location,
4457                parent_instance_id,
4458            } => {
4459                self.close_move_picker();
4460                let qty = if picker.quantity >= picker.stack_quantity {
4461                    None
4462                } else {
4463                    Some(picker.quantity)
4464                };
4465                self.move_item(
4466                    picker.item_instance_id,
4467                    picker.from,
4468                    location,
4469                    parent_instance_id,
4470                    qty,
4471                )
4472                .await?;
4473                let moved = qty.unwrap_or(picker.stack_quantity);
4474                if moved >= picker.stack_quantity {
4475                    self.state.push_log(format!("Moved {}", picker.item_label));
4476                } else {
4477                    self.state.push_log(format!(
4478                        "Moved {} ×{} of {}",
4479                        picker.item_label, moved, picker.stack_quantity
4480                    ));
4481                }
4482            }
4483        }
4484        Ok(())
4485    }
4486
4487    /// `d`: drop the selected item on the ground immediately (no picker).
4488    pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
4489        let Some(row) = self.state.inventory_selected_row() else {
4490            anyhow::bail!("inventory empty");
4491        };
4492        if row.is_equip_shell {
4493            anyhow::bail!("unequip the bag first (Enter), then drop from your person");
4494        }
4495        if row.is_chest_shell {
4496            anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
4497        }
4498        let Some(inst) = row.stack.item_instance_id else {
4499            anyhow::bail!("item has no instance id");
4500        };
4501        if self.state.key_drop_blocked(&row.stack) {
4502            anyhow::bail!("cannot drop the key while its chest is locked");
4503        }
4504        let label = row
4505            .stack
4506            .display_name
4507            .clone()
4508            .unwrap_or_else(|| row.stack.template_id.clone());
4509        self.drop_item(inst, row.from).await?;
4510        self.state.push_log(format!("Dropped {label}"));
4511        Ok(())
4512    }
4513
4514    pub async fn drop_item(
4515        &mut self,
4516        item_instance_id: uuid::Uuid,
4517        from: flatland_protocol::InventoryLocation,
4518    ) -> anyhow::Result<()> {
4519        self.seq += 1;
4520        self.session
4521            .submit_intent(Intent::DropItem {
4522                entity_id: self.state.entity_id,
4523                item_instance_id,
4524                from,
4525                seq: self.seq,
4526            })
4527            .await?;
4528        self.state.intents_sent += 1;
4529        Ok(())
4530    }
4531
4532    /// `x`: open permanent-delete picker for the selected item (quantity + confirm).
4533    pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
4534        let Some(row) = self.state.inventory_selected_row() else {
4535            anyhow::bail!("inventory empty");
4536        };
4537        if row.is_equip_shell {
4538            anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
4539        }
4540        if row.is_chest_shell {
4541            anyhow::bail!("can't destroy a placed chest from the inventory list");
4542        }
4543        let Some(instance_id) = row.stack.item_instance_id else {
4544            anyhow::bail!("item has no instance id");
4545        };
4546        if self.state.key_drop_blocked(&row.stack) {
4547            anyhow::bail!("cannot destroy the key while its chest is locked");
4548        }
4549        let item_label = row
4550            .stack
4551            .display_name
4552            .clone()
4553            .unwrap_or_else(|| row.stack.template_id.clone());
4554        self.state.destroy_picker = Some(DestroyPicker {
4555            item_instance_id: instance_id,
4556            from: row.from,
4557            item_label,
4558            stack_quantity: row.stack.quantity,
4559            quantity: row.stack.quantity,
4560        });
4561        self.state.destroy_confirm_pending = false;
4562        self.state.show_destroy_picker = true;
4563        self.state.show_move_picker = false;
4564        self.state.move_picker = None;
4565        Ok(())
4566    }
4567
4568    pub fn close_destroy_picker(&mut self) {
4569        self.state.show_destroy_picker = false;
4570        self.state.destroy_confirm_pending = false;
4571        self.state.destroy_picker = None;
4572    }
4573
4574    pub fn cancel_destroy_confirm(&mut self) {
4575        self.state.destroy_confirm_pending = false;
4576    }
4577
4578    pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
4579        if self.state.destroy_picker.is_none() {
4580            self.close_destroy_picker();
4581            return Ok(());
4582        }
4583        self.state.destroy_confirm_pending = true;
4584        Ok(())
4585    }
4586
4587    pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
4588        let Some(picker) = self.state.destroy_picker.clone() else {
4589            self.close_destroy_picker();
4590            return Ok(());
4591        };
4592        let qty = if picker.quantity >= picker.stack_quantity {
4593            None
4594        } else {
4595            Some(picker.quantity)
4596        };
4597        self.destroy_item(picker.item_instance_id, picker.from, qty)
4598            .await?;
4599        let destroyed = qty.unwrap_or(picker.stack_quantity);
4600        if destroyed >= picker.stack_quantity {
4601            self.state
4602                .push_log(format!("Destroyed {}", picker.item_label));
4603        } else {
4604            self.state.push_log(format!(
4605                "Destroyed {} ×{} of {}",
4606                picker.item_label, destroyed, picker.stack_quantity
4607            ));
4608        }
4609        self.close_destroy_picker();
4610        Ok(())
4611    }
4612
4613    pub async fn destroy_item(
4614        &mut self,
4615        item_instance_id: uuid::Uuid,
4616        from: flatland_protocol::InventoryLocation,
4617        quantity: Option<u32>,
4618    ) -> anyhow::Result<()> {
4619        self.seq += 1;
4620        self.session
4621            .submit_intent(Intent::DestroyItem {
4622                entity_id: self.state.entity_id,
4623                item_instance_id,
4624                from,
4625                quantity,
4626                seq: self.seq,
4627            })
4628            .await?;
4629        self.state.intents_sent += 1;
4630        Ok(())
4631    }
4632
4633    /// `l`: lock/unlock a placed chest — selected chest in inventory UI, else nearest.
4634    pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
4635        if let Some(row) = self.state.inventory_selected_row() {
4636            if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
4637                return self.toggle_placed_chest_lock(container_id).await;
4638            }
4639        }
4640        self.toggle_nearby_chest_lock().await
4641    }
4642
4643    pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
4644        let chest = self
4645            .state
4646            .placed_containers
4647            .iter()
4648            .find(|c| c.id == container_id)
4649            .cloned()
4650            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
4651        let (px, py) = self.state.player_position();
4652        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
4653            anyhow::bail!("too far from {}", chest.display_name);
4654        }
4655        if !chest.accessible && chest.locked {
4656            anyhow::bail!(
4657                "need the matching key for {} (each crafted chest has its own key)",
4658                chest.display_name
4659            );
4660        }
4661        let lock = !chest.locked;
4662        self.set_container_locked(
4663            flatland_protocol::InventoryLocation::Placed {
4664                container_id: chest.id.clone(),
4665            },
4666            lock,
4667        )
4668        .await?;
4669        self.state.push_log(if lock {
4670            format!("Locked {}", chest.display_name)
4671        } else {
4672            format!("Unlocked {}", chest.display_name)
4673        });
4674        Ok(())
4675    }
4676
4677    /// `l` outside inventory: lock/unlock the nearest placed chest (within `CONTAINER_RANGE_M`).
4678    pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
4679        let chest = self
4680            .state
4681            .nearest_placed_container(CONTAINER_RANGE_M)
4682            .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
4683        self.toggle_placed_chest_lock(&chest.id).await
4684    }
4685
4686    pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
4687        self.equip_mainhand(None).await
4688    }
4689
4690    pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
4691        if !self.state.is_alive() {
4692            anyhow::bail!("you are dead");
4693        }
4694        self.seq += 1;
4695        self.session
4696            .submit_intent(Intent::EquipOffhand {
4697                entity_id: self.state.entity_id,
4698                template_id,
4699                seq: self.seq,
4700            })
4701            .await?;
4702        self.state.intents_sent += 1;
4703        Ok(())
4704    }
4705
4706    pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
4707        self.equip_offhand(None).await
4708    }
4709
4710    pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
4711        let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
4712        for slot in slots {
4713            self.equip_worn(slot, None).await?;
4714        }
4715        Ok(())
4716    }
4717
4718    pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
4719        let (px, py) = self.state.player_position();
4720        let nearest = self
4721            .state
4722            .placed_containers
4723            .iter()
4724            .min_by(|a, b| {
4725                let da = (a.x - px).hypot(a.y - py);
4726                let db = (b.x - px).hypot(b.y - py);
4727                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
4728            })
4729            .cloned();
4730        let Some(chest) = nearest else {
4731            anyhow::bail!("no chest nearby");
4732        };
4733        if (chest.x - px).hypot(chest.y - py) > 2.0 {
4734            anyhow::bail!("too far from chest");
4735        }
4736        self.pickup_container(chest.id).await
4737    }
4738
4739    pub async fn equip_worn(
4740        &mut self,
4741        slot: BodySlot,
4742        instance_id: Option<uuid::Uuid>,
4743    ) -> anyhow::Result<()> {
4744        self.seq += 1;
4745        self.session
4746            .submit_intent(Intent::EquipWorn {
4747                entity_id: self.state.entity_id,
4748                slot,
4749                instance_id,
4750                seq: self.seq,
4751            })
4752            .await?;
4753        self.state.intents_sent += 1;
4754        Ok(())
4755    }
4756
4757    pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
4758        self.seq += 1;
4759        self.session
4760            .submit_intent(Intent::PlaceContainer {
4761                entity_id: self.state.entity_id,
4762                item_instance_id,
4763                seq: self.seq,
4764            })
4765            .await?;
4766        self.state.intents_sent += 1;
4767        Ok(())
4768    }
4769
4770    pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
4771        self.seq += 1;
4772        self.session
4773            .submit_intent(Intent::PickupContainer {
4774                entity_id: self.state.entity_id,
4775                container_id,
4776                seq: self.seq,
4777            })
4778            .await?;
4779        self.state.intents_sent += 1;
4780        Ok(())
4781    }
4782
4783    pub async fn move_item(
4784        &mut self,
4785        item_instance_id: uuid::Uuid,
4786        from: flatland_protocol::InventoryLocation,
4787        to: flatland_protocol::InventoryLocation,
4788        to_parent_instance_id: Option<uuid::Uuid>,
4789        quantity: Option<u32>,
4790    ) -> anyhow::Result<()> {
4791        self.seq += 1;
4792        self.session
4793            .submit_intent(Intent::MoveItem {
4794                entity_id: self.state.entity_id,
4795                item_instance_id,
4796                from,
4797                to,
4798                to_parent_instance_id,
4799                quantity,
4800                seq: self.seq,
4801            })
4802            .await?;
4803        self.state.intents_sent += 1;
4804        Ok(())
4805    }
4806
4807    pub async fn set_container_locked(
4808        &mut self,
4809        location: flatland_protocol::InventoryLocation,
4810        locked: bool,
4811    ) -> anyhow::Result<()> {
4812        self.seq += 1;
4813        self.session
4814            .submit_intent(Intent::SetContainerLocked {
4815                entity_id: self.state.entity_id,
4816                location,
4817                locked,
4818                seq: self.seq,
4819            })
4820            .await?;
4821        self.state.intents_sent += 1;
4822        Ok(())
4823    }
4824
4825    pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
4826        if !self.state.is_alive() {
4827            anyhow::bail!("you are dead");
4828        }
4829        self.seq += 1;
4830        self.session
4831            .submit_intent(Intent::Use {
4832                entity_id: self.state.entity_id,
4833                template_id: template_id.to_string(),
4834                seq: self.seq,
4835            })
4836            .await?;
4837        self.state.intents_sent += 1;
4838        Ok(())
4839    }
4840
4841    pub fn open_craft_menu(&mut self) {
4842        self.state.show_craft_menu = true;
4843        self.state.show_shop_menu = false;
4844        self.state.shop_catalog = None;
4845        self.state.show_stats = false;
4846        self.state.show_inventory_menu = false;
4847        if self.state.blueprints.is_empty() {
4848            self.state.craft_menu_index = 0;
4849            self.state.craft_batch_quantity = 1;
4850            return;
4851        }
4852        self.state.craft_menu_index = self
4853            .state
4854            .craft_menu_index
4855            .min(self.state.blueprints.len() - 1);
4856        if let Some(idx) = self
4857            .state
4858            .blueprints
4859            .iter()
4860            .position(|bp| self.state.can_craft_blueprint(bp))
4861        {
4862            self.state.craft_menu_index = idx;
4863        }
4864        self.state.clamp_craft_batch_quantity();
4865    }
4866
4867    pub fn close_craft_menu(&mut self) {
4868        self.state.show_craft_menu = false;
4869    }
4870
4871    pub fn toggle_keychain_menu(&mut self) {
4872        if self.state.show_keychain_menu {
4873            self.close_keychain_menu();
4874        } else {
4875            self.state.show_keychain_menu = true;
4876            self.state.show_craft_menu = false;
4877            self.state.show_shop_menu = false;
4878            self.state.show_inventory_menu = false;
4879            let n = self.state.keychain_entries().len();
4880            if n == 0 {
4881                self.state.keychain_menu_index = 0;
4882            } else {
4883                self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
4884            }
4885        }
4886    }
4887
4888    pub fn close_keychain_menu(&mut self) {
4889        self.state.show_keychain_menu = false;
4890    }
4891
4892    pub fn keychain_menu_move(&mut self, delta: i32) {
4893        let n = self.state.keychain_entries().len();
4894        if n == 0 {
4895            self.state.keychain_menu_index = 0;
4896            return;
4897        }
4898        let idx = self.state.keychain_menu_index as i32 + delta;
4899        self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
4900    }
4901
4902    pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
4903        if !self.state.is_alive() {
4904            anyhow::bail!("you are dead");
4905        }
4906        let entries = self.state.keychain_entries();
4907        let Some(entry) = entries.get(self.state.keychain_menu_index) else {
4908            anyhow::bail!("nothing selected");
4909        };
4910        let Some(instance_id) = entry.stack.item_instance_id else {
4911            anyhow::bail!("key has no instance id");
4912        };
4913        if entry.stowed {
4914            self.move_item(
4915                instance_id,
4916                flatland_protocol::InventoryLocation::Keychain,
4917                flatland_protocol::InventoryLocation::Root,
4918                None,
4919                Some(1),
4920            )
4921            .await
4922        } else {
4923            self.move_item(
4924                instance_id,
4925                flatland_protocol::InventoryLocation::Root,
4926                flatland_protocol::InventoryLocation::Keychain,
4927                None,
4928                Some(1),
4929            )
4930            .await
4931        }
4932    }
4933
4934    pub fn close_shop_menu(&mut self) {
4935        self.state.show_shop_menu = false;
4936        self.state.shop_catalog = None;
4937        self.state.clear_shop_trade_log();
4938    }
4939
4940    /// Close shop and return to the Talk/Trade verb menu when inside an NPC session.
4941    pub fn back_from_shop_menu(&mut self) {
4942        let return_to_verbs = self.state.npc_verb_target.is_some();
4943        self.close_shop_menu();
4944        if return_to_verbs {
4945            self.state.show_npc_verb_menu = true;
4946        }
4947    }
4948
4949    pub fn shop_tab_toggle(&mut self) {
4950        self.state.shop_tab = match self.state.shop_tab {
4951            ShopTab::Buy => ShopTab::Sell,
4952            ShopTab::Sell => ShopTab::Buy,
4953        };
4954        self.state.shop_menu_index = 0;
4955        if self.state.shop_tab == ShopTab::Sell {
4956            self.state.shop_quantity_set_max();
4957        }
4958        self.state.clamp_shop_selection();
4959    }
4960
4961    pub fn shop_menu_move(&mut self, delta: i32) {
4962        self.state.shop_menu_move(delta);
4963    }
4964
4965    pub fn shop_quantity_adjust(&mut self, delta: i32) {
4966        self.state.shop_quantity_adjust(delta);
4967    }
4968
4969    pub fn shop_quantity_set_max(&mut self) {
4970        self.state.shop_quantity_set_max();
4971    }
4972
4973    pub fn toggle_quest_menu(&mut self) {
4974        self.state.show_quest_menu = !self.state.show_quest_menu;
4975        if self.state.show_quest_menu {
4976            self.state.quest_menu_index = 0;
4977            self.state.quest_withdraw_confirm = false;
4978            self.state.show_workers_menu = false;
4979        }
4980    }
4981
4982    pub fn toggle_workers_menu(&mut self) {
4983        self.state.show_workers_menu = !self.state.show_workers_menu;
4984        if self.state.show_workers_menu {
4985            self.state.workers_menu_index = 0;
4986            self.state.show_quest_menu = false;
4987            self.close_worker_give_picker();
4988            self.close_worker_give_target_picker();
4989            self.close_worker_take_picker();
4990            self.close_worker_teach_picker();
4991            self.cancel_worker_rename();
4992        } else {
4993            self.close_worker_give_picker();
4994            self.close_worker_give_target_picker();
4995            self.close_worker_take_picker();
4996            self.close_worker_teach_picker();
4997            self.cancel_worker_rename();
4998        }
4999    }
5000
5001    pub fn workers_menu_move(&mut self, delta: i32) {
5002        let n = self.state.hired_workers.len();
5003        if n == 0 {
5004            return;
5005        }
5006        let idx = self.state.workers_menu_index as i32;
5007        self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
5008    }
5009
5010    pub fn toggle_workers_menu_compact(&mut self) {
5011        self.state.workers_menu_compact = !self.state.workers_menu_compact;
5012    }
5013
5014    pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
5015        let Some(worker) = self
5016            .state
5017            .hired_workers
5018            .get(self.state.workers_menu_index)
5019            .cloned()
5020        else {
5021            anyhow::bail!("no worker selected");
5022        };
5023        self.seq += 1;
5024        self.session
5025            .submit_intent(Intent::DismissWorker {
5026                entity_id: self.state.entity_id,
5027                worker_instance_id: worker.instance_id.clone(),
5028                seq: self.seq,
5029            })
5030            .await?;
5031        self.state.intents_sent += 1;
5032        self.state
5033            .hired_workers
5034            .retain(|w| w.instance_id != worker.instance_id);
5035        if self.state.workers_menu_index >= self.state.hired_workers.len() {
5036            self.state.workers_menu_index = self
5037                .state
5038                .hired_workers
5039                .len()
5040                .saturating_sub(1);
5041        }
5042        self.state.push_log(format!("Dismissed {}", worker.label));
5043        Ok(())
5044    }
5045
5046    pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
5047        let Some(worker) = self
5048            .state
5049            .hired_workers
5050            .get(self.state.workers_menu_index)
5051            .cloned()
5052        else {
5053            anyhow::bail!("no worker selected");
5054        };
5055        let mode = match worker.mode {
5056            flatland_protocol::WorkerModeView::Companion => "job_loop",
5057            flatland_protocol::WorkerModeView::JobLoop => "idle",
5058            flatland_protocol::WorkerModeView::Idle => "companion",
5059        };
5060        self.seq += 1;
5061        self.session
5062            .submit_intent(Intent::SetWorkerMode {
5063                entity_id: self.state.entity_id,
5064                worker_instance_id: worker.instance_id,
5065                mode: mode.into(),
5066                seq: self.seq,
5067            })
5068            .await?;
5069        self.state.intents_sent += 1;
5070        Ok(())
5071    }
5072
5073    pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
5074        if self.state.hired_workers.is_empty() {
5075            return self.hire_worker_laborer().await;
5076        }
5077        self.workers_toggle_mode_selected().await
5078    }
5079
5080    /// Open a nearby-worker picker for the selected inventory stack (inventory `g`).
5081    /// Always shows a chooser so you can pick Bruce vs Cookie when several are close.
5082    pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
5083        let row = self
5084            .state
5085            .inventory_selected_row()
5086            .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
5087            .clone();
5088        if row.from != flatland_protocol::InventoryLocation::Root {
5089            anyhow::bail!("select a carried item to give");
5090        }
5091        let Some(instance_id) = row.stack.item_instance_id else {
5092            anyhow::bail!("that stack can't be given");
5093        };
5094        let options = self.nearby_worker_give_targets();
5095        if options.is_empty() {
5096            anyhow::bail!(
5097                "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
5098            );
5099        }
5100        let item_label = row
5101            .stack
5102            .display_name
5103            .as_deref()
5104            .unwrap_or(&row.stack.template_id)
5105            .to_string();
5106        self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
5107            item_instance_id: instance_id,
5108            item_label,
5109            quantity: None,
5110            options,
5111        });
5112        self.state.worker_give_target_picker_index = 0;
5113        self.state.show_worker_give_target_picker = true;
5114        // Let the target picker own keys (inventory would otherwise swallow ↑↓/Enter).
5115        self.state.show_inventory_menu = false;
5116        Ok(())
5117    }
5118
5119    /// Hired workers within give/take range, nearest first.
5120    pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
5121        let (px, py, _) = self.state.player_position_with_z();
5122        let mut options: Vec<WorkerGiveTargetOption> = self
5123            .state
5124            .hired_workers
5125            .iter()
5126            .filter_map(|w| {
5127                let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
5128                if dist > WORKER_GIVE_RANGE_M {
5129                    return None;
5130                }
5131                Some(WorkerGiveTargetOption {
5132                    instance_id: w.instance_id.clone(),
5133                    label: w.label.clone(),
5134                    distance_m: dist,
5135                })
5136            })
5137            .collect();
5138        options.sort_by(|a, b| {
5139            a.distance_m
5140                .partial_cmp(&b.distance_m)
5141                .unwrap_or(std::cmp::Ordering::Equal)
5142        });
5143        options
5144    }
5145
5146    pub fn close_worker_give_target_picker(&mut self) {
5147        self.state.show_worker_give_target_picker = false;
5148        self.state.worker_give_target_picker = None;
5149        self.state.worker_give_target_picker_index = 0;
5150    }
5151
5152    pub fn worker_give_target_picker_move(&mut self, delta: i32) {
5153        let Some(picker) = &self.state.worker_give_target_picker else {
5154            return;
5155        };
5156        let n = picker.options.len();
5157        if n == 0 {
5158            return;
5159        }
5160        let idx = self.state.worker_give_target_picker_index as i32;
5161        self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
5162    }
5163
5164    pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
5165        let Some(picker) = self.state.worker_give_target_picker.clone() else {
5166            anyhow::bail!("give target picker not open");
5167        };
5168        let Some(opt) = picker
5169            .options
5170            .get(self.state.worker_give_target_picker_index)
5171            .cloned()
5172        else {
5173            anyhow::bail!("no worker selected");
5174        };
5175        let Some(worker) = self
5176            .state
5177            .hired_workers
5178            .iter()
5179            .find(|w| w.instance_id == opt.instance_id)
5180            .cloned()
5181        else {
5182            self.close_worker_give_target_picker();
5183            anyhow::bail!("worker no longer hired");
5184        };
5185        self.give_item_to_worker(
5186            &worker.instance_id,
5187            &worker.label,
5188            worker.x,
5189            worker.y,
5190            picker.item_instance_id,
5191            &picker.item_label,
5192            picker.quantity,
5193        )
5194        .await?;
5195        self.close_worker_give_target_picker();
5196        Ok(())
5197    }
5198
5199    /// Give the selected inventory stack to a hired worker (opens nearby-worker picker).
5200    pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
5201        self.open_worker_give_target_picker()
5202    }
5203
5204    /// Open the workers-menu give picker for the selected worker (must be in range).
5205    pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
5206        let Some(worker) = self
5207            .state
5208            .hired_workers
5209            .get(self.state.workers_menu_index)
5210            .cloned()
5211        else {
5212            anyhow::bail!("select a hired worker first");
5213        };
5214        let (px, py, _) = self.state.player_position_with_z();
5215        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
5216        if dist > WORKER_GIVE_RANGE_M {
5217            anyhow::bail!(
5218                "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
5219                worker.label
5220            );
5221        }
5222        let options = self.state.giveable_inventory_options();
5223        if options.is_empty() {
5224            anyhow::bail!("nothing in inventory to give");
5225        }
5226        self.state.worker_give_picker = Some(WorkerGivePicker {
5227            worker_instance_id: worker.instance_id,
5228            worker_label: worker.label,
5229            options,
5230        });
5231        self.state.worker_give_picker_index = 0;
5232        self.state.show_worker_give_picker = true;
5233        Ok(())
5234    }
5235
5236    pub fn close_worker_give_picker(&mut self) {
5237        self.state.show_worker_give_picker = false;
5238        self.state.worker_give_picker = None;
5239        self.state.worker_give_picker_index = 0;
5240    }
5241
5242    pub fn worker_give_picker_move(&mut self, delta: i32) {
5243        let Some(picker) = &self.state.worker_give_picker else {
5244            return;
5245        };
5246        let n = picker.options.len();
5247        if n == 0 {
5248            return;
5249        }
5250        let idx = self.state.worker_give_picker_index as i32;
5251        self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
5252    }
5253
5254    /// Confirm the selected row in the workers-menu give picker.
5255    pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
5256        let Some(picker) = self.state.worker_give_picker.clone() else {
5257            anyhow::bail!("give picker not open");
5258        };
5259        let Some(opt) = picker.options.get(self.state.worker_give_picker_index).cloned() else {
5260            anyhow::bail!("no item selected");
5261        };
5262        let Some(worker) = self
5263            .state
5264            .hired_workers
5265            .iter()
5266            .find(|w| w.instance_id == picker.worker_instance_id)
5267            .cloned()
5268        else {
5269            self.close_worker_give_picker();
5270            anyhow::bail!("worker no longer hired");
5271        };
5272        self.give_item_to_worker(
5273            &worker.instance_id,
5274            &worker.label,
5275            worker.x,
5276            worker.y,
5277            opt.item_instance_id,
5278            &opt.label,
5279            None,
5280        )
5281        .await?;
5282        // Refresh options (stack may be gone / reduced) or close when empty.
5283        let options = self.state.giveable_inventory_options();
5284        if options.is_empty() {
5285            self.close_worker_give_picker();
5286        } else {
5287            self.state.worker_give_picker = Some(WorkerGivePicker {
5288                worker_instance_id: picker.worker_instance_id,
5289                worker_label: picker.worker_label,
5290                options,
5291            });
5292            if self.state.worker_give_picker_index
5293                >= self
5294                    .state
5295                    .worker_give_picker
5296                    .as_ref()
5297                    .map(|p| p.options.len())
5298                    .unwrap_or(0)
5299            {
5300                self.state.worker_give_picker_index = self
5301                    .state
5302                    .worker_give_picker
5303                    .as_ref()
5304                    .map(|p| p.options.len().saturating_sub(1))
5305                    .unwrap_or(0);
5306            }
5307        }
5308        Ok(())
5309    }
5310
5311    /// Open the workers-menu teach picker for the selected worker (must be in range).
5312    pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
5313        let Some(worker) = self
5314            .state
5315            .hired_workers
5316            .get(self.state.workers_menu_index)
5317            .cloned()
5318        else {
5319            anyhow::bail!("select a hired worker first");
5320        };
5321        let (px, py, _) = self.state.player_position_with_z();
5322        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
5323        if dist > WORKER_GIVE_RANGE_M {
5324            anyhow::bail!(
5325                "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
5326                worker.label
5327            );
5328        }
5329        let options = self.state.teachable_blueprint_options(&worker);
5330        if options.is_empty() {
5331            anyhow::bail!("no recipes you know that {} still needs", worker.label);
5332        }
5333        self.state.worker_teach_picker = Some(WorkerTeachPicker {
5334            worker_instance_id: worker.instance_id,
5335            worker_label: worker.label,
5336            worker_level: worker.level,
5337            options,
5338        });
5339        self.state.worker_teach_picker_index = 0;
5340        self.state.show_worker_teach_picker = true;
5341        Ok(())
5342    }
5343
5344    pub fn close_worker_teach_picker(&mut self) {
5345        self.state.show_worker_teach_picker = false;
5346        self.state.worker_teach_picker = None;
5347        self.state.worker_teach_picker_index = 0;
5348    }
5349
5350    pub fn worker_teach_picker_move(&mut self, delta: i32) {
5351        let Some(picker) = &self.state.worker_teach_picker else {
5352            return;
5353        };
5354        let n = picker.options.len();
5355        if n == 0 {
5356            return;
5357        }
5358        let idx = self.state.worker_teach_picker_index as i32;
5359        self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
5360    }
5361
5362    pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
5363        let Some(picker) = self.state.worker_teach_picker.clone() else {
5364            anyhow::bail!("teach picker not open");
5365        };
5366        let Some(opt) = picker.options.get(self.state.worker_teach_picker_index).cloned() else {
5367            anyhow::bail!("nothing selected");
5368        };
5369        if !opt.level_ok {
5370            anyhow::bail!(
5371                "{} needs level {} (is level {})",
5372                picker.worker_label,
5373                opt.min_level,
5374                opt.worker_level
5375            );
5376        }
5377        if !opt.can_afford {
5378            anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
5379        }
5380        let Some(worker) = self
5381            .state
5382            .hired_workers
5383            .iter()
5384            .find(|w| w.instance_id == picker.worker_instance_id)
5385            .cloned()
5386        else {
5387            anyhow::bail!("worker gone");
5388        };
5389        let (px, py, _) = self.state.player_position_with_z();
5390        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
5391        if dist > WORKER_GIVE_RANGE_M {
5392            anyhow::bail!("worker {} too far — stand next to them", worker.label);
5393        }
5394        self.seq += 1;
5395        self.session
5396            .submit_intent(Intent::TeachWorkerBlueprint {
5397                entity_id: self.state.entity_id,
5398                worker_instance_id: picker.worker_instance_id.clone(),
5399                blueprint_id: opt.blueprint_id.clone(),
5400                seq: self.seq,
5401            })
5402            .await?;
5403        self.state.intents_sent += 1;
5404        self.state.push_log(format!(
5405            "Teaching {} to {} ({} cp)",
5406            opt.label, picker.worker_label, opt.cost_copper
5407        ));
5408        self.close_worker_teach_picker();
5409        Ok(())
5410    }
5411
5412    async fn give_item_to_worker(
5413        &mut self,
5414        worker_instance_id: &str,
5415        worker_label: &str,
5416        worker_x: f32,
5417        worker_y: f32,
5418        item_instance_id: uuid::Uuid,
5419        item_label: &str,
5420        quantity: Option<u32>,
5421    ) -> anyhow::Result<()> {
5422        let (px, py, _) = self.state.player_position_with_z();
5423        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
5424        if dist > WORKER_GIVE_RANGE_M {
5425            anyhow::bail!("worker {worker_label} too far — stand next to them");
5426        }
5427        self.seq += 1;
5428        self.session
5429            .submit_intent(Intent::GiveWorkerItem {
5430                entity_id: self.state.entity_id,
5431                worker_instance_id: worker_instance_id.to_string(),
5432                item_instance_id,
5433                quantity,
5434                seq: self.seq,
5435            })
5436            .await?;
5437        self.state.intents_sent += 1;
5438        self.state
5439            .push_log(format!("Gave {item_label} to {worker_label}"));
5440        Ok(())
5441    }
5442
5443    /// Open the workers-menu take picker for the selected worker (must be in range).
5444    pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
5445        let Some(worker) = self
5446            .state
5447            .hired_workers
5448            .get(self.state.workers_menu_index)
5449            .cloned()
5450        else {
5451            anyhow::bail!("select a hired worker first");
5452        };
5453        let (px, py, _) = self.state.player_position_with_z();
5454        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
5455        if dist > WORKER_GIVE_RANGE_M {
5456            anyhow::bail!(
5457                "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
5458                worker.label
5459            );
5460        }
5461        let options = Self::worker_inventory_options(&worker);
5462        if options.is_empty() {
5463            anyhow::bail!("{} isn't carrying anything", worker.label);
5464        }
5465        self.state.worker_take_picker = Some(WorkerTakePicker {
5466            worker_instance_id: worker.instance_id,
5467            worker_label: worker.label,
5468            options,
5469        });
5470        self.state.worker_take_picker_index = 0;
5471        self.state.show_worker_take_picker = true;
5472        Ok(())
5473    }
5474
5475    fn worker_inventory_options(
5476        worker: &flatland_protocol::HiredWorkerView,
5477    ) -> Vec<WorkerGiveOption> {
5478        worker
5479            .inventory
5480            .iter()
5481            .filter_map(|stack| {
5482                let item_instance_id = stack.item_instance_id?;
5483                let label = stack
5484                    .display_name
5485                    .clone()
5486                    .unwrap_or_else(|| stack.template_id.clone());
5487                let label = if stack.quantity > 1 {
5488                    format!("{label} ×{}", stack.quantity)
5489                } else {
5490                    label
5491                };
5492                Some(WorkerGiveOption {
5493                    item_instance_id,
5494                    label,
5495                    quantity: stack.quantity,
5496                    template_id: stack.template_id.clone(),
5497                })
5498            })
5499            .collect()
5500    }
5501
5502    pub fn close_worker_take_picker(&mut self) {
5503        self.state.show_worker_take_picker = false;
5504        self.state.worker_take_picker = None;
5505        self.state.worker_take_picker_index = 0;
5506    }
5507
5508    pub fn worker_take_picker_move(&mut self, delta: i32) {
5509        let Some(picker) = &self.state.worker_take_picker else {
5510            return;
5511        };
5512        let n = picker.options.len();
5513        if n == 0 {
5514            return;
5515        }
5516        let idx = self.state.worker_take_picker_index as i32;
5517        self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
5518    }
5519
5520    pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
5521        let Some(picker) = self.state.worker_take_picker.clone() else {
5522            anyhow::bail!("take picker not open");
5523        };
5524        let Some(opt) = picker.options.get(self.state.worker_take_picker_index).cloned() else {
5525            anyhow::bail!("no item selected");
5526        };
5527        let Some(worker) = self
5528            .state
5529            .hired_workers
5530            .iter()
5531            .find(|w| w.instance_id == picker.worker_instance_id)
5532            .cloned()
5533        else {
5534            self.close_worker_take_picker();
5535            anyhow::bail!("worker no longer hired");
5536        };
5537        self.take_item_from_worker(
5538            &worker.instance_id,
5539            &worker.label,
5540            worker.x,
5541            worker.y,
5542            opt.item_instance_id,
5543            &opt.label,
5544            None,
5545        )
5546        .await?;
5547        // Refresh from live view after local optimistic wait — use last known
5548        // worker inventory minus the taken stack for immediate UI; next tick
5549        // will reconcile.
5550        if let Some(w) = self
5551            .state
5552            .hired_workers
5553            .iter()
5554            .find(|w| w.instance_id == picker.worker_instance_id)
5555        {
5556            let options = Self::worker_inventory_options(w)
5557                .into_iter()
5558                .filter(|o| o.item_instance_id != opt.item_instance_id)
5559                .collect::<Vec<_>>();
5560            if options.is_empty() {
5561                self.close_worker_take_picker();
5562            } else {
5563                self.state.worker_take_picker = Some(WorkerTakePicker {
5564                    worker_instance_id: picker.worker_instance_id,
5565                    worker_label: picker.worker_label,
5566                    options,
5567                });
5568                self.state.worker_take_picker_index = self
5569                    .state
5570                    .worker_take_picker_index
5571                    .min(
5572                        self.state
5573                            .worker_take_picker
5574                            .as_ref()
5575                            .map(|p| p.options.len().saturating_sub(1))
5576                            .unwrap_or(0),
5577                    );
5578            }
5579        } else {
5580            self.close_worker_take_picker();
5581        }
5582        Ok(())
5583    }
5584
5585    async fn take_item_from_worker(
5586        &mut self,
5587        worker_instance_id: &str,
5588        worker_label: &str,
5589        worker_x: f32,
5590        worker_y: f32,
5591        item_instance_id: uuid::Uuid,
5592        item_label: &str,
5593        quantity: Option<u32>,
5594    ) -> anyhow::Result<()> {
5595        let (px, py, _) = self.state.player_position_with_z();
5596        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
5597        if dist > WORKER_GIVE_RANGE_M {
5598            anyhow::bail!("worker {worker_label} too far — stand next to them");
5599        }
5600        self.seq += 1;
5601        self.session
5602            .submit_intent(Intent::TakeWorkerItem {
5603                entity_id: self.state.entity_id,
5604                worker_instance_id: worker_instance_id.to_string(),
5605                item_instance_id,
5606                quantity,
5607                seq: self.seq,
5608            })
5609            .await?;
5610        self.state.intents_sent += 1;
5611        self.state
5612            .push_log(format!("Took {item_label} from {worker_label}"));
5613        Ok(())
5614    }
5615
5616    pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
5617        if !self.state.has_worker_lodging() {
5618            anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
5619        }
5620        self.seq += 1;
5621        self.session
5622            .submit_intent(Intent::HireWorker {
5623                entity_id: self.state.entity_id,
5624                def_id: "worker_laborer".into(),
5625                wage_copper_per_interval: 8,
5626                lodging_container_id: None,
5627                job_yaml: None,
5628                seq: self.seq,
5629            })
5630            .await?;
5631        self.state.intents_sent += 1;
5632        Ok(())
5633    }
5634
5635    pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
5636        let Some(worker) = self
5637            .state
5638            .hired_workers
5639            .get(self.state.workers_menu_index)
5640            .cloned()
5641        else {
5642            anyhow::bail!("select a hired worker first");
5643        };
5644        let lodging = worker.lodging_container_id.clone().or_else(|| {
5645            crate::worker_route_editor::owned_lodging_container_ids(
5646                &self.state.placed_containers,
5647                self.state.character_id,
5648            )
5649            .into_iter()
5650            .next()
5651            .map(|(id, _)| id)
5652        });
5653        let label = worker.label.clone();
5654        let editor = if let Some(route) = &worker.route {
5655            crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
5656                worker.instance_id,
5657                worker.label,
5658                route,
5659                lodging,
5660            )
5661        } else {
5662            crate::worker_route_editor::WorkerRouteEditorState::new(
5663                worker.instance_id,
5664                worker.label,
5665                lodging,
5666            )
5667        };
5668        self.state.worker_route_editor = Some(editor);
5669        self.state.show_workers_menu = false;
5670        self.state.push_log(format!(
5671            "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
5672        ));
5673        Ok(())
5674    }
5675
5676    pub fn close_worker_route_editor(&mut self) {
5677        self.state.worker_route_editor = None;
5678    }
5679
5680    pub fn worker_route_editor_toggle_panel(&mut self) {
5681        if let Some(ed) = self.state.worker_route_editor.as_mut() {
5682            ed.toggle_panel_collapsed();
5683        }
5684    }
5685
5686    pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
5687        let n = {
5688            let Some(ed) = self.state.worker_route_editor.as_mut() else {
5689                return;
5690            };
5691            ed.append_waypoint(x, y, z);
5692            ed.stop_count()
5693        };
5694        self.state
5695            .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
5696    }
5697
5698    // ---- picker candidate snapshots ----------------------------------
5699
5700    fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
5701        let (px, py, _) = self.state.player_position_with_z();
5702        crate::worker_route_editor::owned_container_candidates(
5703            &self.state.placed_containers,
5704            self.state.character_id,
5705            px,
5706            py,
5707        )
5708    }
5709
5710    fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
5711        let (px, py, _) = self.state.player_position_with_z();
5712        crate::worker_route_editor::node_candidates(&self.state.resource_nodes, px, py)
5713    }
5714
5715    fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
5716        let (px, py, _) = self.state.player_position_with_z();
5717        crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
5718    }
5719
5720    fn re_template_candidates(&self) -> Vec<String> {
5721        let mut extra = Vec::new();
5722        if let Some(ed) = self.state.worker_route_editor.as_ref() {
5723            for stop in &ed.stops {
5724                match stop {
5725                    crate::worker_route_editor::WorkerRouteStop::DepositAt {
5726                        filter: Some(filter),
5727                        ..
5728                    } => extra.extend(filter.iter().cloned()),
5729                    crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
5730                        extra.push(template.clone());
5731                    }
5732                    crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
5733                        if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint) {
5734                            extra.push(bp.output.clone());
5735                            for input in &bp.inputs {
5736                                extra.push(input.template_id.clone());
5737                            }
5738                        }
5739                    }
5740                    crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
5741                        for it in items {
5742                            extra.push(it.template.clone());
5743                        }
5744                    }
5745                    _ => {}
5746                }
5747            }
5748            // Worker-known recipe outputs even if the player hasn't learned them yet.
5749            if let Some(worker) = self
5750                .state
5751                .hired_workers
5752                .iter()
5753                .find(|w| w.instance_id == ed.worker_instance_id)
5754            {
5755                for recipe in &worker.known_blueprint_ids {
5756                    if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
5757                        extra.push(bp.output.clone());
5758                    }
5759                }
5760            }
5761        }
5762        crate::worker_route_editor::route_item_template_candidates(
5763            &self.state.placed_containers,
5764            self.state.character_id,
5765            &self.state.inventory,
5766            &self.state.blueprints,
5767            &self.state.resource_nodes,
5768            &extra,
5769        )
5770    }
5771
5772    fn re_blueprint_ids(&self) -> Vec<String> {
5773        let worker_known: Option<&[String]> = self
5774            .state
5775            .worker_route_editor
5776            .as_ref()
5777            .and_then(|ed| {
5778                self.state
5779                    .hired_workers
5780                    .iter()
5781                    .find(|w| w.instance_id == ed.worker_instance_id)
5782            })
5783            .map(|w| w.known_blueprint_ids.as_slice());
5784        crate::worker_route_editor::worker_craft_blueprint_ids(
5785            &self.state.blueprints,
5786            worker_known,
5787        )
5788    }
5789
5790    fn re_bed_candidates(&self) -> Vec<(String, String)> {
5791        crate::worker_route_editor::owned_lodging_container_ids(
5792            &self.state.placed_containers,
5793            self.state.character_id,
5794        )
5795    }
5796
5797    fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
5798        self.state
5799            .placed_containers
5800            .iter()
5801            .find(|c| c.id == container_id)
5802            .map(|c| c.contents.clone())
5803            .unwrap_or_default()
5804    }
5805
5806    // ---- sheet navigation (`plans/33`) --------------------------------
5807
5808    /// Row count of the current sheet (for cursor wrapping).
5809    pub fn re_sheet_row_count(&self) -> usize {
5810        use crate::worker_route_editor::RouteEditorSheet as S;
5811        let Some(ed) = self.state.worker_route_editor.as_ref() else {
5812            return 0;
5813        };
5814        match &ed.sheet {
5815            S::Stops => ed.stops.len(),
5816            S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
5817            S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
5818            S::WaypointMapPick => 0,
5819            S::HarvestPicker { .. } => self.re_node_candidates().len(),
5820            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
5821                self.re_container_candidates().len()
5822            }
5823            S::WithdrawItems { lines, .. } => lines.len() + 1, // + Done row
5824            S::DepositFilter { rows, .. } => rows.len() + 1,   // + Done row
5825            S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, // + auto row
5826            S::SellItem { templates, .. } => templates.len() + 1, // + sell-all toggle
5827            S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
5828            S::WaitEntry { .. } => 1,
5829            S::BedPicker { .. } => self.re_bed_candidates().len(),
5830        }
5831    }
5832
5833    /// Current sheet cursor index (0 for sheets without one).
5834    pub fn re_sheet_index(&self) -> usize {
5835        use crate::worker_route_editor::RouteEditorSheet as S;
5836        let Some(ed) = self.state.worker_route_editor.as_ref() else {
5837            return 0;
5838        };
5839        match &ed.sheet {
5840            S::AddMenu { index }
5841            | S::WaypointMenu { index }
5842            | S::HarvestPicker { index }
5843            | S::WithdrawContainers { index }
5844            | S::DepositContainers { index }
5845            | S::SellNpcs { index }
5846            | S::CraftBlueprint { index }
5847            | S::BedPicker { index }
5848            | S::WithdrawItems { index, .. }
5849            | S::DepositFilter { index, .. }
5850            | S::SellItem { index, .. } => *index,
5851            _ => 0,
5852        }
5853    }
5854
5855    /// Move the current sheet's cursor, wrapping within its rows.
5856    pub fn re_sheet_move(&mut self, delta: i32) {
5857        use crate::worker_route_editor::RouteEditorSheet as S;
5858        let count = self.re_sheet_row_count();
5859        if count == 0 {
5860            return;
5861        }
5862        let cur = self.re_sheet_index() as i32;
5863        let next = (cur + delta).rem_euclid(count as i32) as usize;
5864        let Some(ed) = self.state.worker_route_editor.as_mut() else {
5865            return;
5866        };
5867        match &mut ed.sheet {
5868            S::AddMenu { index }
5869            | S::WaypointMenu { index }
5870            | S::HarvestPicker { index }
5871            | S::WithdrawContainers { index }
5872            | S::DepositContainers { index }
5873            | S::SellNpcs { index }
5874            | S::CraftBlueprint { index }
5875            | S::BedPicker { index }
5876            | S::WithdrawItems { index, .. }
5877            | S::DepositFilter { index, .. }
5878            | S::SellItem { index, .. } => *index = next,
5879            _ => {}
5880        }
5881    }
5882
5883    /// `[`/`]` on a sheet: adjust quantity (withdraw lines, wait ticks).
5884    pub fn re_sheet_adjust(&mut self, delta: i32) {
5885        use crate::worker_route_editor::RouteEditorSheet as S;
5886        let index = self.re_sheet_index();
5887        let Some(ed) = self.state.worker_route_editor.as_mut() else {
5888            return;
5889        };
5890        match &mut ed.sheet {
5891            S::WithdrawItems { lines, .. } => {
5892                if let Some(line) = lines.get_mut(index) {
5893                    line.adjust_qty(delta);
5894                }
5895            }
5896            S::WaitEntry { ticks } => {
5897                *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
5898            }
5899            _ => {}
5900        }
5901    }
5902
5903    pub fn re_sheet_back(&mut self) {
5904        let Some(ed) = self.state.worker_route_editor.as_mut() else {
5905            return;
5906        };
5907        use crate::worker_route_editor::RouteEditorSheet as S;
5908        let was_editing = ed.editing_index.is_some();
5909        let from_top_picker = matches!(
5910            ed.sheet,
5911            S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
5912        );
5913        ed.sheet_back();
5914        if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
5915            // Chest retarget commits on pick; Esc here just ends the edit sheet.
5916            self.state
5917                .push_log("Route: left edit sheet — press s to save current stops".to_string());
5918        }
5919    }
5920
5921    /// True when the editor is at its root sheet (Esc should close it).
5922    pub fn re_at_root_sheet(&self) -> bool {
5923        self.state
5924            .worker_route_editor
5925            .as_ref()
5926            .is_some_and(|ed| matches!(ed.sheet, crate::worker_route_editor::RouteEditorSheet::Stops))
5927    }
5928
5929    pub fn re_open_add_menu(&mut self) {
5930        if let Some(ed) = self.state.worker_route_editor.as_mut() {
5931            ed.open_add_menu();
5932        }
5933    }
5934
5935    pub fn re_open_bed_picker(&mut self) {
5936        let beds = self.re_bed_candidates();
5937        if beds.is_empty() {
5938            self.state
5939                .push_log("Route: place a camp bed first".to_string());
5940            return;
5941        }
5942        let current = self
5943            .state
5944            .worker_route_editor
5945            .as_ref()
5946            .and_then(|ed| ed.lodging_container_id.clone());
5947        let index = current
5948            .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
5949            .unwrap_or(0);
5950        self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
5951    }
5952
5953    fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
5954        if let Some(ed) = self.state.worker_route_editor.as_mut() {
5955            ed.open_sheet(sheet);
5956        }
5957    }
5958
5959    /// Confirm a sheet's stop and log the outcome.
5960    fn re_confirm_stop(
5961        &mut self,
5962        stop: crate::worker_route_editor::WorkerRouteStop,
5963        what: String,
5964    ) {
5965        let appended = self
5966            .state
5967            .worker_route_editor
5968            .as_mut()
5969            .is_some_and(|ed| ed.confirm_stop(stop));
5970        if appended {
5971            self.state.push_log(format!("Route: + {what}"));
5972        } else {
5973            self.state
5974                .push_log(format!("Route: {what} already in route — selected it"));
5975        }
5976    }
5977
5978    fn re_open_withdraw_items(&mut self, container_id: String) {
5979        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
5980        let contents = self.re_container_contents(&container_id);
5981        // When editing, keep the stop's item picks even if the player retargets
5982        // to a different (possibly empty) chest — otherwise Done has nothing to
5983        // confirm and the edit appears to "revert".
5984        let existing = self
5985            .state
5986            .worker_route_editor
5987            .as_ref()
5988            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
5989            .and_then(|stop| match stop {
5990                WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
5991                _ => None,
5992            })
5993            .unwrap_or_default();
5994        let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
5995        // Commit the new chest immediately while editing so Esc-before-Done still
5996        // keeps the retarget (items update only when Done is pressed).
5997        if let Some(ed) = self.state.worker_route_editor.as_mut() {
5998            let _ = ed.retarget_withdraw_container(container_id.clone());
5999        }
6000        self.re_open_sheet(S::WithdrawItems {
6001            container_id,
6002            lines,
6003            index: 0,
6004        });
6005    }
6006
6007    fn re_withdraw_items_activate(&mut self, index: usize) {
6008        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
6009        enum Outcome {
6010            Cycled,
6011            Confirmed(String),
6012            Empty,
6013        }
6014        let outcome = {
6015            let Some(ed) = self.state.worker_route_editor.as_mut() else {
6016                return;
6017            };
6018            let S::WithdrawItems {
6019                container_id,
6020                lines,
6021                index: sheet_index,
6022            } = &mut ed.sheet
6023            else {
6024                return;
6025            };
6026            *sheet_index = index;
6027            if index < lines.len() {
6028                lines[index].cycle();
6029                Outcome::Cycled
6030            } else {
6031                let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
6032                if items.is_empty() {
6033                    Outcome::Empty
6034                } else {
6035                    let stop = WorkerRouteStop::WithdrawFrom {
6036                        container_id: container_id.clone(),
6037                        items,
6038                    };
6039                    let summary = stop.summary();
6040                    ed.confirm_stop(stop);
6041                    Outcome::Confirmed(summary)
6042                }
6043            }
6044        };
6045        match outcome {
6046            Outcome::Cycled => {}
6047            Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
6048            Outcome::Empty => self
6049                .state
6050                .push_log("Route: pick at least one item (Space/Enter toggles All/qty)".to_string()),
6051        }
6052    }
6053
6054    fn re_open_deposit_filter(&mut self, container_id: String) {
6055        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
6056        // Preserve filter when retargeting the deposit chest while editing.
6057        let existing_filter = self
6058            .state
6059            .worker_route_editor
6060            .as_ref()
6061            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
6062            .and_then(|stop| match stop {
6063                WorkerRouteStop::DepositAt { filter, .. } => {
6064                    Some(filter.clone().unwrap_or_default())
6065                }
6066                _ => None,
6067            });
6068        let mut candidates = self.re_template_candidates();
6069        if let Some(ref chosen) = existing_filter {
6070            for t in chosen {
6071                if !candidates.iter().any(|c| c == t) {
6072                    candidates.push(t.clone());
6073                }
6074            }
6075            candidates.sort();
6076            candidates.dedup();
6077        }
6078        let rows: Vec<(String, bool)> = match existing_filter {
6079            Some(chosen) => candidates
6080                .iter()
6081                .map(|t| (t.clone(), chosen.contains(t)))
6082                .collect(),
6083            None => candidates.into_iter().map(|t| (t, false)).collect(),
6084        };
6085        if let Some(ed) = self.state.worker_route_editor.as_mut() {
6086            let _ = ed.retarget_deposit_container(container_id.clone());
6087        }
6088        self.re_open_sheet(S::DepositFilter {
6089            container_id,
6090            rows,
6091            index: 0,
6092        });
6093    }
6094
6095    fn re_deposit_filter_activate(&mut self, index: usize) {
6096        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
6097        let mut confirmed: Option<String> = None;
6098        {
6099            let Some(ed) = self.state.worker_route_editor.as_mut() else {
6100                return;
6101            };
6102            let S::DepositFilter {
6103                container_id,
6104                rows,
6105                index: sheet_index,
6106            } = &mut ed.sheet
6107            else {
6108                return;
6109            };
6110            *sheet_index = index;
6111            if index < rows.len() {
6112                rows[index].1 = !rows[index].1;
6113            } else {
6114                // "Done" row.
6115                let chosen: Vec<String> = rows
6116                    .iter()
6117                    .filter(|(_, on)| *on)
6118                    .map(|(t, _)| t.clone())
6119                    .collect();
6120                let filter = if chosen.is_empty() { None } else { Some(chosen) };
6121                let stop = WorkerRouteStop::DepositAt {
6122                    container_id: container_id.clone(),
6123                    filter,
6124                };
6125                confirmed = Some(stop.summary());
6126                ed.confirm_stop(stop);
6127            }
6128        }
6129        if let Some(what) = confirmed {
6130            self.state.push_log(format!("Route: + {what}"));
6131        }
6132    }
6133
6134    fn re_open_sell_item(&mut self, npc_id: Option<String>) {
6135        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
6136        let templates = self.re_template_candidates();
6137        if templates.is_empty() {
6138            self.state.push_log(
6139                "Route: no item templates available — learn a craft recipe or place a harvest node first"
6140                    .to_string(),
6141            );
6142            return;
6143        }
6144        // Prefill when editing an existing sell stop.
6145        let (pre_npc, pre_template, pre_all) = self
6146            .state
6147            .worker_route_editor
6148            .as_ref()
6149            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
6150            .and_then(|stop| match stop {
6151                WorkerRouteStop::TradeWith {
6152                    npc_id,
6153                    template,
6154                    sell_all,
6155                } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
6156                _ => None,
6157            })
6158            .unwrap_or((None, None, true));
6159        let npc_id = npc_id.or(pre_npc);
6160        let index = pre_template
6161            .and_then(|t| templates.iter().position(|x| x == &t))
6162            .map(|i| i + 1) // +1 for the sell-all toggle row
6163            .unwrap_or(1);
6164        self.re_open_sheet(S::SellItem {
6165            npc_id,
6166            templates,
6167            index,
6168            sell_all: pre_all,
6169        });
6170    }
6171
6172    fn re_sell_item_activate(&mut self, index: usize) {
6173        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
6174        let mut confirmed: Option<String> = None;
6175        {
6176            let Some(ed) = self.state.worker_route_editor.as_mut() else {
6177                return;
6178            };
6179            let S::SellItem {
6180                npc_id,
6181                templates,
6182                index: sheet_index,
6183                sell_all,
6184            } = &mut ed.sheet
6185            else {
6186                return;
6187            };
6188            *sheet_index = index;
6189            if index == 0 {
6190                *sell_all = !*sell_all;
6191            } else if let Some(template) = templates.get(index - 1).cloned() {
6192                let stop = WorkerRouteStop::TradeWith {
6193                    npc_id: npc_id.clone(),
6194                    template,
6195                    sell_all: *sell_all,
6196                };
6197                confirmed = Some(stop.summary());
6198                ed.confirm_stop(stop);
6199            }
6200        }
6201        if let Some(what) = confirmed {
6202            self.state.push_log(format!("Route: + {what}"));
6203        }
6204    }
6205
6206    /// Enter on the stop list: open the selected stop's sheet, prefilled.
6207    pub fn re_edit_selected_stop(&mut self) {
6208        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
6209        let Some(stop) = self
6210            .state
6211            .worker_route_editor
6212            .as_ref()
6213            .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
6214        else {
6215            self.state
6216                .push_log("Route: no stop selected — press a to add one".to_string());
6217            return;
6218        };
6219        if let Some(ed) = self.state.worker_route_editor.as_mut() {
6220            ed.begin_edit_selected();
6221        }
6222        match stop {
6223            WorkerRouteStop::Waypoint { .. } => {
6224                self.re_open_sheet(S::WaypointMenu { index: 0 });
6225            }
6226            WorkerRouteStop::HarvestNode { node_id } => {
6227                let nodes = self.re_node_candidates();
6228                let index = nodes.iter().position(|n| n.id == node_id).unwrap_or(0);
6229                if nodes.is_empty() {
6230                    self.re_cancel_edit();
6231                    self.state
6232                        .push_log("Route: no harvestable nodes visible to retarget".to_string());
6233                } else {
6234                    self.re_open_sheet(S::HarvestPicker { index });
6235                }
6236            }
6237            WorkerRouteStop::WithdrawFrom { container_id, .. } => {
6238                // Open the container picker so the player can retarget storage
6239                // (previously jumped straight to items on the same chest).
6240                let containers = self.re_container_candidates();
6241                if containers.is_empty() {
6242                    self.re_cancel_edit();
6243                    self.state
6244                        .push_log("Route: place a storage chest first".to_string());
6245                } else {
6246                    let index = containers
6247                        .iter()
6248                        .position(|c| c.id == container_id)
6249                        .unwrap_or(0);
6250                    self.re_open_sheet(S::WithdrawContainers { index });
6251                }
6252            }
6253            WorkerRouteStop::DepositAt { container_id, .. } => {
6254                let containers = self.re_container_candidates();
6255                if containers.is_empty() {
6256                    self.re_cancel_edit();
6257                    self.state
6258                        .push_log("Route: place a storage chest first".to_string());
6259                } else {
6260                    let index = containers
6261                        .iter()
6262                        .position(|c| c.id == container_id)
6263                        .unwrap_or(0);
6264                    self.re_open_sheet(S::DepositContainers { index });
6265                }
6266            }
6267            WorkerRouteStop::TradeWith { npc_id, .. } => {
6268                let npcs = self.re_npc_candidates();
6269                // Row 0 is "auto / nearest"; rows 1.. map to npcs.
6270                let index = npc_id
6271                    .as_ref()
6272                    .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
6273                    .unwrap_or(0);
6274                self.re_open_sheet(S::SellNpcs { index });
6275            }
6276            WorkerRouteStop::CraftAt { blueprint, .. } => {
6277                let bps = self.re_blueprint_ids();
6278                let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
6279                if bps.is_empty() {
6280                    self.re_cancel_edit();
6281                    self.state
6282                        .push_log("Route: no known blueprints to retarget".to_string());
6283                } else {
6284                    self.re_open_sheet(S::CraftBlueprint { index });
6285                }
6286            }
6287            WorkerRouteStop::RestIfNeeded => {
6288                self.re_cancel_edit();
6289                self.state
6290                    .push_log("Route: rest has no settings (change the bed with l)".to_string());
6291            }
6292            WorkerRouteStop::Wait { wait_ticks } => {
6293                self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
6294            }
6295        }
6296    }
6297
6298    fn re_cancel_edit(&mut self) {
6299        if let Some(ed) = self.state.worker_route_editor.as_mut() {
6300            ed.editing_index = None;
6301        }
6302    }
6303
6304    // ---- mouse clicks inside the overlay ------------------------------
6305
6306    pub fn worker_route_editor_ui_click(
6307        &mut self,
6308        click: crate::worker_route_editor::RouteEditorClick,
6309    ) {
6310        use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
6311        match click {
6312            RouteEditorClick::SelectStop(i) => {
6313                if let Some(ed) = self.state.worker_route_editor.as_mut() {
6314                    ed.sheet = S::Stops;
6315                    ed.select_stop(i);
6316                }
6317            }
6318            RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
6319            RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
6320            RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
6321        }
6322    }
6323
6324    /// Activate (Enter / click) a row of the current sheet.
6325    pub fn re_sheet_row_activate(&mut self, row: usize) {
6326        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
6327        let Some(sheet) = self
6328            .state
6329            .worker_route_editor
6330            .as_ref()
6331            .map(|ed| ed.sheet.clone())
6332        else {
6333            return;
6334        };
6335        match sheet {
6336            S::Stops => {
6337                if let Some(ed) = self.state.worker_route_editor.as_mut() {
6338                    ed.select_stop(row);
6339                }
6340            }
6341            S::AddMenu { .. } => match row {
6342                0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
6343                1 => {
6344                    if self.re_node_candidates().is_empty() {
6345                        self.state
6346                            .push_log("Route: no harvestable nodes visible in this region".to_string());
6347                    } else {
6348                        self.re_open_sheet(S::HarvestPicker { index: 0 });
6349                    }
6350                }
6351                2 | 3 => {
6352                    if self.re_container_candidates().is_empty() {
6353                        self.state
6354                            .push_log("Route: place a storage chest first".to_string());
6355                    } else if row == 2 {
6356                        self.re_open_sheet(S::WithdrawContainers { index: 0 });
6357                    } else {
6358                        self.re_open_sheet(S::DepositContainers { index: 0 });
6359                    }
6360                }
6361                4 => {
6362                    if self.re_template_candidates().is_empty() {
6363                        self.state.push_log(
6364                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
6365                                .to_string(),
6366                        );
6367                    } else {
6368                        self.re_open_sheet(S::SellNpcs { index: 0 });
6369                    }
6370                }
6371                5 => {
6372                    if self.re_blueprint_ids().is_empty() {
6373                        self.state.push_log(
6374                            "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
6375                                .to_string(),
6376                        );
6377                    } else {
6378                        self.re_open_sheet(S::CraftBlueprint { index: 0 });
6379                    }
6380                }
6381                6 => self.re_confirm_stop(
6382                    WorkerRouteStop::RestIfNeeded,
6383                    "rest if needed".into(),
6384                ),
6385                7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
6386                _ => {}
6387            },
6388            S::WaypointMenu { .. } => match row {
6389                0 => {
6390                    let (x, y, z) = self.state.player_position_with_z();
6391                    let stop = WorkerRouteStop::Waypoint { x, y, z };
6392                    self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
6393                }
6394                1 => {
6395                    self.re_open_sheet(S::WaypointMapPick);
6396                    self.state.push_log("Route: click the map to place the waypoint (Esc to finish)".to_string());
6397                }
6398                _ => {}
6399            },
6400            S::HarvestPicker { .. } => {
6401                let nodes = self.re_node_candidates();
6402                if let Some(n) = nodes.get(row) {
6403                    let stop = WorkerRouteStop::HarvestNode {
6404                        node_id: n.id.clone(),
6405                    };
6406                    let label = n.label.clone();
6407                    self.re_confirm_stop(stop, format!("harvest {label}"));
6408                }
6409            }
6410            S::WithdrawContainers { .. } => {
6411                let containers = self.re_container_candidates();
6412                if let Some(c) = containers.get(row) {
6413                    let id = c.id.clone();
6414                    self.re_open_withdraw_items(id);
6415                }
6416            }
6417            S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
6418            S::DepositContainers { .. } => {
6419                let containers = self.re_container_candidates();
6420                if let Some(c) = containers.get(row) {
6421                    let id = c.id.clone();
6422                    self.re_open_deposit_filter(id);
6423                }
6424            }
6425            S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
6426            S::SellNpcs { .. } => {
6427                let npcs = self.re_npc_candidates();
6428                let npc_id = if row == 0 {
6429                    None
6430                } else {
6431                    npcs.get(row - 1).map(|n| n.id.clone())
6432                };
6433                if row == 0 || npc_id.is_some() {
6434                    self.re_open_sell_item(npc_id);
6435                }
6436            }
6437            S::SellItem { .. } => self.re_sell_item_activate(row),
6438            S::CraftBlueprint { .. } => {
6439                let bps = self.re_blueprint_ids();
6440                if let Some(bp) = bps.get(row) {
6441                    let stop = WorkerRouteStop::CraftAt {
6442                        device: "hand".into(),
6443                        blueprint: bp.clone(),
6444                        qty: None,
6445                    };
6446                    self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
6447                }
6448            }
6449            S::WaitEntry { ticks } => {
6450                let stop = WorkerRouteStop::Wait {
6451                    wait_ticks: ticks,
6452                };
6453                self.re_confirm_stop(stop, format!("wait {ticks}t"));
6454            }
6455            S::BedPicker { .. } => {
6456                let beds = self.re_bed_candidates();
6457                if let Some((id, name)) = beds.get(row) {
6458                    let (id, name) = (id.clone(), name.clone());
6459                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
6460                        ed.lodging_container_id = Some(id.clone());
6461                        ed.sheet = S::Stops;
6462                    }
6463                    self.state
6464                        .push_log(format!("Route: rest bed set to {name}"));
6465                }
6466            }
6467            S::WaypointMapPick => {}
6468        }
6469    }
6470
6471    // ---- map clicks (sheet-scoped accelerators) ------------------------
6472
6473    /// Map click while the route editor is open. When a picker sheet is open
6474    /// the click feeds *that* sheet (chest clicks choose the withdraw/deposit
6475    /// container, NPC clicks the merchant, node clicks the harvest target);
6476    /// otherwise the click quick-adds the nearest target (context-aware).
6477    pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
6478        use crate::worker_route_editor as wre;
6479        use wre::RouteEditorSheet as S;
6480        if self.state.worker_route_editor.is_none() {
6481            return;
6482        }
6483        let sheet = self
6484            .state
6485            .worker_route_editor
6486            .as_ref()
6487            .map(|ed| ed.sheet.clone())
6488            .unwrap_or(S::Stops);
6489        match sheet {
6490            S::WaypointMapPick => {
6491                let (_, _, z) = self.state.player_position_with_z();
6492                let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
6493                self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
6494                // Stay in map-pick mode when adding (not editing) for fast pathing.
6495                let editing = self
6496                    .state
6497                    .worker_route_editor
6498                    .as_ref()
6499                    .is_some_and(|ed| ed.editing_index.is_some());
6500                if !editing {
6501                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
6502                        ed.sheet = S::WaypointMapPick;
6503                    }
6504                }
6505            }
6506            S::HarvestPicker { .. } => {
6507                if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
6508                    let stop = wre::WorkerRouteStop::HarvestNode {
6509                        node_id: node.id.clone(),
6510                    };
6511                    let label = node.label.clone();
6512                    self.re_confirm_stop(stop, format!("harvest {label}"));
6513                }
6514            }
6515            S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
6516                // Chest click picks (or switches) the withdraw source.
6517                if let Some(cid) = wre::pick_storage_container_at(
6518                    &self.state.placed_containers,
6519                    self.state.character_id,
6520                    x,
6521                    y,
6522                ) {
6523                    self.re_open_withdraw_items(cid);
6524                }
6525            }
6526            S::DepositContainers { .. } | S::DepositFilter { .. } => {
6527                if let Some(cid) = wre::pick_storage_container_at(
6528                    &self.state.placed_containers,
6529                    self.state.character_id,
6530                    x,
6531                    y,
6532                ) {
6533                    self.re_open_deposit_filter(cid);
6534                }
6535            }
6536            S::SellNpcs { .. } => {
6537                if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
6538                    self.re_open_sell_item(Some(npc_id));
6539                }
6540            }
6541            S::SellItem { .. } => {
6542                if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
6543                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
6544                        if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
6545                            *slot = Some(npc_id.clone());
6546                        }
6547                    }
6548                    self.state
6549                        .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
6550                }
6551            }
6552            // Stop list / other sheets: context-aware quick add.
6553            _ => self.worker_route_editor_quick_add_click(x, y),
6554        }
6555    }
6556
6557    /// Quick-add map click with no picker open: nearest of bed/chest/merchant/
6558    /// node wins; duplicates select the existing stop; a click on a merchant
6559    /// while a sell stop is selected pins it.
6560    fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
6561        use crate::worker_route_editor as wre;
6562        let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
6563            let dx = ax - bx;
6564            let dy = ay - by;
6565            (dx * dx + dy * dy).sqrt()
6566        };
6567
6568        // 1. Retarget the selected stop when the click names its target:
6569        //    sell stop → pin the merchant; withdraw stop → set the source chest.
6570        let selected_stop_kind = self
6571            .state
6572            .worker_route_editor
6573            .as_ref()
6574            .and_then(|ed| ed.stops.get(ed.selected_stop_index))
6575            .map(|s| match s {
6576                wre::WorkerRouteStop::TradeWith { .. } => 1,
6577                wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
6578                _ => 0,
6579            })
6580            .unwrap_or(0);
6581        if selected_stop_kind == 1 {
6582            if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
6583                if let Some(ed) = self.state.worker_route_editor.as_mut() {
6584                    ed.set_selected_trade_npc(npc_id.clone());
6585                }
6586                self.state
6587                    .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
6588                return;
6589            }
6590        }
6591        if selected_stop_kind == 2 {
6592            if let Some(cid) = wre::pick_storage_container_at(
6593                &self.state.placed_containers,
6594                self.state.character_id,
6595                x,
6596                y,
6597            ) {
6598                let name = self
6599                    .state
6600                    .placed_containers
6601                    .iter()
6602                    .find(|c| c.id == cid)
6603                    .map(|c| c.display_name.clone())
6604                    .unwrap_or_else(|| "container".into());
6605                if let Some(ed) = self.state.worker_route_editor.as_mut() {
6606                    ed.set_selected_withdraw_container(cid.clone());
6607                }
6608                self.state
6609                    .push_log(format!("Route: withdraw source → {name}"));
6610                return;
6611            }
6612        }
6613
6614        // 2. Nearest candidate of any kind wins. Category order is the
6615        //    tie-break for exact overlaps (bed over chest over merchant over node).
6616        enum Target {
6617            Bed(String),
6618            Container(String),
6619            Npc(String, String),
6620            Node(String, String),
6621        }
6622        let mut best: Option<(f32, u8, Target)> = None;
6623        let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
6624            let better = match best {
6625                None => true,
6626                Some((bd, brank, _)) => d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank),
6627            };
6628            if better {
6629                *best = Some((d, rank, t));
6630            }
6631        };
6632        if let Some(bed_id) = wre::pick_lodging_container_at(
6633            &self.state.placed_containers,
6634            self.state.character_id,
6635            x,
6636            y,
6637        ) {
6638            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
6639                // A bed that's already the rest bed doubles as a storage chest —
6640                // a second click on it means "deposit here", not "set bed again".
6641                let already_bed = self
6642                    .state
6643                    .worker_route_editor
6644                    .as_ref()
6645                    .is_some_and(|ed| ed.lodging_container_id.as_deref() == Some(bed_id.as_str()));
6646                if already_bed {
6647                    consider(dist(x, y, c.x, c.y), 1, Target::Container(bed_id), &mut best);
6648                } else {
6649                    consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
6650                }
6651            }
6652        }
6653        if let Some(cid) = wre::pick_storage_container_at(
6654            &self.state.placed_containers,
6655            self.state.character_id,
6656            x,
6657            y,
6658        ) {
6659            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
6660                consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
6661            }
6662        }
6663        if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
6664            if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
6665                consider(
6666                    dist(x, y, n.x, n.y),
6667                    2,
6668                    Target::Npc(npc_id, label),
6669                    &mut best,
6670                );
6671            }
6672        }
6673        if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
6674            let d = dist(x, y, node.x, node.y);
6675            consider(
6676                d,
6677                3,
6678                Target::Node(node.id.clone(), node.label.clone()),
6679                &mut best,
6680            );
6681        }
6682
6683        match best.map(|(_, _, t)| t) {
6684            Some(Target::Bed(bed_id)) => {
6685                let name = self
6686                    .state
6687                    .placed_containers
6688                    .iter()
6689                    .find(|c| c.id == bed_id)
6690                    .map(|c| c.display_name.clone())
6691                    .unwrap_or_else(|| "camp bed".into());
6692                if let Some(ed) = self.state.worker_route_editor.as_mut() {
6693                    ed.lodging_container_id = Some(bed_id.clone());
6694                }
6695                self.state
6696                    .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
6697            }
6698            Some(Target::Container(cid)) => {
6699                let name = self
6700                    .state
6701                    .placed_containers
6702                    .iter()
6703                    .find(|c| c.id == cid)
6704                    .map(|c| c.display_name.clone())
6705                    .unwrap_or_else(|| "container".into());
6706                let added = self
6707                    .state
6708                    .worker_route_editor
6709                    .as_mut()
6710                    .is_some_and(|ed| ed.append_deposit_at(&cid));
6711                if added {
6712                    self.state
6713                        .push_log(format!("Route: + deposit at {name} ({cid})"));
6714                } else {
6715                    self.state.push_log(format!(
6716                        "Route: {name} already in route — selected it (d to remove)"
6717                    ));
6718                }
6719            }
6720            Some(Target::Npc(npc_id, label)) => {
6721                // Quick-add sell: first storage template (the full sell flow
6722                // lives in the a → Sell sheet).
6723                let template = self.re_template_candidates().into_iter().next();
6724                let Some(template) = template else {
6725                    self.state.push_log("Route: no items in your storage to sell — stock a chest first".to_string());
6726                    return;
6727                };
6728                let added = self
6729                    .state
6730                    .worker_route_editor
6731                    .as_mut()
6732                    .is_some_and(|ed| ed.append_trade_with(template.clone(), Some(npc_id.clone()), true));
6733                if added {
6734                    self.state
6735                        .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
6736                } else {
6737                    self.state.push_log(format!(
6738                        "Route: {label} already sells {template} — selected it (d to remove)"
6739                    ));
6740                }
6741            }
6742            Some(Target::Node(id, label)) => {
6743                let added = self
6744                    .state
6745                    .worker_route_editor
6746                    .as_mut()
6747                    .is_some_and(|ed| ed.append_harvest_node(&id));
6748                if added {
6749                    self.state
6750                        .push_log(format!("Route: + harvest node {label} ({id})"));
6751                } else {
6752                    self.state.push_log(format!(
6753                        "Route: {label} already in route — selected it (d to remove)"
6754                    ));
6755                }
6756            }
6757            None => {}
6758        }
6759    }
6760
6761    pub fn worker_route_editor_select(&mut self, delta: i32) {
6762        let Some(ed) = self.state.worker_route_editor.as_mut() else {
6763            return;
6764        };
6765        if ed.stops.is_empty() {
6766            return;
6767        }
6768        let n = ed.stops.len() as i32;
6769        let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
6770        ed.selected_stop_index = next;
6771    }
6772
6773    pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
6774        let Some(ed) = self.state.worker_route_editor.as_mut() else {
6775            return;
6776        };
6777        if delta < 0 {
6778            ed.move_selected_up();
6779        } else if delta > 0 {
6780            ed.move_selected_down();
6781        }
6782    }
6783
6784    pub fn worker_route_editor_delete_selected(&mut self) {
6785        let removed = self
6786            .state
6787            .worker_route_editor
6788            .as_mut()
6789            .is_some_and(|ed| {
6790                let before = ed.stop_count();
6791                ed.remove_selected_stop();
6792                ed.stop_count() < before
6793            });
6794        if removed {
6795            self.state.push_log("Route: removed selected stop");
6796        }
6797    }
6798
6799    /// Clear all stops. Saving afterwards parks the worker in idle mode
6800    /// instead of leaving it in a broken job loop.
6801    pub fn worker_route_editor_clear_stops(&mut self) {
6802        let Some(ed) = self.state.worker_route_editor.as_mut() else {
6803            return;
6804        };
6805        if ed.stops.is_empty() {
6806            self.state.push_log("Route: already empty — s saves an idle worker".to_string());
6807            return;
6808        }
6809        ed.stops.clear();
6810        ed.selected_stop_index = 0;
6811        self.state
6812            .push_log("Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string());
6813    }
6814
6815    pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
6816        if self.state.pending_worker_job_ack.is_some() {
6817            anyhow::bail!("route save still pending — wait for server ack");
6818        }
6819        let Some(ed) = self.state.worker_route_editor.clone() else {
6820            anyhow::bail!("route editor not open");
6821        };
6822        // Empty route = stand down: park the worker in idle mode rather than
6823        // erroring out or leaving it marching a ghost loop.
6824        let (job_yaml, idle) = if ed.stops.is_empty() {
6825            (ed.build_idle_job_yaml(), true)
6826        } else {
6827            (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
6828        };
6829        let worker_id = ed.worker_instance_id.clone();
6830        let route_view = if idle {
6831            None
6832        } else {
6833            Some(ed.to_route_view())
6834        };
6835        let mode = if idle {
6836            flatland_protocol::WorkerModeView::Idle
6837        } else {
6838            flatland_protocol::WorkerModeView::JobLoop
6839        };
6840        let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
6841            .state
6842            .hired_workers
6843            .iter()
6844            .find(|w| w.instance_id == worker_id)
6845            .map(|w| {
6846                (
6847                    w.route.clone(),
6848                    w.mode,
6849                    w.step_label.clone(),
6850                    w.last_error.clone(),
6851                )
6852            })
6853            .unwrap_or((
6854                None,
6855                flatland_protocol::WorkerModeView::Idle,
6856                String::new(),
6857                None,
6858            ));
6859        self.seq += 1;
6860        let seq = self.seq;
6861        self.session
6862            .submit_intent(Intent::SetWorkerJob {
6863                entity_id: self.state.entity_id,
6864                worker_instance_id: worker_id.clone(),
6865                job_yaml,
6866                seq,
6867            })
6868            .await?;
6869        self.state.intents_sent += 1;
6870        if let Some(w) = self
6871            .state
6872            .hired_workers
6873            .iter_mut()
6874            .find(|w| w.instance_id == worker_id)
6875        {
6876            w.route = route_view;
6877            w.mode = mode;
6878            w.last_error = None;
6879            if idle {
6880                w.step_label.clear();
6881            }
6882        }
6883        self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
6884            seq,
6885            worker_instance_id: worker_id,
6886            worker_label: ed.worker_label.clone(),
6887            idle,
6888            stop_count: ed.stops.len(),
6889            prev_route,
6890            prev_mode,
6891            prev_step_label,
6892            prev_last_error,
6893        });
6894        self.state.push_log(format!(
6895            "Route: saving for {}… (waiting for server)",
6896            ed.worker_label
6897        ));
6898        // Keep editor open until IntentAck; reject Interaction reverts optimistic state.
6899        Ok(())
6900    }
6901    pub fn quest_menu_move(&mut self, delta: i32) {
6902        let n = self.state.active_quest_entries().len();
6903        if n == 0 {
6904            return;
6905        }
6906        let idx = self.state.quest_menu_index as i32;
6907        self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
6908    }
6909
6910    pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
6911        let Some(offer) = self.state.pending_quest_offer.clone() else {
6912            anyhow::bail!("no quest offer");
6913        };
6914        self.seq += 1;
6915        let seq = self.seq;
6916        self.session
6917            .submit_intent(Intent::AcceptQuest {
6918                entity_id: self.state.entity_id,
6919                quest_id: offer.quest_id,
6920                seq,
6921            })
6922            .await?;
6923        self.state.intents_sent += 1;
6924        Ok(())
6925    }
6926
6927    pub fn quest_offer_decline(&mut self) {
6928        self.state.show_quest_offer = false;
6929        self.state.pending_quest_offer = None;
6930        if !self.state.show_npc_chat
6931            && !self.state.show_shop_menu
6932            && self.state.npc_verb_target.is_some()
6933        {
6934            self.state.show_npc_verb_menu = true;
6935        }
6936    }
6937
6938    pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
6939        if !self.state.show_quest_menu {
6940            return Ok(());
6941        }
6942        let active: Vec<_> = self
6943            .state
6944            .active_quest_entries()
6945            .into_iter()
6946            .cloned()
6947            .collect();
6948        let Some(entry) = active.get(self.state.quest_menu_index) else {
6949            return Ok(());
6950        };
6951        if self.state.quest_withdraw_confirm {
6952            if !entry.can_withdraw {
6953                anyhow::bail!("quest cannot be withdrawn");
6954            }
6955            self.seq += 1;
6956            let seq = self.seq;
6957            self.session
6958                .submit_intent(Intent::WithdrawQuest {
6959                    entity_id: self.state.entity_id,
6960                    quest_id: entry.quest_id.clone(),
6961                    seq,
6962                })
6963                .await?;
6964            self.state.intents_sent += 1;
6965            self.state.quest_withdraw_confirm = false;
6966            return Ok(());
6967        }
6968        self.seq += 1;
6969        let seq = self.seq;
6970        self.session
6971            .submit_intent(Intent::TrackQuest {
6972                entity_id: self.state.entity_id,
6973                quest_id: entry.quest_id.clone(),
6974                seq,
6975            })
6976            .await?;
6977        self.state.intents_sent += 1;
6978        Ok(())
6979    }
6980
6981    pub fn quest_request_withdraw(&mut self) {
6982        if self.state.show_quest_menu {
6983            self.state.quest_withdraw_confirm = true;
6984        }
6985    }
6986
6987    pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
6988        if !self.state.is_alive() {
6989            anyhow::bail!("you are dead");
6990        }
6991        let Some(catalog) = self.state.shop_catalog.clone() else {
6992            anyhow::bail!("no shop open");
6993        };
6994        self.seq += 1;
6995        let seq = self.seq;
6996        match self.state.shop_tab {
6997            ShopTab::Buy => {
6998                let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
6999                    anyhow::bail!("nothing selected");
7000                };
7001                if offer.already_owned {
7002                    anyhow::bail!("already owned");
7003                }
7004                self.session
7005                    .submit_intent(Intent::ShopBuy {
7006                        entity_id: self.state.entity_id,
7007                        npc_id: catalog.npc_id.clone(),
7008                        offer_id: offer.offer_id.clone(),
7009                        quantity: self.state.shop_quantity,
7010                        seq,
7011                    })
7012                    .await?;
7013            }
7014            ShopTab::Sell => {
7015                let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
7016                    anyhow::bail!("nothing to sell");
7017                };
7018                if line.quantity == 0 {
7019                    anyhow::bail!("you have no {}", line.label);
7020                }
7021                let quantity = self.state.shop_quantity.min(line.quantity).max(1);
7022                self.session
7023                    .submit_intent(Intent::ShopSell {
7024                        entity_id: self.state.entity_id,
7025                        npc_id: catalog.npc_id.clone(),
7026                        template_id: line.template_id.clone(),
7027                        quantity,
7028                        seq,
7029                    })
7030                    .await?;
7031            }
7032        }
7033        self.state.intents_sent += 1;
7034        Ok(())
7035    }
7036
7037    pub fn craft_menu_move(&mut self, delta: i32) {
7038        let n = self.state.blueprints.len();
7039        if n == 0 {
7040            return;
7041        }
7042        let idx = self.state.craft_menu_index as i32;
7043        let next = (idx + delta).rem_euclid(n as i32);
7044        self.state.craft_menu_index = next as usize;
7045        self.state.clamp_craft_batch_quantity();
7046    }
7047
7048    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
7049        self.state.craft_batch_adjust_quantity(delta);
7050    }
7051
7052    pub fn craft_batch_set_max(&mut self) {
7053        self.state.craft_batch_set_max();
7054    }
7055
7056    pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
7057        let Some(blueprint) = self
7058            .state
7059            .blueprints
7060            .get(self.state.craft_menu_index)
7061            .cloned()
7062        else {
7063            anyhow::bail!("no blueprints known");
7064        };
7065        if !self.state.can_craft_blueprint(&blueprint) {
7066            let hint = self
7067                .state
7068                .craft_missing_hint(&blueprint)
7069                .unwrap_or_else(|| "missing materials".into());
7070            anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
7071        }
7072        let count = self.state.craft_batch_quantity;
7073        let max = self.state.max_craft_batches(&blueprint);
7074        if max == 0 {
7075            anyhow::bail!("cannot craft {}", blueprint.label);
7076        }
7077        let batches = count.min(max);
7078        self.craft(&blueprint.id, Some(batches)).await?;
7079        self.state.show_craft_menu = false;
7080        Ok(())
7081    }
7082
7083    pub async fn move_by(
7084        &mut self,
7085        forward: f32,
7086        strafe: f32,
7087        vertical: f32,
7088        sprint: bool,
7089    ) -> anyhow::Result<()> {
7090        if !self.state.is_alive() {
7091            anyhow::bail!("you are dead");
7092        }
7093        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
7094            self.last_move_forward = forward;
7095            self.last_move_strafe = strafe;
7096        }
7097        self.seq += 1;
7098        self.session
7099            .submit_intent(Intent::Move {
7100                entity_id: self.state.entity_id,
7101                forward,
7102                strafe,
7103                vertical,
7104                sprint,
7105                seq: self.seq,
7106            })
7107            .await?;
7108        self.state.intents_sent += 1;
7109        Ok(())
7110    }
7111
7112    pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
7113        if !self.state.connected {
7114            crate::harvest_trace!("harvest_nearest rejected: not connected");
7115            anyhow::bail!("not connected");
7116        }
7117        if !self.state.is_alive() {
7118            crate::harvest_trace!("harvest_nearest rejected: player dead");
7119            anyhow::bail!("you are dead");
7120        }
7121        if self.state.harvest_in_progress {
7122            if self.state.harvest_state_stale() {
7123                self.state.clear_harvest_state();
7124            } else {
7125                anyhow::bail!("already harvesting");
7126            }
7127        }
7128        let (px, py) = self
7129            .state
7130            .player
7131            .as_ref()
7132            .map(|p| (p.transform.position.x, p.transform.position.y))
7133            .unwrap_or((0.0, 0.0));
7134
7135        let available = self
7136            .state
7137            .resource_nodes
7138            .iter()
7139            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
7140            .count();
7141        let node_id = self
7142            .state
7143            .resource_nodes
7144            .iter()
7145            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
7146            .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
7147            .min_by(|a, b| {
7148                let da = distance(px, py, a.x, a.y);
7149                let db = distance(px, py, b.x, b.y);
7150                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
7151            })
7152            .map(|n| n.id.clone());
7153
7154        let Some(node_id) = node_id else {
7155            let has_loot = self
7156                .state
7157                .ground_drops
7158                .iter()
7159                .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
7160            if has_loot {
7161                return self.pickup_nearest().await;
7162            }
7163            anyhow::bail!(
7164                "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
7165            );
7166        };
7167
7168        self.seq += 1;
7169        let seq = self.seq;
7170        crate::harvest_trace!(
7171            entity_id = self.state.entity_id,
7172            node_id = %node_id,
7173            seq,
7174            px,
7175            py,
7176            available_nodes = available,
7177            "submitting harvest intent"
7178        );
7179        self.session
7180            .submit_intent(Intent::Harvest {
7181                entity_id: self.state.entity_id,
7182                node_id,
7183                seq,
7184            })
7185            .await?;
7186        self.state.intents_sent += 1;
7187        self.state.harvest_in_progress = true;
7188        self.state.harvest_started_at = Some(Instant::now());
7189        self.state.push_log("Harvesting…");
7190        crate::harvest_trace!(
7191            entity_id = self.state.entity_id,
7192            seq,
7193            "harvest intent queued to session"
7194        );
7195        Ok(())
7196    }
7197
7198    pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
7199        if !self.state.is_alive() {
7200            anyhow::bail!("you are dead");
7201        }
7202        let blueprint_id = self
7203            .state
7204            .blueprints
7205            .iter()
7206            .find(|bp| self.state.can_craft_blueprint(bp))
7207            .map(|bp| bp.id.clone())
7208            .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
7209        self.craft(&blueprint_id, None).await
7210    }
7211
7212    pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
7213        if !self.state.is_alive() {
7214            anyhow::bail!("you are dead");
7215        }
7216        self.seq += 1;
7217        self.session
7218            .submit_intent(Intent::Craft {
7219                entity_id: self.state.entity_id,
7220                blueprint_id: blueprint_id.to_string(),
7221                count,
7222                seq: self.seq,
7223            })
7224            .await?;
7225        self.state.intents_sent += 1;
7226        let (label, batches) = self
7227            .state
7228            .blueprints
7229            .iter()
7230            .find(|b| b.id == blueprint_id)
7231            .map(|b| {
7232                let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
7233                (b.label.as_str(), n)
7234            })
7235            .unwrap_or((blueprint_id, count.unwrap_or(1)));
7236        self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
7237        Ok(())
7238    }
7239
7240    pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
7241        if !self.state.is_alive() {
7242            anyhow::bail!("you are dead");
7243        }
7244        let target_id = match self.state.nearest_interact_target() {
7245            Some(id) => id,
7246            None => {
7247                anyhow::bail!("nothing to interact with nearby");
7248            }
7249        };
7250        if self.state.npcs.iter().any(|n| n.id == target_id) {
7251            self.state.show_npc_verb_menu = true;
7252            self.state.npc_verb_target = Some(target_id);
7253            self.state.npc_verb_index = 0;
7254            return Ok(());
7255        }
7256        self.seq += 1;
7257        self.session
7258            .submit_intent(Intent::Interact {
7259                entity_id: self.state.entity_id,
7260                target_id: target_id.clone(),
7261                seq: self.seq,
7262            })
7263            .await?;
7264        self.state.intents_sent += 1;
7265        Ok(())
7266    }
7267
7268    /// Context-sensitive world use: interact → pickup loot/chest → harvest/butcher.
7269    pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
7270        if !self.state.is_alive() {
7271            anyhow::bail!("you are dead");
7272        }
7273        if self.state.nearest_interact_target().is_some() {
7274            return self.interact_nearest().await;
7275        }
7276        // Prefer a clear "move closer" when a board is visible but out of reach,
7277        // instead of silently falling through to harvest.
7278        if let Some((label, dist)) = self.state.nearest_quest_board() {
7279            if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
7280                anyhow::bail!(
7281                    "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
7282                );
7283            }
7284        }
7285        let (px, py) = self.state.player_position();
7286        let has_loot = self
7287            .state
7288            .ground_drops
7289            .iter()
7290            .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
7291        if has_loot {
7292            return self.pickup_nearest().await;
7293        }
7294        if self
7295            .state
7296            .placed_containers
7297            .iter()
7298            .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
7299        {
7300            return self.pickup_nearest_container().await;
7301        }
7302        match self.harvest_nearest().await {
7303            Ok(()) => Ok(()),
7304            Err(err) => {
7305                let msg = err.to_string();
7306                if msg.contains("no harvestable")
7307                    || msg.contains("press p")
7308                    || msg.contains("press f")
7309                {
7310                    anyhow::bail!(
7311                        "nothing to use nearby — stand by an NPC/door, loot (*), chest, or resource"
7312                    );
7313                }
7314                Err(err)
7315            }
7316        }
7317    }
7318
7319    /// Cast a hotbar-bound ability (`1`–`9`). Heals prefer T2/self; others prefer T1.
7320    pub async fn cast_hotbar_ability(&mut self, ability_id: &str) -> anyhow::Result<()> {
7321        if !self.state.is_alive() {
7322            anyhow::bail!("you are dead");
7323        }
7324        let target = if ability_id == "heal_touch" {
7325            Some(
7326                self.state
7327                    .target_for_slot(2)
7328                    .unwrap_or(self.state.entity_id),
7329            )
7330        } else {
7331            self.state
7332                .target_for_slot(1)
7333                .or_else(|| self.state.target_for_slot(2))
7334        };
7335        let Some(target_id) = target else {
7336            anyhow::bail!("no target — Tab to select, then press the hotbar key");
7337        };
7338        self.cast_ability(ability_id, Some(target_id)).await
7339    }
7340
7341    pub fn npc_verb_options(&self) -> Vec<&'static str> {
7342        self.state.npc_verb_options()
7343    }
7344
7345    pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
7346        let Some(npc_id) = self.state.npc_verb_target.clone() else {
7347            return Ok(());
7348        };
7349        let options = self.npc_verb_options();
7350        let choice = options
7351            .get(self.state.npc_verb_index)
7352            .copied()
7353            .unwrap_or("Talk");
7354        self.seq += 1;
7355        match choice {
7356            "Trade" => {
7357                self.session
7358                    .submit_intent(Intent::Interact {
7359                        entity_id: self.state.entity_id,
7360                        target_id: npc_id,
7361                        seq: self.seq,
7362                    })
7363                    .await?;
7364            }
7365            _ => {
7366                self.session
7367                    .submit_intent(Intent::NpcTalkOpen {
7368                        entity_id: self.state.entity_id,
7369                        npc_id,
7370                        seq: self.seq,
7371                    })
7372                    .await?;
7373            }
7374        }
7375        self.state.intents_sent += 1;
7376        Ok(())
7377    }
7378
7379    pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
7380        let Some(chat) = self.state.npc_chat.clone() else {
7381            return Ok(());
7382        };
7383        let message = chat.input.trim().to_string();
7384        if message.is_empty() || chat.pending {
7385            return Ok(());
7386        }
7387        if let Some(c) = self.state.npc_chat.as_mut() {
7388            c.lines.push(format!("You: {message}"));
7389            c.input.clear();
7390            c.pending = true;
7391        }
7392        self.seq += 1;
7393        self.session
7394            .submit_intent(Intent::NpcTalkSay {
7395                entity_id: self.state.entity_id,
7396                npc_id: chat.npc_id,
7397                message,
7398                seq: self.seq,
7399            })
7400            .await?;
7401        self.state.intents_sent += 1;
7402        Ok(())
7403    }
7404
7405    pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
7406        let return_to_verbs = self.state.npc_verb_target.is_some();
7407        let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
7408            self.state.show_npc_chat = false;
7409            if return_to_verbs {
7410                self.state.show_npc_verb_menu = true;
7411            }
7412            return Ok(());
7413        };
7414        self.seq += 1;
7415        self.session
7416            .submit_intent(Intent::NpcTalkClose {
7417                entity_id: self.state.entity_id,
7418                npc_id,
7419                seq: self.seq,
7420            })
7421            .await?;
7422        self.state.intents_sent += 1;
7423        self.state.show_npc_chat = false;
7424        self.state.npc_chat = None;
7425        if return_to_verbs {
7426            self.state.show_npc_verb_menu = true;
7427        }
7428        Ok(())
7429    }
7430
7431    /// Esc/back inside Talk, Trade, quest-offer, or the verb menu — pop one layer, not the whole session.
7432    pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
7433        if self.state.show_quest_offer
7434            && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
7435        {
7436            self.quest_offer_decline();
7437            return Ok(());
7438        }
7439        if self.state.show_npc_chat {
7440            return self.npc_talk_close().await;
7441        }
7442        if self.state.show_shop_menu {
7443            self.back_from_shop_menu();
7444            return Ok(());
7445        }
7446        if self.state.show_npc_verb_menu {
7447            self.state.show_npc_verb_menu = false;
7448            self.state.npc_verb_target = None;
7449        }
7450        Ok(())
7451    }
7452
7453    pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
7454        self.seq += 1;
7455        self.session
7456            .submit_intent(Intent::TestDamage {
7457                entity_id: self.state.entity_id,
7458                amount,
7459                seq: self.seq,
7460            })
7461            .await?;
7462        self.state.intents_sent += 1;
7463        Ok(())
7464    }
7465
7466    pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
7467        self.cycle_combat_target_slot(1, reverse).await
7468    }
7469
7470    pub async fn cycle_combat_target_slot(
7471        &mut self,
7472        slot_index: u8,
7473        reverse: bool,
7474    ) -> anyhow::Result<()> {
7475        if !self.state.is_alive() {
7476            anyhow::bail!("you are dead");
7477        }
7478        let candidates = self.state.candidates_for_slot(slot_index);
7479        if candidates.is_empty() {
7480            anyhow::bail!("no targets nearby");
7481        }
7482        let current = self.state.target_for_slot(slot_index);
7483        let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
7484        let next_idx = match idx {
7485            None => 0,
7486            Some(i) if reverse => {
7487                if i == 0 {
7488                    candidates.len() - 1
7489                } else {
7490                    i - 1
7491                }
7492            }
7493            Some(i) => (i + 1) % candidates.len(),
7494        };
7495        if idx == Some(next_idx) && candidates.len() == 1 {
7496            self.clear_combat_target_slot(slot_index).await?;
7497            return Ok(());
7498        }
7499        let (target_id, label) = candidates[next_idx].clone();
7500        self.set_combat_target_slot(slot_index, target_id, &label)
7501            .await
7502    }
7503
7504    pub async fn set_combat_target_slot(
7505        &mut self,
7506        slot_index: u8,
7507        target_id: EntityId,
7508        label: &str,
7509    ) -> anyhow::Result<()> {
7510        if !self.state.is_alive() {
7511            anyhow::bail!("you are dead");
7512        }
7513        self.seq += 1;
7514        self.session
7515            .submit_intent(Intent::SetTargetSlot {
7516                entity_id: self.state.entity_id,
7517                slot_index,
7518                target_id,
7519                seq: self.seq,
7520            })
7521            .await?;
7522        self.state.intents_sent += 1;
7523        if slot_index == 1 {
7524            self.state.combat_target = Some(target_id);
7525            self.state.combat_target_label = Some(label.to_string());
7526        }
7527        self.state
7528            .push_log(format!("Slot {slot_index} target: {label}"));
7529        Ok(())
7530    }
7531
7532    pub async fn set_combat_target(
7533        &mut self,
7534        target_id: EntityId,
7535        label: &str,
7536    ) -> anyhow::Result<()> {
7537        self.set_combat_target_slot(1, target_id, label).await
7538    }
7539
7540    pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
7541        if slot_index == 1 && self.state.combat_target.is_none() {
7542            return Ok(());
7543        }
7544        self.seq += 1;
7545        self.session
7546            .submit_intent(Intent::ClearTargetSlot {
7547                entity_id: self.state.entity_id,
7548                slot_index,
7549                seq: self.seq,
7550            })
7551            .await?;
7552        if slot_index == 1 {
7553            self.state.combat_target = None;
7554            self.state.combat_target_label = None;
7555        }
7556        self.state.intents_sent += 1;
7557        self.state
7558            .push_log(format!("Slot {slot_index} target cleared"));
7559        Ok(())
7560    }
7561
7562    pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
7563        self.clear_combat_target_slot(1).await
7564    }
7565
7566    pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
7567        if !self.state.is_alive() {
7568            anyhow::bail!("you are dead");
7569        }
7570        self.seq += 1;
7571        self.session
7572            .submit_intent(Intent::AdvanceRotation {
7573                entity_id: self.state.entity_id,
7574                slot_index,
7575                seq: self.seq,
7576            })
7577            .await?;
7578        self.state.intents_sent += 1;
7579        Ok(())
7580    }
7581
7582    pub async fn assign_slot_preset(
7583        &mut self,
7584        slot_index: u8,
7585        preset_id: &str,
7586    ) -> anyhow::Result<()> {
7587        if !self.state.is_alive() {
7588            anyhow::bail!("you are dead");
7589        }
7590        self.seq += 1;
7591        self.session
7592            .submit_intent(Intent::AssignSlotPreset {
7593                entity_id: self.state.entity_id,
7594                slot_index,
7595                preset_id: preset_id.to_string(),
7596                seq: self.seq,
7597            })
7598            .await?;
7599        self.state.intents_sent += 1;
7600        if let Some(slot) = self
7601            .state
7602            .combat_slots
7603            .iter_mut()
7604            .find(|s| s.slot_index == slot_index)
7605        {
7606            slot.preset_id = Some(preset_id.to_string());
7607            if let Some(preset) = self
7608                .state
7609                .rotation_presets
7610                .iter()
7611                .find(|p| p.id == preset_id)
7612            {
7613                slot.preset_label = Some(preset.label.clone());
7614                slot.rotation = preset.abilities.clone();
7615                slot.rotation_index = 0;
7616            }
7617        }
7618        self.state
7619            .push_log(format!("T{slot_index} loadout → {preset_id}"));
7620        Ok(())
7621    }
7622
7623    pub async fn cast_ability(
7624        &mut self,
7625        ability_id: &str,
7626        target_id: Option<EntityId>,
7627    ) -> anyhow::Result<()> {
7628        if !self.state.is_alive() {
7629            anyhow::bail!("you are dead");
7630        }
7631        let target_id = target_id
7632            .or_else(|| self.state.target_for_slot(2))
7633            .or_else(|| self.state.target_for_slot(1))
7634            .unwrap_or(self.state.entity_id);
7635        self.seq += 1;
7636        self.session
7637            .submit_intent(Intent::Cast {
7638                entity_id: self.state.entity_id,
7639                ability_id: ability_id.to_string(),
7640                target_id,
7641                seq: self.seq,
7642            })
7643            .await?;
7644        self.state.intents_sent += 1;
7645        self.state
7646            .push_log(format!("Cast {ability_id} → {target_id}"));
7647        Ok(())
7648    }
7649
7650    pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
7651        self.seq += 1;
7652        self.session
7653            .submit_intent(Intent::UpsertRotationPreset {
7654                entity_id: self.state.entity_id,
7655                preset: preset.clone(),
7656                seq: self.seq,
7657            })
7658            .await?;
7659        self.state.intents_sent += 1;
7660        if let Some(existing) = self
7661            .state
7662            .rotation_presets
7663            .iter_mut()
7664            .find(|p| p.id == preset.id)
7665        {
7666            *existing = preset.clone();
7667        } else {
7668            self.state.rotation_presets.push(preset.clone());
7669        }
7670        for slot in &mut self.state.combat_slots {
7671            if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
7672                slot.preset_label = Some(preset.label.clone());
7673                slot.rotation = preset.abilities.clone();
7674            }
7675        }
7676        self.state
7677            .push_log(format!("Saved rotation: {}", preset.label));
7678        Ok(())
7679    }
7680
7681    pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
7682        self.seq += 1;
7683        self.session
7684            .submit_intent(Intent::DeleteRotationPreset {
7685                entity_id: self.state.entity_id,
7686                preset_id: preset_id.to_string(),
7687                seq: self.seq,
7688            })
7689            .await?;
7690        self.state.intents_sent += 1;
7691        self.state.rotation_presets.retain(|p| p.id != preset_id);
7692        for slot in &mut self.state.combat_slots {
7693            if slot.preset_id.as_deref() == Some(preset_id) {
7694                slot.preset_id = None;
7695                slot.preset_label = None;
7696                slot.rotation.clear();
7697                slot.rotation_index = 0;
7698            }
7699        }
7700        self.state
7701            .push_log(format!("Deleted rotation: {preset_id}"));
7702        Ok(())
7703    }
7704
7705    pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
7706        if !self.state.is_alive() {
7707            anyhow::bail!("you are dead");
7708        }
7709        let enabled = !self
7710            .state
7711            .combat_slots
7712            .iter()
7713            .find(|s| s.slot_index == slot_index)
7714            .map(|s| s.auto_enabled)
7715            .unwrap_or(false);
7716        self.seq += 1;
7717        self.session
7718            .submit_intent(Intent::SetAutoAttack {
7719                entity_id: self.state.entity_id,
7720                slot_index,
7721                enabled,
7722                seq: self.seq,
7723            })
7724            .await?;
7725        if slot_index == 1 {
7726            self.state.auto_attack = enabled;
7727        }
7728        self.state.intents_sent += 1;
7729        self.state.push_log(format!(
7730            "T{slot_index} auto {}",
7731            if enabled { "ON" } else { "OFF" }
7732        ));
7733        Ok(())
7734    }
7735
7736    pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
7737        if !self.state.connected {
7738            anyhow::bail!("not connected");
7739        }
7740        if !self.state.is_alive() {
7741            anyhow::bail!("you are dead");
7742        }
7743        let (px, py) = self.state.player_position();
7744        if self
7745            .state
7746            .ground_drops
7747            .iter()
7748            .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
7749        {
7750            anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
7751        }
7752        self.seq += 1;
7753        self.session
7754            .submit_intent(Intent::Pickup {
7755                entity_id: self.state.entity_id,
7756                drop_id: None,
7757                seq: self.seq,
7758            })
7759            .await?;
7760        self.state.intents_sent += 1;
7761        Ok(())
7762    }
7763
7764    pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
7765        self.toggle_auto_attack_slot(1).await
7766    }
7767
7768    pub async fn dodge(&mut self) -> anyhow::Result<()> {
7769        if !self.state.is_alive() {
7770            anyhow::bail!("you are dead");
7771        }
7772        self.seq += 1;
7773        self.session
7774            .submit_intent(Intent::Dodge {
7775                entity_id: self.state.entity_id,
7776                seq: self.seq,
7777            })
7778            .await?;
7779        self.state.intents_sent += 1;
7780        self.state.push_log("Dodge!");
7781        Ok(())
7782    }
7783
7784    pub async fn lunge(&mut self) -> anyhow::Result<()> {
7785        if !self.state.is_alive() {
7786            anyhow::bail!("you are dead");
7787        }
7788        let (forward, strafe) = self.last_move_axes();
7789        self.seq += 1;
7790        self.session
7791            .submit_intent(Intent::Lunge {
7792                entity_id: self.state.entity_id,
7793                forward,
7794                strafe,
7795                seq: self.seq,
7796            })
7797            .await?;
7798        self.state.intents_sent += 1;
7799        self.state.push_log("Lunge!");
7800        Ok(())
7801    }
7802
7803    pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
7804        if !self.state.is_alive() {
7805            anyhow::bail!("you are dead");
7806        }
7807        self.seq += 1;
7808        self.session
7809            .submit_intent(Intent::DirectionalJump {
7810                entity_id: self.state.entity_id,
7811                forward,
7812                strafe,
7813                seq: self.seq,
7814            })
7815            .await?;
7816        self.state.intents_sent += 1;
7817        self.state.push_log("Jump!");
7818        Ok(())
7819    }
7820
7821    /// Remembered WASD axes for lunge when not currently moving.
7822    pub fn last_move_axes(&self) -> (f32, f32) {
7823        (self.last_move_forward, self.last_move_strafe)
7824    }
7825
7826    pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
7827        if !self.state.is_alive() {
7828            anyhow::bail!("you are dead");
7829        }
7830        self.seq += 1;
7831        self.session
7832            .submit_intent(Intent::Block {
7833                entity_id: self.state.entity_id,
7834                enabled,
7835                seq: self.seq,
7836            })
7837            .await?;
7838        self.state.intents_sent += 1;
7839        if enabled {
7840            self.state.push_log("Blocking");
7841        }
7842        Ok(())
7843    }
7844
7845    pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
7846        if !self.state.is_alive() {
7847            anyhow::bail!("you are dead");
7848        }
7849        self.seq += 1;
7850        self.session
7851            .submit_intent(Intent::EquipMainhand {
7852                entity_id: self.state.entity_id,
7853                template_id,
7854                seq: self.seq,
7855            })
7856            .await?;
7857        self.state.intents_sent += 1;
7858        Ok(())
7859    }
7860
7861    /// Equip paperdoll: activate selected slot (unequip filled, or equip first matching candidate).
7862    pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
7863        let idx = self.state.equip_menu_index;
7864        let slots = equip_paperdoll_rows(&self.state);
7865        let Some(row) = slots.get(idx) else {
7866            return Ok(());
7867        };
7868        match row {
7869            EquipPaperdollRow::Body { slot, filled } => {
7870                if *filled {
7871                    self.equip_worn(*slot, None).await
7872                } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
7873                    self.equip_worn(*slot, Some(inst)).await
7874                } else {
7875                    self.state.push_log(format!("No item for {}", body_slot_label(*slot)));
7876                    Ok(())
7877                }
7878            }
7879            EquipPaperdollRow::Mainhand { filled } => {
7880                if *filled {
7881                    self.unequip_mainhand().await
7882                } else if let Some(tid) = first_inventory_weapon(&self.state) {
7883                    self.equip_mainhand(Some(tid)).await
7884                } else {
7885                    self.state.push_log("No weapon in inventory".to_string());
7886                    Ok(())
7887                }
7888            }
7889            EquipPaperdollRow::Offhand { filled, locked } => {
7890                if *locked {
7891                    self.state
7892                        .push_log("Offhand locked — two-handed weapon equipped".to_string());
7893                    Ok(())
7894                } else if *filled {
7895                    self.unequip_offhand().await
7896                } else if let Some(tid) = first_inventory_offhand(&self.state) {
7897                    self.equip_offhand(Some(tid)).await
7898                } else {
7899                    self.state
7900                        .push_log("No offhand item in inventory".to_string());
7901                    Ok(())
7902                }
7903            }
7904        }
7905    }
7906
7907    pub async fn say(
7908        &mut self,
7909        channel: flatland_protocol::ChatChannel,
7910        text: &str,
7911    ) -> anyhow::Result<()> {
7912        self.seq += 1;
7913        self.session
7914            .submit_intent(Intent::Say {
7915                entity_id: self.state.entity_id,
7916                channel,
7917                text: text.to_string(),
7918                seq: self.seq,
7919            })
7920            .await?;
7921        self.state.intents_sent += 1;
7922        Ok(())
7923    }
7924
7925    pub async fn stop(&mut self) -> anyhow::Result<()> {
7926        self.seq += 1;
7927        self.session
7928            .submit_intent(Intent::Stop {
7929                entity_id: self.state.entity_id,
7930                seq: self.seq,
7931            })
7932            .await?;
7933        self.state.intents_sent += 1;
7934        Ok(())
7935    }
7936
7937    pub fn disconnect(&self) {
7938        self.session.disconnect();
7939    }
7940}
7941
7942fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
7943    let dx = ax - bx;
7944    let dy = ay - by;
7945    (dx * dx + dy * dy).sqrt()
7946}
7947
7948#[cfg(test)]
7949mod tests {
7950    use std::collections::BTreeMap;
7951
7952    use super::*;
7953    use flatland_protocol::{
7954        BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
7955    };
7956
7957    fn sample_state() -> GameState {
7958        let mut state = GameState {
7959            session_id: 1,
7960            entity_id: 1,
7961            character_id: None,
7962            tick: 0,
7963            chunk_rev: 0,
7964            content_rev: 0,
7965            publish_rev: 0,
7966            entities: vec![EntityState {
7967                id: 1,
7968                label: "You".into(),
7969                transform: Transform {
7970                    position: WorldCoord::surface(128.0, 128.0),
7971                    yaw: 0.0,
7972                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
7973                },
7974                vitals: None,
7975                attributes: None,
7976                skills: None,
7977                inside_building: None,
7978                tile_id: None,
7979                presentation_state: None,
7980                sprite_mode: None,
7981                progression_xp: None,
7982            }],
7983            player: None,
7984            resource_nodes: vec![ResourceNodeView {
7985                id: "oak-1".into(),
7986                label: "Oak".into(),
7987                x: 130.0,
7988                y: 128.0,
7989                z: 0.0,
7990                item_template: "oak_log".into(),
7991                state: ResourceNodeState::Available,
7992                blocking: true,
7993                blocking_radius_m: 0.8,
7994                tile_id: None,
7995                sprite_mode: None,
7996                presentation_state: None,
7997            }],
7998            ground_drops: vec![],
7999            placed_containers: vec![],
8000            buildings: vec![BuildingView {
8001                id: "broker-hut".into(),
8002                label: "Broker".into(),
8003                x: 148.0,
8004                y: 118.0,
8005                width_m: 8.0,
8006                depth_m: 6.0,
8007                interior_blueprint: Some("broker_hut".into()),
8008                tags: vec![],
8009            }],
8010            doors: vec![flatland_protocol::DoorView {
8011                id: "door-1".into(),
8012                building_id: "broker-hut".into(),
8013                x: 148.0,
8014                y: 118.0,
8015                open: false,
8016                portal: Some("front".into()),
8017            }],
8018            interior_map: None,
8019            npcs: vec![],
8020            blueprints: vec![],
8021            world_width_m: 256.0,
8022            world_height_m: 256.0,
8023            terrain_zones: Vec::new(),
8024            z_platforms: Vec::new(),
8025            z_transitions: Vec::new(),
8026            world_clock: flatland_protocol::WorldClock::default(),
8027            inventory: std::collections::HashMap::new(),
8028            inventory_hints: std::collections::HashMap::new(),
8029            logs: VecDeque::new(),
8030            intents_sent: 0,
8031            ticks_received: 0,
8032            connected: true,
8033            disconnect_reason: None,
8034            show_stats: false,
8035            show_equip_menu: false,
8036            equip_menu_index: 0,
8037            show_craft_menu: false,
8038            craft_menu_index: 0,
8039            craft_batch_quantity: 1,
8040            show_shop_menu: false,
8041            shop_catalog: None,
8042            shop_tab: ShopTab::default(),
8043            shop_menu_index: 0,
8044            shop_quantity: 1,
8045            shop_trade_log: VecDeque::new(),
8046            show_npc_verb_menu: false,
8047            npc_verb_target: None,
8048            npc_verb_index: 0,
8049            show_npc_chat: false,
8050            npc_chat: None,
8051            show_inventory_menu: false,
8052            inventory_menu_index: 0,
8053            show_move_picker: false,
8054            show_rename_prompt: false,
8055            show_worker_rename: false,
8056            rename_buffer: String::new(),
8057            move_picker_index: 0,
8058            move_picker: None,
8059            show_destroy_picker: false,
8060            destroy_confirm_pending: false,
8061            destroy_picker: None,
8062            combat_target: None,
8063            combat_target_label: None,
8064            in_combat: false,
8065            auto_attack: true,
8066            combat_has_los: false,
8067            attack_cd_ticks: 0,
8068            gcd_ticks: 0,
8069            weapon_ability_id: "unarmed".into(),
8070            mainhand_template_id: None,
8071            mainhand_label: None,
8072            offhand_template_id: None,
8073            offhand_label: None,
8074            mainhand_hand_slots: 1,
8075            defense: None,
8076            worn: BTreeMap::new(),
8077            carry_mass: 0.0,
8078            carry_mass_max: 0.0,
8079            encumbrance: flatland_protocol::EncumbranceState::Light,
8080            inventory_stacks: Vec::new(),
8081            keychain_stacks: Vec::new(),
8082            combat_target_detail: None,
8083            cast_progress: None,
8084            ability_cooldowns: Vec::new(),
8085            blocking_active: false,
8086            max_target_slots: 1,
8087            combat_slots: Vec::new(),
8088            rotation_presets: Vec::new(),
8089            show_loadout_menu: false,
8090            show_keychain_menu: false,
8091            keychain_menu_index: 0,
8092            show_rotation_editor: false,
8093            loadout_menu_index: 0,
8094            rotation_editor: RotationEditorState::default(),
8095            harvest_in_progress: false,
8096            harvest_started_at: None,
8097            pending_craft_ack: None,
8098            pending_worker_job_ack: None,
8099            quest_log: Vec::new(),
8100            interactables: Vec::new(),
8101            ledger: None,
8102            career: None,
8103            character_sheet_tab: CharacterSheetTab::Character,
8104            ledger_period: LedgerPeriod::Day,
8105            show_quest_offer: false,
8106            pending_quest_offer: None,
8107            show_quest_menu: false,
8108            quest_menu_index: 0,
8109            quest_withdraw_confirm: false,
8110            hired_workers: Vec::new(),
8111            show_workers_menu: false,
8112            workers_menu_index: 0,
8113            workers_menu_compact: false,
8114            worker_step_display: BTreeMap::new(),
8115            show_worker_give_picker: false,
8116            worker_give_picker_index: 0,
8117            worker_give_picker: None,
8118            show_worker_give_target_picker: false,
8119            worker_give_target_picker_index: 0,
8120            worker_give_target_picker: None,
8121            show_worker_take_picker: false,
8122            worker_take_picker_index: 0,
8123            worker_take_picker: None,
8124            show_worker_teach_picker: false,
8125            worker_teach_picker_index: 0,
8126            worker_teach_picker: None,
8127            worker_route_editor: None,
8128            progression_curve: None,
8129        };
8130        state.player = state.entities.first().cloned();
8131        state
8132    }
8133
8134    #[test]
8135    fn probe_use_world_npc_beats_nearby_loot() {
8136        let mut state = sample_state();
8137        state.npcs.push(flatland_protocol::NpcView {
8138            id: "ada".into(),
8139            label: "Ada".into(),
8140            role: "broker".into(),
8141            x: 129.0,
8142            y: 128.0,
8143            building_id: None,
8144            entity_id: None,
8145            life_state: None,
8146            hp_pct: None,
8147            can_trade: true,
8148            tile_id: None,
8149            behavior_state: None,
8150            presentation_state: None,
8151            sprite_mode: None,
8152        });
8153        state.ground_drops.push(flatland_protocol::GroundDropView {
8154            id: "d1".into(),
8155            template_id: "lumber".into(),
8156            quantity: 1,
8157            x: 128.5,
8158            y: 128.0,
8159            z: 0.0,
8160            tile_id: None,
8161        });
8162        let probe = state.probe_use_world();
8163        let primary = probe.primary.expect("primary");
8164        assert_eq!(primary.kind, crate::UseWorldKind::Npc);
8165        assert_eq!(primary.id, "ada");
8166    }
8167
8168    #[test]
8169    fn probe_use_world_harvest_when_in_range() {
8170        let state = sample_state(); // oak at 130,128 — player 128,128 → dist 2 > 1.5
8171        let probe = state.probe_use_world();
8172        assert!(
8173            probe.primary.is_none(),
8174            "oak is 2m away, out of harvest range"
8175        );
8176        assert!(probe
8177            .candidates
8178            .iter()
8179            .any(|c| c.kind == crate::UseWorldKind::Harvest));
8180
8181        let mut state = sample_state();
8182        state.resource_nodes[0].x = 129.0;
8183        let probe = state.probe_use_world();
8184        let primary = probe.primary.expect("primary");
8185        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
8186    }
8187
8188    #[test]
8189    fn empty_entity_tick_preserves_welcome_snapshot() {
8190        let mut state = sample_state();
8191        state.inventory.insert("carrot".into(), 3);
8192        let delta = TickDelta {
8193            tick: 1,
8194            entities: vec![],
8195            resource_nodes: vec![],
8196            ground_drops: vec![],
8197            placed_containers: vec![],
8198            buildings: vec![],
8199            doors: vec![],
8200            interior_map: None,
8201            npcs: vec![],
8202            inventory: vec![],
8203            blueprints: vec![],
8204            world_clock: flatland_protocol::WorldClock::default(),
8205            combat: None,
8206            quest_log: vec![],
8207            hired_workers: Vec::new(),
8208            interactables: vec![],
8209            ledger: None,
8210            career: None,
8211        };
8212
8213        state.apply_tick_fields(&delta, 1);
8214
8215        assert_eq!(state.entities.len(), 1);
8216        assert!(state.player.is_some());
8217        assert_eq!(state.inventory.get("carrot"), Some(&3));
8218        assert_eq!(state.resource_nodes.len(), 1);
8219    }
8220
8221    #[test]
8222    fn tick_preserves_world_layers_when_delta_omits_them() {
8223        let mut state = sample_state();
8224        let delta = TickDelta {
8225            tick: 1,
8226            entities: state.entities.clone(),
8227            resource_nodes: vec![],
8228            ground_drops: vec![],
8229            placed_containers: vec![],
8230            buildings: vec![],
8231            doors: vec![],
8232            interior_map: None,
8233            npcs: vec![],
8234            inventory: vec![],
8235            blueprints: vec![],
8236            world_clock: flatland_protocol::WorldClock::default(),
8237            combat: None,
8238            quest_log: vec![],
8239            hired_workers: Vec::new(),
8240            interactables: vec![],
8241            ledger: None,
8242            career: None,
8243        };
8244
8245        state.apply_tick_fields(&delta, 1);
8246
8247        assert_eq!(state.resource_nodes.len(), 1);
8248        assert_eq!(state.buildings.len(), 1);
8249        assert_eq!(state.doors.len(), 1);
8250    }
8251
8252    #[test]
8253    fn tick_updates_resource_nodes_when_server_sends_them() {
8254        let mut state = sample_state();
8255        let delta = TickDelta {
8256            tick: 1,
8257            entities: state.entities.clone(),
8258            resource_nodes: vec![ResourceNodeView {
8259                id: "oak-1".into(),
8260                label: "Oak".into(),
8261                x: 130.0,
8262                y: 128.0,
8263                z: 0.0,
8264                item_template: "oak_log".into(),
8265                state: ResourceNodeState::Cooldown,
8266                blocking: true,
8267                blocking_radius_m: 0.8,
8268                tile_id: None,
8269                sprite_mode: None,
8270                presentation_state: None,
8271            }],
8272            buildings: vec![],
8273            doors: vec![],
8274            interior_map: None,
8275            npcs: vec![],
8276            inventory: vec![],
8277            blueprints: vec![],
8278            world_clock: flatland_protocol::WorldClock::default(),
8279            ground_drops: vec![],
8280            placed_containers: vec![],
8281            combat: None,
8282            quest_log: vec![],
8283            hired_workers: Vec::new(),
8284            interactables: vec![],
8285            ledger: None,
8286            career: None,
8287        };
8288
8289        state.apply_tick_fields(&delta, 1);
8290
8291        assert!(matches!(
8292            state.resource_nodes[0].state,
8293            ResourceNodeState::Cooldown
8294        ));
8295    }
8296
8297    #[test]
8298    fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
8299        let mut state = GameState {
8300            session_id: 1,
8301            entity_id: 1,
8302            character_id: None,
8303            tick: 0,
8304            chunk_rev: 0,
8305            content_rev: 0,
8306            publish_rev: 0,
8307            entities: vec![EntityState {
8308                id: 1,
8309                label: "You".into(),
8310                transform: Transform {
8311                    position: WorldCoord::surface(4.5, 2.0),
8312                    yaw: 0.0,
8313                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
8314                },
8315                vitals: None,
8316                attributes: None,
8317                skills: None,
8318                inside_building: Some("broker_hut".into()),
8319                tile_id: None,
8320                presentation_state: None,
8321                sprite_mode: None,
8322                progression_xp: None,
8323            }],
8324            player: None,
8325            resource_nodes: vec![],
8326            ground_drops: vec![],
8327            placed_containers: vec![],
8328            buildings: vec![BuildingView {
8329                id: "broker_hut".into(),
8330                label: "Broker".into(),
8331                x: 158.0,
8332                y: 124.0,
8333                width_m: 8.0,
8334                depth_m: 6.0,
8335                interior_blueprint: Some("broker_hut".into()),
8336                tags: vec![],
8337            }],
8338            doors: vec![flatland_protocol::DoorView {
8339                id: "broker_hut_exit".into(),
8340                building_id: "broker_hut".into(),
8341                x: 4.3,
8342                y: 0.9,
8343                open: true,
8344                portal: Some("front".into()),
8345            }],
8346            interior_map: None,
8347            npcs: vec![flatland_protocol::NpcView {
8348                id: "ada_broker".into(),
8349                label: "Ada".into(),
8350                x: 4.5,
8351                y: 2.0,
8352                building_id: Some("broker_hut".into()),
8353                role: "broker".into(),
8354                entity_id: None,
8355                life_state: None,
8356                hp_pct: None,
8357                can_trade: true,
8358                tile_id: None,
8359                behavior_state: None,
8360                presentation_state: None,
8361                sprite_mode: None,
8362            }],
8363            blueprints: vec![],
8364            world_width_m: 256.0,
8365            world_height_m: 256.0,
8366            terrain_zones: Vec::new(),
8367            z_platforms: Vec::new(),
8368            z_transitions: Vec::new(),
8369            world_clock: flatland_protocol::WorldClock::default(),
8370            inventory: std::collections::HashMap::new(),
8371            inventory_hints: std::collections::HashMap::new(),
8372            logs: VecDeque::new(),
8373            intents_sent: 0,
8374            ticks_received: 0,
8375            connected: true,
8376            disconnect_reason: None,
8377            show_stats: false,
8378            show_equip_menu: false,
8379            equip_menu_index: 0,
8380            show_craft_menu: false,
8381            craft_menu_index: 0,
8382            craft_batch_quantity: 1,
8383            show_shop_menu: false,
8384            shop_catalog: None,
8385            shop_tab: ShopTab::default(),
8386            shop_menu_index: 0,
8387            shop_quantity: 1,
8388            shop_trade_log: VecDeque::new(),
8389            show_npc_verb_menu: false,
8390            npc_verb_target: None,
8391            npc_verb_index: 0,
8392            show_npc_chat: false,
8393            npc_chat: None,
8394            show_inventory_menu: false,
8395            inventory_menu_index: 0,
8396            show_move_picker: false,
8397            show_rename_prompt: false,
8398            show_worker_rename: false,
8399            rename_buffer: String::new(),
8400            move_picker_index: 0,
8401            move_picker: None,
8402            show_destroy_picker: false,
8403            destroy_confirm_pending: false,
8404            destroy_picker: None,
8405            combat_target: None,
8406            combat_target_label: None,
8407            in_combat: false,
8408            auto_attack: true,
8409            combat_has_los: false,
8410            attack_cd_ticks: 0,
8411            gcd_ticks: 0,
8412            weapon_ability_id: "unarmed".into(),
8413            mainhand_template_id: None,
8414            mainhand_label: None,
8415            offhand_template_id: None,
8416            offhand_label: None,
8417            mainhand_hand_slots: 1,
8418            defense: None,
8419            worn: BTreeMap::new(),
8420            carry_mass: 0.0,
8421            carry_mass_max: 0.0,
8422            encumbrance: flatland_protocol::EncumbranceState::Light,
8423            inventory_stacks: Vec::new(),
8424            keychain_stacks: Vec::new(),
8425            combat_target_detail: None,
8426            cast_progress: None,
8427            ability_cooldowns: Vec::new(),
8428            blocking_active: false,
8429            max_target_slots: 1,
8430            combat_slots: Vec::new(),
8431            rotation_presets: Vec::new(),
8432            show_loadout_menu: false,
8433            show_keychain_menu: false,
8434            keychain_menu_index: 0,
8435            show_rotation_editor: false,
8436            loadout_menu_index: 0,
8437            rotation_editor: RotationEditorState::default(),
8438            harvest_in_progress: false,
8439            harvest_started_at: None,
8440            pending_craft_ack: None,
8441            pending_worker_job_ack: None,
8442            quest_log: Vec::new(),
8443            interactables: Vec::new(),
8444            ledger: None,
8445            career: None,
8446            character_sheet_tab: CharacterSheetTab::Character,
8447            ledger_period: LedgerPeriod::Day,
8448            show_quest_offer: false,
8449            pending_quest_offer: None,
8450            show_quest_menu: false,
8451            quest_menu_index: 0,
8452            quest_withdraw_confirm: false,
8453            hired_workers: Vec::new(),
8454            show_workers_menu: false,
8455            workers_menu_index: 0,
8456            workers_menu_compact: false,
8457            worker_step_display: BTreeMap::new(),
8458            show_worker_give_picker: false,
8459            worker_give_picker_index: 0,
8460            worker_give_picker: None,
8461            show_worker_give_target_picker: false,
8462            worker_give_target_picker_index: 0,
8463            worker_give_target_picker: None,
8464            show_worker_take_picker: false,
8465            worker_take_picker_index: 0,
8466            worker_take_picker: None,
8467            show_worker_teach_picker: false,
8468            worker_teach_picker_index: 0,
8469            worker_teach_picker: None,
8470            worker_route_editor: None,
8471            progression_curve: None,
8472        };
8473        state.player = state.entities.first().cloned();
8474        assert_eq!(
8475            state.nearest_interact_target().as_deref(),
8476            Some("ada_broker")
8477        );
8478    }
8479
8480    #[test]
8481    fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
8482        let mut state = sample_state();
8483        // Player is at (128, 128) per sample_state(). One chest just inside
8484        // CONTAINER_RANGE_M, one clearly beyond it.
8485        state.placed_containers = vec![
8486            flatland_protocol::PlacedContainerView {
8487                id: "near".into(),
8488                template_id: "wooden_chest_small".into(),
8489                display_name: "Wooden Chest".into(),
8490                x: 130.0,
8491                y: 128.0,
8492                z: 0.0,
8493                locked: true,
8494                accessible: true,
8495                owner_character_id: None,
8496                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
8497                lock_id: None,
8498                capacity_volume: None,
8499                item_instance_id: Some(uuid::Uuid::from_u128(1)),
8500                tile_id: None,
8501                worker_lodging_capacity: None,
8502            },
8503            flatland_protocol::PlacedContainerView {
8504                id: "far".into(),
8505                template_id: "wooden_chest_small".into(),
8506                display_name: "Distant Chest".into(),
8507                x: 128.0 + CONTAINER_RANGE_M + 5.0,
8508                y: 128.0,
8509                z: 0.0,
8510                locked: false,
8511                accessible: true,
8512                owner_character_id: None,
8513                contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
8514                lock_id: None,
8515                capacity_volume: None,
8516                item_instance_id: Some(uuid::Uuid::from_u128(2)),
8517                tile_id: None,
8518                worker_lodging_capacity: None,
8519            },
8520        ];
8521
8522        let nearby = state.nearby_containers();
8523        assert_eq!(
8524            nearby.len(),
8525            1,
8526            "far chest must not appear once out of range"
8527        );
8528        assert_eq!(nearby[0].view.id, "near");
8529        assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
8530        assert!(nearby[0].rows[0].is_chest_shell);
8531
8532        // The same chest, but locked and inaccessible (no key held), must hide
8533        // contents but still show the selectable chest shell row.
8534        state.placed_containers[0].accessible = false;
8535        let nearby = state.nearby_containers();
8536        assert_eq!(nearby.len(), 1);
8537        assert_eq!(nearby[0].rows.len(), 1);
8538        assert!(nearby[0].rows[0].is_chest_shell);
8539    }
8540
8541    #[test]
8542    fn chest_pickup_destinations_offer_person_and_worn_bag() {
8543        let mut state = sample_state();
8544        let back_id = uuid::Uuid::from_u128(42);
8545        state.worn.insert(
8546            BodySlot::Back,
8547            flatland_protocol::ItemStack {
8548                template_id: "travel_backpack".into(),
8549                quantity: 1,
8550                item_instance_id: Some(back_id),
8551                props: Default::default(),
8552                contents: Vec::new(),
8553                display_name: Some("Travel Backpack".into()),
8554                category: Some("container".into()),
8555                base_mass: Some(2.5),
8556                base_volume: Some(12.0),
8557                capacity_volume: Some(80.0),
8558                stackable: Some(false),
8559                world_placeable: Some(false),
8560                worker_lodging_capacity: None,
8561                equip_slot: None,
8562                armor_physical: None,
8563                resists: vec![],
8564                hand_slots: None,
8565            },
8566        );
8567        let opts = state.chest_pickup_destinations("chest-1");
8568        assert!(opts.iter().any(|o| matches!(
8569            &o.kind,
8570            MoveOptionKind::PickupPlaced {
8571                nest_parent_instance_id: None,
8572                ..
8573            }
8574        )));
8575        assert!(opts.iter().any(|o| matches!(
8576            &o.kind,
8577            MoveOptionKind::PickupPlaced {
8578                nest_parent_instance_id: Some(id),
8579                ..
8580            } if *id == back_id
8581        )));
8582        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
8583    }
8584
8585    #[test]
8586    fn placed_container_public_label_hides_owner_custom_name() {
8587        let owner = uuid::Uuid::from_u128(99);
8588        let mut state = sample_state();
8589        state.character_id = Some(uuid::Uuid::from_u128(1));
8590        state.inventory_hints.insert(
8591            "wooden_chest_medium".into(),
8592            InventoryHint {
8593                display_name: "Medium Wooden Chest".into(),
8594                category: "container".into(),
8595                base_mass: None,
8596                base_volume: None,
8597                capacity_volume: None,
8598                stackable: false,
8599            },
8600        );
8601        let chest = flatland_protocol::PlacedContainerView {
8602            id: "c1".into(),
8603            template_id: "wooden_chest_medium".into(),
8604            display_name: "Barry's Loot #a3f2".into(),
8605            x: 128.0,
8606            y: 128.0,
8607            z: 0.0,
8608            locked: false,
8609            accessible: true,
8610            owner_character_id: Some(owner),
8611            contents: vec![],
8612            lock_id: None,
8613            capacity_volume: None,
8614            item_instance_id: None,
8615            tile_id: None,
8616            worker_lodging_capacity: None,
8617        };
8618        assert_eq!(
8619            state.placed_container_public_label(&chest),
8620            "Medium Wooden Chest"
8621        );
8622        state.character_id = Some(owner);
8623        assert_eq!(
8624            state.placed_container_public_label(&chest),
8625            "Barry's Loot #a3f2"
8626        );
8627    }
8628
8629    #[test]
8630    fn location_context_lists_nearby_resource_node() {
8631        let mut state = sample_state();
8632        state.player = state.entities.first().cloned();
8633        state.resource_nodes[0].x = 128.2;
8634        state.resource_nodes[0].y = 128.0;
8635        let lines = state.location_context_lines();
8636        assert!(
8637            lines
8638                .iter()
8639                .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
8640            "expected resource node in context: {:?}",
8641            lines
8642        );
8643    }
8644
8645    #[test]
8646    fn quest_board_usable_within_board_radius() {
8647        let mut state = sample_state();
8648        state.player = state.entities.first().cloned();
8649        state.interactables = vec![flatland_protocol::InteractableView {
8650            id: "board-1".into(),
8651            kind: "quest_board".into(),
8652            label: "Town Quest Board".into(),
8653            x: 130.5,
8654            y: 128.0,
8655            z: 0.0,
8656            board_id: Some("starter_town_board".into()),
8657        }];
8658        // ~2.5m away — outside the old 1.5m interact radius, inside the 3.0m board radius.
8659        assert_eq!(
8660            state.nearest_interact_target().as_deref(),
8661            Some("board-1"),
8662            "quest board should be selectable at ~2.5m"
8663        );
8664        let lines = state.location_context_lines();
8665        assert!(
8666            lines
8667                .iter()
8668                .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
8669            "HUD should advertise f when board is in range: {:?}",
8670            lines
8671        );
8672    }
8673
8674    #[test]
8675    fn inventory_selectable_rows_orders_worn_before_person_before_nearby() {
8676        let mut state = sample_state();
8677        state.worn.insert(
8678            BodySlot::Back,
8679            flatland_protocol::ItemStack {
8680                template_id: "travel_backpack".into(),
8681                quantity: 1,
8682                item_instance_id: Some(uuid::Uuid::from_u128(3)),
8683                props: Default::default(),
8684                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
8685                display_name: None,
8686                category: None,
8687                base_mass: None,
8688                base_volume: None,
8689                capacity_volume: None,
8690                stackable: None,
8691                world_placeable: None,
8692                worker_lodging_capacity: None,
8693                equip_slot: None,
8694                armor_physical: None,
8695                resists: vec![],
8696                hand_slots: None,
8697            },
8698        );
8699        state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
8700        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
8701            id: "chest-1".into(),
8702            template_id: "wooden_chest_small".into(),
8703            display_name: "Wooden Chest".into(),
8704            x: 129.0,
8705            y: 128.0,
8706            z: 0.0,
8707            locked: false,
8708            accessible: true,
8709            owner_character_id: None,
8710            contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
8711            lock_id: None,
8712            capacity_volume: None,
8713            item_instance_id: Some(uuid::Uuid::from_u128(4)),
8714            tile_id: None,
8715            worker_lodging_capacity: None,
8716        }];
8717
8718        let rows = state.inventory_selectable_rows();
8719        let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
8720        assert_eq!(
8721            sections,
8722            vec![
8723                InventorySection::Worn,   // backpack shell
8724                InventorySection::Worn,   // iron_ore nested in backpack
8725                InventorySection::Person, // lumber
8726                InventorySection::Nearby, // chest shell
8727                InventorySection::Nearby, // wood_axe in chest
8728            ]
8729        );
8730        assert_eq!(rows[0].stack.template_id, "travel_backpack");
8731        assert!(rows[0].is_equip_shell);
8732        assert_eq!(rows[1].stack.template_id, "iron_ore");
8733        assert_eq!(rows[1].depth, 1);
8734        assert_eq!(rows[2].stack.template_id, "lumber");
8735        assert!(rows[3].is_chest_shell);
8736        assert_eq!(rows[4].stack.template_id, "wood_axe");
8737        assert_eq!(rows[4].depth, 1);
8738
8739        let lines = state.inventory_browser_lines();
8740        assert!(lines.iter().any(|l| matches!(
8741            l,
8742            InventoryBrowserLine::Section(s) if s.contains("Worn")
8743        )));
8744        assert!(lines.iter().any(|l| matches!(
8745            l,
8746            InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
8747                || text.contains("backpack")
8748        )));
8749    }
8750
8751    #[test]
8752    fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
8753        let mut state = sample_state();
8754        let back_id = uuid::Uuid::from_u128(5);
8755        state.worn.insert(
8756            BodySlot::Back,
8757            flatland_protocol::ItemStack {
8758                template_id: "travel_backpack".into(),
8759                quantity: 1,
8760                item_instance_id: Some(back_id),
8761                props: Default::default(),
8762                contents: Vec::new(),
8763                display_name: None,
8764                category: Some("container".into()),
8765                base_mass: None,
8766                base_volume: None,
8767                capacity_volume: Some(80.0),
8768                stackable: None,
8769                world_placeable: None,
8770                worker_lodging_capacity: None,
8771                equip_slot: None,
8772                armor_physical: None,
8773                resists: vec![],
8774                hand_slots: None,
8775            },
8776        );
8777        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
8778            id: "chest-1".into(),
8779            template_id: "wooden_chest_small".into(),
8780            display_name: "Wooden Chest".into(),
8781            x: 129.0,
8782            y: 128.0,
8783            z: 0.0,
8784            locked: false,
8785            accessible: true,
8786            owner_character_id: None,
8787            contents: Vec::new(),
8788            lock_id: None,
8789            capacity_volume: None,
8790            item_instance_id: Some(uuid::Uuid::from_u128(6)),
8791            tile_id: None,
8792            worker_lodging_capacity: None,
8793        }];
8794
8795        // Item currently sitting loose on the person (Root): backpack + nearby
8796        // chest should both be offered, plus Drop/Cancel, but not "Root" itself.
8797        let opts = state.move_destinations_for(
8798            &flatland_protocol::InventoryLocation::Root,
8799            None,
8800            None,
8801            "lumber",
8802        );
8803        assert!(!opts.iter().any(|o| matches!(
8804            &o.kind,
8805            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
8806        )));
8807        assert!(opts.iter().any(|o| matches!(
8808            &o.kind,
8809            MoveOptionKind::Move { location, parent_instance_id, .. }
8810                if *location == flatland_protocol::InventoryLocation::Worn {
8811                    slot: BodySlot::Back,
8812                } && *parent_instance_id == Some(back_id)
8813        )));
8814        assert!(opts.iter().any(|o| matches!(
8815            &o.kind,
8816            MoveOptionKind::Move { location, .. }
8817                if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
8818        )));
8819        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
8820        assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
8821
8822        // Item currently inside the worn backpack: the backpack itself must be
8823        // excluded from its own destination list (can't move an item into the
8824        // container it's already in).
8825        let from_backpack = flatland_protocol::InventoryLocation::Worn {
8826            slot: BodySlot::Back,
8827        };
8828        let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
8829        assert!(!opts.iter().any(|o| matches!(
8830            &o.kind,
8831            MoveOptionKind::Move { location, parent_instance_id, .. }
8832                if *location == from_backpack && *parent_instance_id == Some(back_id)
8833        )));
8834        assert!(opts.iter().any(|o| matches!(
8835            &o.kind,
8836            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
8837        )));
8838    }
8839
8840    #[test]
8841    fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
8842        let mut state = sample_state();
8843        // Insert out of display order — BTreeMap iteration must still yield the
8844        // canonical Head/Body/Arms/Legs/Feet/Back/Waist order regardless.
8845        state.worn.insert(
8846            BodySlot::Waist,
8847            flatland_protocol::ItemStack {
8848                template_id: "simple_belt".into(),
8849                quantity: 1,
8850                item_instance_id: Some(uuid::Uuid::from_u128(10)),
8851                props: Default::default(),
8852                contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
8853                display_name: None,
8854                category: Some("container".into()),
8855                base_mass: None,
8856                base_volume: None,
8857                capacity_volume: None,
8858                stackable: None,
8859                world_placeable: None,
8860                worker_lodging_capacity: None,
8861                equip_slot: None,
8862                armor_physical: None,
8863                resists: vec![],
8864                hand_slots: None,
8865            },
8866        );
8867        state.worn.insert(
8868            BodySlot::Head,
8869            flatland_protocol::ItemStack {
8870                template_id: "cloth_cap".into(),
8871                quantity: 1,
8872                item_instance_id: Some(uuid::Uuid::from_u128(11)),
8873                props: Default::default(),
8874                contents: Vec::new(),
8875                display_name: None,
8876                category: Some("armor".into()),
8877                base_mass: None,
8878                base_volume: None,
8879                capacity_volume: None,
8880                stackable: None,
8881                world_placeable: None,
8882                worker_lodging_capacity: None,
8883                equip_slot: None,
8884                armor_physical: None,
8885                resists: vec![],
8886                hand_slots: None,
8887            },
8888        );
8889        state.worn.insert(
8890            BodySlot::Back,
8891            flatland_protocol::ItemStack {
8892                template_id: "travel_backpack".into(),
8893                quantity: 1,
8894                item_instance_id: Some(uuid::Uuid::from_u128(12)),
8895                props: Default::default(),
8896                contents: Vec::new(),
8897                display_name: None,
8898                category: Some("container".into()),
8899                base_mass: None,
8900                base_volume: None,
8901                capacity_volume: None,
8902                stackable: None,
8903                world_placeable: None,
8904                worker_lodging_capacity: None,
8905                equip_slot: None,
8906                armor_physical: None,
8907                resists: vec![],
8908                hand_slots: None,
8909            },
8910        );
8911
8912        let rows = state.worn_rows();
8913        // Head, then Back, then Waist (+ nested pouch) — enum declaration order.
8914        assert_eq!(rows.len(), 4);
8915        assert_eq!(rows[0].stack.template_id, "cloth_cap");
8916        assert!(rows[0].is_equip_shell);
8917        assert_eq!(rows[1].stack.template_id, "travel_backpack");
8918        assert!(rows[1].is_equip_shell);
8919        assert_eq!(rows[2].stack.template_id, "simple_belt");
8920        assert!(rows[2].is_equip_shell);
8921        assert_eq!(rows[3].stack.template_id, "leather_pouch");
8922        assert_eq!(rows[3].depth, 1);
8923        assert!(!rows[3].is_equip_shell);
8924    }
8925
8926    #[test]
8927    fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
8928        let mut state = sample_state();
8929        state.worn.insert(
8930            BodySlot::Waist,
8931            flatland_protocol::ItemStack {
8932                template_id: "simple_belt".into(),
8933                quantity: 1,
8934                item_instance_id: Some(uuid::Uuid::from_u128(20)),
8935                props: Default::default(),
8936                contents: Vec::new(),
8937                display_name: Some("Simple Belt".into()),
8938                category: Some("container".into()),
8939                base_mass: None,
8940                base_volume: None,
8941                capacity_volume: None,
8942                stackable: None,
8943                world_placeable: None,
8944                worker_lodging_capacity: None,
8945                equip_slot: None,
8946                armor_physical: None,
8947                resists: vec![],
8948                hand_slots: None,
8949            },
8950        );
8951        state.worn.insert(
8952            BodySlot::Head,
8953            flatland_protocol::ItemStack {
8954                template_id: "cloth_cap".into(),
8955                quantity: 1,
8956                item_instance_id: Some(uuid::Uuid::from_u128(21)),
8957                props: Default::default(),
8958                contents: Vec::new(),
8959                display_name: Some("Cloth Cap".into()),
8960                category: Some("armor".into()),
8961                base_mass: None,
8962                base_volume: None,
8963                capacity_volume: None,
8964                stackable: None,
8965                world_placeable: None,
8966                worker_lodging_capacity: None,
8967                equip_slot: None,
8968                armor_physical: None,
8969                resists: vec![],
8970                hand_slots: None,
8971            },
8972        );
8973
8974        let opts = state.move_destinations_for(
8975            &flatland_protocol::InventoryLocation::Root,
8976            None,
8977            None,
8978            "leather_pouch",
8979        );
8980        assert!(
8981            opts.iter().any(|o| matches!(
8982                &o.kind,
8983                MoveOptionKind::Move { location, .. }
8984                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
8985            )),
8986            "belt loop must be offered when moving a pouch"
8987        );
8988        assert!(
8989            !opts.iter().any(|o| matches!(
8990                &o.kind,
8991                MoveOptionKind::Move { location, .. }
8992                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
8993            )),
8994            "armor slots can't hold other items and must not appear as move destinations"
8995        );
8996        let belt_opt = opts
8997            .iter()
8998            .find(|o| matches!(
8999                &o.kind,
9000                MoveOptionKind::Move { location, .. }
9001                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
9002            ))
9003            .unwrap();
9004        assert!(belt_opt.label.contains("belt loop"));
9005
9006        let opts = state.move_destinations_for(
9007            &flatland_protocol::InventoryLocation::Root,
9008            None,
9009            None,
9010            "lumber",
9011        );
9012        assert!(
9013            !opts.iter().any(|o| o.label.contains("belt loop")),
9014            "loose materials must not target the belt shell — only nested pouches"
9015        );
9016    }
9017
9018    #[test]
9019    fn move_destinations_for_offers_dimensional_pouch_on_belt() {
9020        let mut state = sample_state();
9021        let belt_id = uuid::Uuid::from_u128(30);
9022        let pouch_id = uuid::Uuid::from_u128(31);
9023        state.worn.insert(
9024            BodySlot::Waist,
9025            flatland_protocol::ItemStack {
9026                template_id: "simple_belt".into(),
9027                quantity: 1,
9028                item_instance_id: Some(belt_id),
9029                props: Default::default(),
9030                world_placeable: None,
9031                worker_lodging_capacity: None,
9032                equip_slot: None,
9033                armor_physical: None,
9034                resists: vec![],
9035                hand_slots: None,
9036                contents: vec![flatland_protocol::ItemStack {
9037                    template_id: "dimensional_pouch".into(),
9038                    quantity: 1,
9039                    item_instance_id: Some(pouch_id),
9040                    props: Default::default(),
9041                    contents: Vec::new(),
9042                    display_name: Some("Dimensional Pouch".into()),
9043                    category: Some("container".into()),
9044                    base_mass: None,
9045                    base_volume: None,
9046                    capacity_volume: Some(200.0),
9047                    stackable: None,
9048                    world_placeable: None,
9049                    worker_lodging_capacity: None,
9050                    equip_slot: None,
9051                    armor_physical: None,
9052                    resists: vec![],
9053                    hand_slots: None,
9054                }],
9055                display_name: Some("Simple Belt".into()),
9056                category: Some("container".into()),
9057                base_mass: None,
9058                base_volume: None,
9059                capacity_volume: None,
9060                stackable: None,
9061            },
9062        );
9063
9064        let opts = state.move_destinations_for(
9065            &flatland_protocol::InventoryLocation::Root,
9066            None,
9067            None,
9068            "iron_ore",
9069        );
9070        assert!(
9071            opts.iter().any(|o| matches!(
9072                &o.kind,
9073                MoveOptionKind::Move {
9074                    location,
9075                    parent_instance_id,
9076                    ..
9077                } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
9078                    && *parent_instance_id == Some(pouch_id)
9079            )),
9080            "dimensional pouch clipped on belt must accept loose items"
9081        );
9082        assert!(
9083            opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
9084            "destination label should name the pouch"
9085        );
9086    }
9087
9088    #[test]
9089    fn container_volume_label_on_placed_chest_shell() {
9090        let mut state = sample_state();
9091        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
9092            id: "chest-1".into(),
9093            template_id: "wooden_chest_small".into(),
9094            display_name: "Camp Chest".into(),
9095            worker_lodging_capacity: None,
9096            x: 129.0,
9097            y: 128.0,
9098            z: 0.0,
9099            locked: false,
9100            accessible: true,
9101            owner_character_id: None,
9102            contents: vec![flatland_protocol::ItemStack {
9103                template_id: "iron_ore".into(),
9104                quantity: 2,
9105                item_instance_id: None,
9106                props: Default::default(),
9107                contents: Vec::new(),
9108                display_name: None,
9109                category: None,
9110                base_mass: None,
9111                base_volume: Some(2.0),
9112                capacity_volume: None,
9113                stackable: None,
9114                world_placeable: None,
9115                worker_lodging_capacity: None,
9116                equip_slot: None,
9117                armor_physical: None,
9118                resists: vec![],
9119                hand_slots: None,
9120            }],
9121            lock_id: None,
9122            capacity_volume: Some(60.0),
9123            item_instance_id: Some(uuid::Uuid::from_u128(4)),
9124            tile_id: None,
9125        }];
9126        let nearby = state.nearby_containers();
9127        let label = state.container_volume_label(&nearby[0].rows[0]);
9128        assert!(
9129            label.contains("vol 4/60"),
9130            "expected used/cap in label, got {label}"
9131        );
9132        assert!(
9133            label.contains("56 free"),
9134            "expected free space, got {label}"
9135        );
9136    }
9137
9138    #[test]
9139    fn key_pair_chest_label_from_placed_lock_id() {
9140        let mut state = sample_state();
9141        let owner = uuid::Uuid::from_u128(77);
9142        state.character_id = Some(owner);
9143        let lock = uuid::Uuid::from_u128(99).to_string();
9144        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
9145            id: "chest-1".into(),
9146            template_id: "wooden_chest_small".into(),
9147            display_name: "Barry's Loot #a3f2".into(),
9148            x: 129.0,
9149            y: 128.0,
9150            z: 0.0,
9151            locked: true,
9152            accessible: true,
9153            owner_character_id: Some(owner),
9154            contents: Vec::new(),
9155            lock_id: Some(lock.clone()),
9156            capacity_volume: None,
9157            item_instance_id: Some(uuid::Uuid::from_u128(4)),
9158            tile_id: None,
9159            worker_lodging_capacity: None,
9160        }];
9161        let key_id = uuid::Uuid::from_u128(5);
9162        let key = flatland_protocol::ItemStack {
9163            template_id: KEY_TEMPLATE.into(),
9164            quantity: 1,
9165            item_instance_id: Some(key_id),
9166            props: BTreeMap::from([
9167                (PROP_OPENS_LOCK_ID.into(), lock),
9168                (
9169                    PROP_OPENS_CONTAINER_NAME.into(),
9170                    "Barry's Loot #a3f2".into(),
9171                ),
9172            ]),
9173            contents: Vec::new(),
9174            display_name: Some("Container Key".into()),
9175            category: Some("key".into()),
9176            base_mass: None,
9177            base_volume: None,
9178            capacity_volume: None,
9179            stackable: None,
9180            world_placeable: None,
9181            worker_lodging_capacity: None,
9182                equip_slot: None,
9183                armor_physical: None,
9184                resists: vec![],
9185                hand_slots: None,
9186        };
9187        state.inventory_stacks = vec![key.clone()];
9188        assert_eq!(
9189            state.key_pair_chest_label(&key).as_deref(),
9190            Some("Barry's Loot #a3f2")
9191        );
9192        assert!(state.key_drop_blocked(&key));
9193    }
9194
9195    #[test]
9196    fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
9197        let mut state = sample_state();
9198        let lock = uuid::Uuid::from_u128(101).to_string();
9199        let key = flatland_protocol::ItemStack {
9200            template_id: KEY_TEMPLATE.into(),
9201            quantity: 1,
9202            item_instance_id: Some(uuid::Uuid::from_u128(7)),
9203            props: BTreeMap::from([
9204                (PROP_OPENS_LOCK_ID.into(), lock),
9205                (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
9206            ]),
9207            contents: Vec::new(),
9208            display_name: None,
9209            category: Some("key".into()),
9210            base_mass: None,
9211            base_volume: None,
9212            capacity_volume: None,
9213            stackable: None,
9214            world_placeable: None,
9215            worker_lodging_capacity: None,
9216                equip_slot: None,
9217                armor_physical: None,
9218                resists: vec![],
9219                hand_slots: None,
9220        };
9221        state.placed_containers.clear();
9222        assert_eq!(
9223            state.key_pair_chest_label(&key).as_deref(),
9224            Some("Camp Stash")
9225        );
9226    }
9227
9228    #[test]
9229    fn key_drop_allowed_when_paired_chest_unlocked() {
9230        let mut state = sample_state();
9231        let lock = uuid::Uuid::from_u128(100).to_string();
9232        let key_id = uuid::Uuid::from_u128(6);
9233        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
9234            id: "chest-1".into(),
9235            template_id: "wooden_chest_small".into(),
9236            display_name: "Camp Chest".into(),
9237            x: 129.0,
9238            y: 128.0,
9239            z: 0.0,
9240            locked: false,
9241            accessible: true,
9242            owner_character_id: None,
9243            contents: Vec::new(),
9244            lock_id: Some(lock.clone()),
9245            capacity_volume: None,
9246            item_instance_id: None,
9247            tile_id: None,
9248            worker_lodging_capacity: None,
9249        }];
9250        let key = flatland_protocol::ItemStack {
9251            template_id: KEY_TEMPLATE.into(),
9252            quantity: 1,
9253            item_instance_id: Some(key_id),
9254            props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
9255            contents: Vec::new(),
9256            display_name: None,
9257            category: Some("key".into()),
9258            base_mass: None,
9259            base_volume: None,
9260            capacity_volume: None,
9261            stackable: None,
9262            world_placeable: None,
9263            worker_lodging_capacity: None,
9264                equip_slot: None,
9265                armor_physical: None,
9266                resists: vec![],
9267                hand_slots: None,
9268        };
9269        state.inventory_stacks = vec![key.clone()];
9270        assert!(!state.key_drop_blocked(&key));
9271        let opts = state.move_destinations_for(
9272            &flatland_protocol::InventoryLocation::Root,
9273            None,
9274            Some(key_id),
9275            KEY_TEMPLATE,
9276        );
9277        assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
9278    }
9279
9280    #[test]
9281    fn combat_hud_refreshes_progression_xp_when_entity_stale() {
9282        use flatland_protocol::{CombatHud, ProgressionXp, ProgressionCurve};
9283
9284        let mut state = sample_state();
9285        let curve = ProgressionCurve::default();
9286        let bootstrap = ProgressionXp::bootstrap_new(
9287            curve.baseline_display,
9288            curve.xp_base,
9289            curve.xp_growth,
9290        );
9291        let mut fresh = bootstrap.clone();
9292        fresh.strength += 0.08;
9293        if let Some(player) = state.player.as_mut() {
9294            player.progression_xp = Some(bootstrap);
9295        }
9296
9297        let combat = CombatHud {
9298            progression_xp: Some(fresh.clone()),
9299            progression_baseline: curve.baseline_display,
9300            progression_xp_base: curve.xp_base,
9301            progression_xp_growth: curve.xp_growth,
9302            attributes: state.player.as_ref().and_then(|p| p.attributes),
9303            skills: state.player.as_ref().and_then(|p| p.skills.clone()),
9304            ..CombatHud::default()
9305        };
9306        state.apply_combat_hud(&combat);
9307
9308        let xp = state
9309            .player
9310            .as_ref()
9311            .and_then(|p| p.progression_xp.as_ref())
9312            .expect("xp");
9313        assert!((xp.strength - fresh.strength).abs() < 0.001);
9314        assert!(state.progression_curve.is_some());
9315    }
9316
9317    #[test]
9318    fn loose_consumable_move_picker_offers_use_and_storage() {
9319        let mut state = sample_state();
9320        let inst = uuid::Uuid::from_u128(77);
9321        state.inventory_stacks = vec![flatland_protocol::ItemStack {
9322            template_id: "carrot".into(),
9323            quantity: 2,
9324            item_instance_id: Some(inst),
9325            props: Default::default(),
9326            contents: Vec::new(),
9327            display_name: Some("Wild Carrot".into()),
9328            category: Some("consumable".into()),
9329            base_mass: None,
9330            base_volume: None,
9331            capacity_volume: None,
9332            stackable: Some(true),
9333            world_placeable: None,
9334            worker_lodging_capacity: None,
9335                equip_slot: None,
9336                armor_physical: None,
9337                resists: vec![],
9338                hand_slots: None,
9339        }];
9340        state.inventory_hints.insert(
9341            "carrot".into(),
9342            InventoryHint {
9343                display_name: "Wild Carrot".into(),
9344                category: "consumable".into(),
9345                base_mass: Some(0.15),
9346                base_volume: Some(0.3),
9347                capacity_volume: None,
9348                stackable: true,
9349            },
9350        );
9351        state.show_inventory_menu = true;
9352        state.inventory_menu_index = 0;
9353
9354        let row = state.inventory_selected_row().expect("carrot row");
9355        let mut options = state.move_destinations_for(
9356            &row.from,
9357            row.from_parent_instance_id,
9358            row.stack.item_instance_id,
9359            &row.stack.template_id,
9360        );
9361        if row.from == flatland_protocol::InventoryLocation::Root
9362            && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
9363        {
9364            options.insert(
9365                0,
9366                MoveOption {
9367                    label: "Use (eat / drink)".into(),
9368                    kind: MoveOptionKind::Use,
9369                },
9370            );
9371        }
9372
9373        assert_eq!(options.first().map(|o| &o.label), Some(&"Use (eat / drink)".into()));
9374        assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
9375        assert!(options.iter().any(|o| matches!(o.kind, MoveOptionKind::Drop)));
9376    }
9377}