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,
7    DoorView, EntityId, EntityState, Intent, LifeState, NpcView, RotationPreset, Seq, SessionId,
8    TerrainKindView, TerrainZoneView, Tick,
9};
10
11use crate::session::{PlayConnection, SessionEvent};
12
13/// Matches `flatland_sim::containers` prop keys (client does not depend on sim).
14const KEY_TEMPLATE: &str = "container_key";
15const PROP_LOCK_ID: &str = "lock_id";
16const PROP_OPENS_LOCK_ID: &str = "opens_lock_id";
17const PROP_OPENS_CONTAINER_NAME: &str = "opens_container_name";
18const PROP_CUSTOM_NAME: &str = "custom_name";
19const PROP_LOCKED: &str = "locked";
20
21fn stack_is_locked(stack: &flatland_protocol::ItemStack) -> bool {
22    stack
23        .props
24        .get(PROP_LOCKED)
25        .is_some_and(|v| v == "true" || v == "1")
26}
27
28const MAX_LOG_LINES: usize = 200;
29const INTERACTION_RADIUS_M: f32 = 1.5;
30const DOOR_INTERACTION_RADIUS_M: f32 = 3.5;
31const HARVEST_CLIENT_TIMEOUT: Duration = Duration::from_secs(12);
32/// Matches `assets/config/server-settings.yaml` default for batch-cap UI estimates.
33const CRAFT_STAMINA_COST: f32 = 3.0;
34
35/// Catalog hints synced from server `ItemStack` wire rows.
36#[derive(Debug, Clone, Default)]
37pub struct InventoryHint {
38    pub display_name: String,
39    pub category: String,
40    pub base_mass: Option<f32>,
41    pub base_volume: Option<f32>,
42    pub capacity_volume: Option<f32>,
43    pub stackable: bool,
44}
45
46/// Rotation editor overlay mode (`plans/26` §C2.5).
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum RotationEditorMode {
49    #[default]
50    List,
51    EditSequence,
52    PickAbility,
53    EditLabel,
54}
55
56/// Local rotation editor UI state (not persisted).
57#[derive(Debug, Clone, Default)]
58pub struct RotationEditorState {
59    pub mode: RotationEditorMode,
60    pub list_index: usize,
61    pub ability_index: usize,
62    pub picker_index: usize,
63    pub draft: Option<RotationPreset>,
64    pub label_buffer: String,
65}
66
67impl RotationEditorState {
68    pub fn reset(&mut self) {
69        *self = Self::default();
70    }
71}
72
73/// Max distance (m) a placed chest can be browsed/moved-into from the inventory
74/// UI. Mirrors `flatland_sim::interaction::CONTAINER_INTERACTION_RADIUS_M` so the
75/// client only ever shows chests the server will actually let you use — this is
76/// what makes a chest disappear from the menu as soon as you walk away.
77pub const CONTAINER_RANGE_M: f32 = 3.0;
78
79/// Broad section of the inventory browser a row belongs to (drives the grouped
80/// "Worn" / "On you" / "Nearby chest" headers in the UI).
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum InventorySection {
83    /// Inside a worn body-slot item (backpack, belt w/ clipped pouches, armor).
84    Worn,
85    /// Loose on your person — not worn, not inside a placed chest.
86    Person,
87    /// Inside a placed chest within reach.
88    Nearby,
89}
90
91/// Short display label for a body slot (`plans/08` §4.1) — shared by the inventory
92/// browser section headers and the move-destination picker.
93pub fn body_slot_label(slot: BodySlot) -> &'static str {
94    match slot {
95        BodySlot::Head => "Head",
96        BodySlot::Body => "Body",
97        BodySlot::Arms => "Arms",
98        BodySlot::Legs => "Legs",
99        BodySlot::Feet => "Feet",
100        BodySlot::Back => "Back",
101        BodySlot::Waist => "Waist",
102    }
103}
104
105/// Client-side naming heuristic for "Enter equips this" — the client doesn't sync the
106/// item catalog's `equip_slot`, so this guesses from `template_id` the same way the
107/// pre-generalization code already guessed backpack vs. pouch. Pouches deliberately
108/// aren't covered — they attach to belt loops via the move picker instead of equipping
109/// directly (`plans/08` §4.1).
110fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
111    if template_id.contains("backpack") {
112        Some(BodySlot::Back)
113    } else if template_id.contains("belt") {
114        Some(BodySlot::Waist)
115    } else if template_id.contains("cap") || template_id.contains("hat") || template_id.contains("helm") {
116        Some(BodySlot::Head)
117    } else if template_id.contains("shirt") || template_id.contains("robe") || template_id.contains("vest") {
118        Some(BodySlot::Body)
119    } else if template_id.contains("sleeves") || template_id.contains("gloves") || template_id.contains("gauntlets") {
120        Some(BodySlot::Arms)
121    } else if template_id.contains("pants") || template_id.contains("leggings") {
122        Some(BodySlot::Legs)
123    } else if template_id.contains("boots") || template_id.contains("shoes") {
124        Some(BodySlot::Feet)
125    } else {
126        None
127    }
128}
129
130/// One row in the inventory browser tree.
131#[derive(Debug, Clone)]
132pub struct InventoryRow {
133    pub depth: usize,
134    pub stack: flatland_protocol::ItemStack,
135    /// `MoveItem` source location for this stack.
136    pub from: flatland_protocol::InventoryLocation,
137    /// Parent container instance when nested (belt shell, backpack, chest, pouch).
138    pub from_parent_instance_id: Option<uuid::Uuid>,
139    /// Equipped bag/chest shell — unequip via Enter instead of the move picker.
140    pub is_equip_shell: bool,
141    /// Placed world chest shell — lock/unlock via Enter or `l`.
142    pub is_chest_shell: bool,
143    pub section: InventorySection,
144}
145
146/// A placed chest within `CONTAINER_RANGE_M`, with its contents pre-flattened for
147/// the browser (empty when locked without the matching key).
148#[derive(Debug, Clone)]
149pub struct NearbyContainer {
150    pub view: flatland_protocol::PlacedContainerView,
151    pub distance_m: f32,
152    pub rows: Vec<InventoryRow>,
153}
154
155/// A destination the currently-picked item could be moved to.
156#[derive(Debug, Clone)]
157pub struct MoveOption {
158    pub label: String,
159    pub kind: MoveOptionKind,
160}
161
162#[derive(Debug, Clone, PartialEq)]
163pub enum MoveOptionKind {
164    Move {
165        location: flatland_protocol::InventoryLocation,
166        parent_instance_id: Option<uuid::Uuid>,
167    },
168    Drop,
169    Cancel,
170}
171
172/// Active "move to…" destination picker state for the selected inventory item.
173#[derive(Debug, Clone)]
174pub struct MovePicker {
175    pub item_instance_id: uuid::Uuid,
176    pub from: flatland_protocol::InventoryLocation,
177    pub item_label: String,
178    pub template_id: String,
179    pub stack_quantity: u32,
180    pub quantity: u32,
181    pub options: Vec<MoveOption>,
182}
183
184/// Active permanent-delete picker for the selected inventory item.
185#[derive(Debug, Clone)]
186pub struct DestroyPicker {
187    pub item_instance_id: uuid::Uuid,
188    pub from: flatland_protocol::InventoryLocation,
189    pub item_label: String,
190    pub stack_quantity: u32,
191    pub quantity: u32,
192}
193
194fn push_inventory_rows(
195    rows: &mut Vec<InventoryRow>,
196    depth: usize,
197    stack: &flatland_protocol::ItemStack,
198    from: &flatland_protocol::InventoryLocation,
199    from_parent_instance_id: Option<uuid::Uuid>,
200    section: InventorySection,
201) {
202    rows.push(InventoryRow {
203        depth,
204        stack: stack.clone(),
205        from: from.clone(),
206        from_parent_instance_id,
207        is_equip_shell: false,
208        is_chest_shell: false,
209        section,
210    });
211    for child in &stack.contents {
212        push_inventory_rows(
213            rows,
214            depth + 1,
215            child,
216            from,
217            stack.item_instance_id,
218            section,
219        );
220    }
221}
222
223#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
224pub enum ShopTab {
225    #[default]
226    Buy,
227    Sell,
228}
229
230#[derive(Debug, Clone)]
231pub struct GameState {
232    pub session_id: SessionId,
233    pub entity_id: EntityId,
234    /// Logged-in character — used to show owner-only container labels.
235    pub character_id: Option<uuid::Uuid>,
236    pub tick: Tick,
237    pub chunk_rev: u64,
238    pub content_rev: u64,
239    pub entities: Vec<EntityState>,
240    pub player: Option<EntityState>,
241    pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
242    pub ground_drops: Vec<flatland_protocol::GroundDropView>,
243    pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
244    pub buildings: Vec<BuildingView>,
245    pub doors: Vec<DoorView>,
246    pub npcs: Vec<NpcView>,
247    pub blueprints: Vec<BlueprintView>,
248    pub world_width_m: f32,
249    pub world_height_m: f32,
250    pub terrain_zones: Vec<TerrainZoneView>,
251    pub world_clock: flatland_protocol::WorldClock,
252    pub inventory: std::collections::HashMap<String, u32>,
253    pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
254    pub logs: VecDeque<String>,
255    pub intents_sent: u64,
256    pub ticks_received: u64,
257    pub connected: bool,
258    pub disconnect_reason: Option<String>,
259    pub show_stats: bool,
260    pub show_craft_menu: bool,
261    pub craft_menu_index: usize,
262    /// How many timed crafts to queue when confirming the craft menu.
263    pub craft_batch_quantity: u32,
264    pub show_shop_menu: bool,
265    pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
266    pub shop_tab: ShopTab,
267    pub shop_menu_index: usize,
268    pub shop_quantity: u32,
269    pub show_inventory_menu: bool,
270    pub inventory_menu_index: usize,
271    pub show_move_picker: bool,
272    pub move_picker_index: usize,
273    pub move_picker: Option<MovePicker>,
274    pub show_destroy_picker: bool,
275    pub destroy_confirm_pending: bool,
276    pub destroy_picker: Option<DestroyPicker>,
277    /// Rename prompt for a selected container (`n` in inventory).
278    pub show_rename_prompt: bool,
279    pub rename_buffer: String,
280    /// Slot-1 combat target (mirrors server after SetTarget).
281    pub combat_target: Option<EntityId>,
282    pub combat_target_label: Option<String>,
283    pub in_combat: bool,
284    pub auto_attack: bool,
285    pub combat_has_los: bool,
286    pub attack_cd_ticks: u64,
287    pub gcd_ticks: u64,
288    pub weapon_ability_id: String,
289    pub mainhand_template_id: Option<String>,
290    pub mainhand_label: Option<String>,
291    /// Worn body-slot items — backpack (`Back`), belt w/ clipped pouches (`Waist`), and
292    /// future armor. At most one item per slot (`plans/08` §4.1).
293    pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
294    pub carry_mass: f32,
295    pub carry_mass_max: f32,
296    pub encumbrance: flatland_protocol::EncumbranceState,
297    /// Full nested inventory stacks from the server (root only; worn are separate).
298    pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
299    pub combat_target_detail: Option<CombatTargetHud>,
300    pub cast_progress: Option<CastProgressHud>,
301    pub ability_cooldowns: Vec<AbilityCooldownHud>,
302    pub blocking_active: bool,
303    pub max_target_slots: u8,
304    pub combat_slots: Vec<CombatSlotHud>,
305    pub rotation_presets: Vec<RotationPreset>,
306    pub show_loadout_menu: bool,
307    pub show_rotation_editor: bool,
308    pub loadout_menu_index: usize,
309    pub rotation_editor: RotationEditorState,
310    /// True after a harvest intent is accepted until result/reject/disconnect.
311    pub harvest_in_progress: bool,
312    /// Wall-clock start of the current harvest; clears stale client state on timeout.
313    pub harvest_started_at: Option<Instant>,
314    /// Craft log deferred until the server acks the craft intent.
315    pub pending_craft_ack: Option<(u32, String, u32)>,
316}
317
318impl GameState {
319    pub fn push_log(&mut self, line: impl Into<String>) {
320        self.logs.push_back(line.into());
321        while self.logs.len() > MAX_LOG_LINES {
322            self.logs.pop_front();
323        }
324    }
325
326    pub fn is_alive(&self) -> bool {
327        self.player
328            .as_ref()
329            .and_then(|p| p.vitals)
330            .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
331            .unwrap_or(true)
332    }
333
334    pub fn clear_harvest_state(&mut self) {
335        self.harvest_in_progress = false;
336        self.harvest_started_at = None;
337    }
338
339    fn harvest_state_stale(&self) -> bool {
340        match self.harvest_started_at {
341            Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
342            None => self.harvest_in_progress,
343        }
344    }
345
346    pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
347        self.player.as_ref().and_then(|p| p.vitals)
348    }
349
350    pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
351        let materials_ok = blueprint.inputs.iter().all(|input| {
352            self.inventory
353                .get(&input.template_id)
354                .copied()
355                .unwrap_or(0)
356                >= input.quantity
357        });
358        let tools_ok = blueprint.required_tools.iter().all(|tool| {
359            self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1
360        });
361        let station_ok = match blueprint.station.as_deref() {
362            None | Some("hand") => true,
363            Some(tag) => self.player_at_station_tag(tag),
364        };
365        materials_ok && tools_ok && station_ok
366    }
367
368    pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
369        if !self.can_craft_blueprint(blueprint) {
370            return 0;
371        }
372        let mut limit = u32::MAX;
373        for input in &blueprint.inputs {
374            if input.quantity == 0 {
375                continue;
376            }
377            let have = self
378                .inventory
379                .get(&input.template_id)
380                .copied()
381                .unwrap_or(0);
382            limit = limit.min(have / input.quantity);
383        }
384        for tool in &blueprint.required_tools {
385            if tool.consumed {
386                let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
387                limit = limit.min(have);
388            }
389        }
390        let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
391        if CRAFT_STAMINA_COST > 0.0 {
392            limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
393        }
394        limit
395    }
396
397    pub fn clamp_craft_batch_quantity(&mut self) {
398        let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
399            self.craft_batch_quantity = 1;
400            return;
401        };
402        let max = self.max_craft_batches(bp).max(1);
403        self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
404    }
405
406    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
407        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
408            return;
409        };
410        let max = self.max_craft_batches(&bp).max(1);
411        let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
412        self.craft_batch_quantity = next as u32;
413    }
414
415    pub fn craft_batch_set_max(&mut self) {
416        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
417            return;
418        };
419        let max = self.max_craft_batches(&bp);
420        self.craft_batch_quantity = if max == 0 { 1 } else { max };
421    }
422
423    pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
424        let preserve_ui = self.show_shop_menu;
425        let tab = self.shop_tab;
426        let index = self.shop_menu_index;
427        let qty = self.shop_quantity;
428
429        self.show_shop_menu = true;
430        self.show_craft_menu = false;
431        self.show_inventory_menu = false;
432        self.show_stats = false;
433        self.shop_catalog = Some(catalog);
434
435        if preserve_ui {
436            self.shop_tab = tab;
437            self.shop_menu_index = index;
438            self.shop_quantity = qty;
439        } else {
440            self.shop_tab = ShopTab::Buy;
441            self.shop_menu_index = 0;
442            self.shop_quantity = 1;
443        }
444        self.clamp_shop_selection();
445    }
446
447    pub fn shop_list_len(&self) -> usize {
448        let Some(catalog) = &self.shop_catalog else {
449            return 0;
450        };
451        match self.shop_tab {
452            ShopTab::Buy => catalog.sells.len(),
453            ShopTab::Sell => catalog.buys.len(),
454        }
455    }
456
457    pub fn shop_menu_move(&mut self, delta: i32) {
458        let n = self.shop_list_len();
459        if n == 0 {
460            return;
461        }
462        let idx = self.shop_menu_index as i32;
463        let next = (idx + delta).rem_euclid(n as i32);
464        self.shop_menu_index = next as usize;
465        self.clamp_shop_quantity();
466    }
467
468    pub fn shop_quantity_adjust(&mut self, delta: i32) {
469        let max = self.shop_quantity_max();
470        let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
471        self.shop_quantity = next as u32;
472    }
473
474    pub(crate) fn clamp_shop_selection(&mut self) {
475        let n = self.shop_list_len();
476        if n == 0 {
477            self.shop_menu_index = 0;
478        } else {
479            self.shop_menu_index = self.shop_menu_index.min(n - 1);
480        }
481        self.clamp_shop_quantity();
482    }
483
484    fn shop_quantity_max(&self) -> u32 {
485        let Some(catalog) = &self.shop_catalog else {
486            return 1;
487        };
488        match self.shop_tab {
489            ShopTab::Buy => {
490                if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
491                    if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
492                        return 1;
493                    }
494                }
495                99
496            }
497            ShopTab::Sell => catalog
498                .buys
499                .get(self.shop_menu_index)
500                .map(|l| l.quantity)
501                .unwrap_or(1)
502                .max(1),
503        }
504    }
505
506    pub fn shop_quantity_set_max(&mut self) {
507        self.shop_quantity = self.shop_quantity_max();
508    }
509
510    fn clamp_shop_quantity(&mut self) {
511        self.shop_quantity = self.shop_quantity.clamp(1, self.shop_quantity_max());
512    }
513
514    pub fn player_at_station_tag(&self, tag: &str) -> bool {
515        let (px, py) = self.player_position();
516        self.building_interior_at(px, py)
517            .is_some_and(|b| b.tags.iter().any(|t| t == tag))
518    }
519
520    /// Short hint for UI when a recipe cannot be started.
521    pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
522        if self.can_craft_blueprint(blueprint) {
523            return None;
524        }
525        let mut missing = Vec::new();
526        for input in &blueprint.inputs {
527            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
528            if have < input.quantity {
529                missing.push(format!("{}×{} (have {have})", input.quantity, input.template_id));
530            }
531        }
532        for tool in &blueprint.required_tools {
533            let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
534            if have < 1 {
535                missing.push(format!("tool: {}", tool.item));
536            }
537        }
538        if let Some(station) = blueprint.station.as_deref() {
539            if station != "hand" && !self.player_at_station_tag(station) {
540                missing.push(format!("station: {station} (enter building)"));
541            }
542        }
543        if missing.is_empty() {
544            None
545        } else {
546            Some(missing.join(", "))
547        }
548    }
549
550    pub fn player_entity(&self) -> Option<&EntityState> {
551        self.player
552            .as_ref()
553            .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
554    }
555
556    pub fn player_position(&self) -> (f32, f32) {
557        self.player_entity()
558            .map(|p| (p.transform.position.x, p.transform.position.y))
559            .unwrap_or((0.0, 0.0))
560    }
561
562    pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
563        let mut rows: Vec<(String, u32, String)> = self
564            .inventory
565            .iter()
566            .filter(|(_, q)| **q > 0)
567            .map(|(id, qty)| {
568                let label = self
569                    .inventory_hints
570                    .get(id)
571                    .map(|h| h.display_name.clone())
572                    .unwrap_or_else(|| id.clone());
573                (id.clone(), *qty, label)
574            })
575            .collect();
576        rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
577        rows
578    }
579
580    pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
581        self.inventory_hints
582            .get(template_id)
583            .map(|h| h.category.as_str())
584            .filter(|c| !c.is_empty())
585    }
586
587    pub fn item_base_mass(&self, template_id: &str) -> f32 {
588        self.inventory_hints
589            .get(template_id)
590            .and_then(|h| h.base_mass)
591            .unwrap_or(0.5)
592    }
593
594    pub fn item_base_volume(&self, template_id: &str) -> f32 {
595        self.inventory_hints
596            .get(template_id)
597            .and_then(|h| h.base_volume)
598            .unwrap_or(1.0)
599    }
600
601    pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
602        let unit = stack
603            .base_mass
604            .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
605        unit * stack.quantity as f32
606    }
607
608    fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
609        let unit = stack.base_volume.unwrap_or(1.0);
610        unit * stack.quantity as f32
611            + stack
612                .contents
613                .iter()
614                .map(Self::stack_tree_volume)
615                .sum::<f32>()
616    }
617
618    fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
619        contents.iter().map(Self::stack_tree_volume).sum()
620    }
621
622    fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
623        self.inventory_hints
624            .get(template_id)
625            .and_then(|h| h.capacity_volume)
626            .filter(|c| *c > 0.0)
627    }
628
629    fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
630        stack
631            .capacity_volume
632            .filter(|c| *c > 0.0)
633            .or_else(|| self.template_capacity_volume(&stack.template_id))
634    }
635
636    /// Volume used / capacity / free space label for storage containers in the inventory UI.
637    pub fn container_volume_label(&self, row: &InventoryRow) -> String {
638        let Some((used, cap)) = self.container_volume_stats(row) else {
639            return String::new();
640        };
641        let free = (cap - used).max(0.0);
642        format!("  vol {used:.0}/{cap:.0} ({free:.0} free)")
643    }
644
645    fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
646        if row.is_chest_shell {
647            let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
648                return None;
649            };
650            let chest = self
651                .placed_containers
652                .iter()
653                .find(|c| c.id == *container_id)?;
654            let cap = self
655                .stack_capacity_volume(&row.stack)
656                .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
657            let used = if chest.accessible {
658                Self::contents_used_volume(&chest.contents)
659            } else {
660                0.0
661            };
662            return Some((used, cap));
663        }
664
665        let cap = self.stack_capacity_volume(&row.stack)?;
666        let used = Self::contents_used_volume(&row.stack.contents);
667        Some((used, cap))
668    }
669
670    pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
671        if row.is_chest_shell {
672            return true;
673        }
674        if row.is_equip_shell {
675            return self.inventory_item_category(&row.stack.template_id) == Some("container");
676        }
677        self.inventory_item_category(&row.stack.template_id) == Some("container")
678            || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
679    }
680
681    fn container_stack_for(
682        &self,
683        location: &flatland_protocol::InventoryLocation,
684        parent_instance_id: Option<uuid::Uuid>,
685    ) -> Option<flatland_protocol::ItemStack> {
686        match location {
687            flatland_protocol::InventoryLocation::Root => {
688                let pid = parent_instance_id?;
689                self.find_stack_by_instance(&self.inventory_stacks, pid)
690            }
691            flatland_protocol::InventoryLocation::Worn { slot } => {
692                let worn = self.worn.get(slot)?;
693                if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
694                    Some(worn.clone())
695                } else {
696                    self.find_stack_by_instance(&worn.contents, parent_instance_id?)
697                }
698            }
699            flatland_protocol::InventoryLocation::Placed { container_id } => {
700                let chest = self.placed_containers.iter().find(|c| c.id == *container_id)?;
701                if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
702                    Some(flatland_protocol::ItemStack {
703                        template_id: chest.template_id.clone(),
704                        quantity: 1,
705                        item_instance_id: chest.item_instance_id,
706                        props: Default::default(),
707                        contents: chest.contents.clone(),
708                        display_name: Some(chest.display_name.clone()),
709                        category: Some("container".into()),
710                        base_mass: None,
711                        base_volume: None,
712                        capacity_volume: self
713                            .inventory_hints
714                            .get(&chest.template_id)
715                            .and_then(|h| h.capacity_volume),
716                        stackable: None,
717                    })
718                } else {
719                    self.find_stack_by_instance(&chest.contents, parent_instance_id?)
720                }
721            }
722        }
723    }
724
725    fn find_stack_by_instance(
726        &self,
727        stacks: &[flatland_protocol::ItemStack],
728        instance_id: uuid::Uuid,
729    ) -> Option<flatland_protocol::ItemStack> {
730        for stack in stacks {
731            if stack.item_instance_id == Some(instance_id) {
732                return Some(stack.clone());
733            }
734            if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
735                return Some(found);
736            }
737        }
738        None
739    }
740
741    /// Client-side estimate of how many units can move to `to` (server clamps authoritatively).
742    pub fn max_movable_to(
743        &self,
744        template_id: &str,
745        stack_qty: u32,
746        from: &flatland_protocol::InventoryLocation,
747        to: &flatland_protocol::InventoryLocation,
748        parent_instance_id: Option<uuid::Uuid>,
749    ) -> u32 {
750        let unit_vol = self.item_base_volume(template_id);
751        let unit_mass = self.item_base_mass(template_id);
752        let mut limit = stack_qty;
753
754        if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
755            let cap = parent
756                .capacity_volume
757                .or_else(|| {
758                    self.inventory_hints
759                        .get(&parent.template_id)
760                        .and_then(|h| h.capacity_volume)
761                })
762                .unwrap_or(0.0);
763            if cap > 0.0 && unit_vol > 0.0 {
764                let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
765                limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
766            }
767        }
768
769        let to_person = matches!(
770            to,
771            flatland_protocol::InventoryLocation::Root | flatland_protocol::InventoryLocation::Worn { .. }
772        );
773        let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
774        if to_person && from_placed && unit_mass > 0.0 {
775            let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
776            if self.encumbrance == flatland_protocol::EncumbranceState::Over {
777                limit = 0;
778            } else {
779                limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
780            }
781        }
782
783        limit.max(0).min(stack_qty)
784    }
785
786    pub fn move_picker_max_at_selection(&self) -> u32 {
787        let Some(picker) = &self.move_picker else {
788            return 1;
789        };
790        let Some(opt) = picker.options.get(self.move_picker_index) else {
791            return picker.stack_quantity;
792        };
793        match &opt.kind {
794            MoveOptionKind::Cancel | MoveOptionKind::Drop => picker.stack_quantity,
795            MoveOptionKind::Move {
796                location,
797                parent_instance_id,
798            } => self.max_movable_to(
799                &picker.template_id,
800                picker.stack_quantity,
801                &picker.from,
802                location,
803                *parent_instance_id,
804            ),
805        }
806    }
807
808    pub fn clamp_move_picker_quantity(&mut self) {
809        let max = self.move_picker_max_at_selection();
810        if let Some(picker) = &mut self.move_picker {
811            if max == 0 {
812                picker.quantity = 1;
813            } else {
814                picker.quantity = picker.quantity.clamp(1, max);
815            }
816        }
817    }
818
819    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
820        let max = self.move_picker_max_at_selection().max(1);
821        if let Some(picker) = &mut self.move_picker {
822            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
823            picker.quantity = next as u32;
824        }
825    }
826
827    pub fn move_picker_set_quantity_max(&mut self) {
828        let max = self.move_picker_max_at_selection();
829        if let Some(picker) = &mut self.move_picker {
830            picker.quantity = if max == 0 { 1 } else { max.min(picker.stack_quantity) };
831        }
832    }
833
834    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
835        if let Some(picker) = &mut self.destroy_picker {
836            let max = picker.stack_quantity.max(1);
837            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
838            picker.quantity = next as u32;
839        }
840    }
841
842    pub fn destroy_picker_set_quantity_max(&mut self) {
843        if let Some(picker) = &mut self.destroy_picker {
844            picker.quantity = picker.stack_quantity.max(1);
845        }
846    }
847
848    pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
849        let have = self.inventory.get(template_id).copied().unwrap_or(0);
850        (have, have >= need)
851    }
852
853    pub fn currency_display(&self) -> String {
854        crate::currency::currency_line(&self.inventory)
855    }
856
857    /// True when standing in a shallow-water terrain zone from the segment snapshot.
858    pub fn in_shallow_water(&self) -> bool {
859        let (px, py) = self.player_position();
860        self.terrain_at(px, py)
861            .is_some_and(|k| k == TerrainKindView::ShallowWater)
862    }
863
864    pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
865        self.terrain_zones.iter().find_map(|z| {
866            if x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1 {
867                Some(z.kind)
868            } else {
869                None
870            }
871        })
872    }
873
874    /// Building interior the player is standing in, if any (position-based).
875    pub fn building_interior_at(&self, px: f32, py: f32) -> Option<&BuildingView> {
876        self.buildings
877            .iter()
878            .find(|b| point_in_building_interior(px, py, b))
879    }
880
881    /// Interior instance map to render (ignores stale `inside_building` when outdoors).
882    pub fn effective_inside_building(&self) -> Option<String> {
883        let (px, py) = self.player_position();
884        if let Some(b) = self.building_interior_at(px, py) {
885            return Some(b.id.clone());
886        }
887        if is_instanced_world_coord(px, py, self.world_width_m, self.world_height_m) {
888            if let Some(id) = self
889                .player_entity()
890                .and_then(|p| p.inside_building.clone())
891            {
892                return Some(id);
893            }
894            if self.buildings.len() == 1 {
895                return Some(self.buildings[0].id.clone());
896            }
897        }
898        None
899    }
900
901    pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
902        self.inventory_stacks = stacks.to_vec();
903        self.inventory.clear();
904        self.inventory_hints.clear();
905        fn walk(
906            stacks: &[flatland_protocol::ItemStack],
907            inventory: &mut std::collections::HashMap<String, u32>,
908            hints: &mut std::collections::HashMap<String, InventoryHint>,
909        ) {
910            for stack in stacks {
911                *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
912                if stack.display_name.is_some()
913                    || stack.category.is_some()
914                    || stack.base_mass.is_some()
915                    || stack.base_volume.is_some()
916                {
917                    hints.insert(
918                        stack.template_id.clone(),
919                        InventoryHint {
920                            display_name: stack
921                                .display_name
922                                .clone()
923                                .unwrap_or_else(|| stack.template_id.clone()),
924                            category: stack.category.clone().unwrap_or_default(),
925                            base_mass: stack.base_mass,
926                            base_volume: stack.base_volume,
927                            capacity_volume: stack.capacity_volume,
928                            stackable: stack.stackable.unwrap_or(true),
929                        },
930                    );
931                }
932                walk(&stack.contents, inventory, hints);
933            }
934        }
935        walk(stacks, &mut self.inventory, &mut self.inventory_hints);
936        // Include worn items (and nested contents, e.g. belt-clipped pouches) in craft counts.
937        for item in self.worn.values() {
938            walk(std::slice::from_ref(item), &mut self.inventory, &mut self.inventory_hints);
939        }
940    }
941
942    /// Worn body-slot items — each shown as a shell row (unequip via Enter) followed by
943    /// its nested contents (e.g. pouches clipped onto a worn belt). `BodySlot` derives
944    /// `Ord` in display order (Head/Body/Arms/Legs/Feet/Back/Waist), so `BTreeMap`
945    /// iteration alone gives a stable row order.
946    pub fn worn_rows(&self) -> Vec<InventoryRow> {
947        let mut rows = Vec::new();
948        for (slot, item) in &self.worn {
949            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
950            rows.push(InventoryRow {
951                depth: 0,
952                stack: item.clone(),
953                from: from.clone(),
954                from_parent_instance_id: None,
955                is_equip_shell: true,
956                is_chest_shell: false,
957                section: InventorySection::Worn,
958            });
959            for child in &item.contents {
960                push_inventory_rows(
961                    &mut rows,
962                    1,
963                    child,
964                    &from,
965                    item.item_instance_id,
966                    InventorySection::Worn,
967                );
968            }
969        }
970        rows
971    }
972
973    /// Loose on-person inventory (not worn, not inside a placed chest).
974    pub fn person_rows(&self) -> Vec<InventoryRow> {
975        let mut rows = Vec::new();
976        for stack in &self.inventory_stacks {
977            push_inventory_rows(
978                &mut rows,
979                0,
980                stack,
981                &flatland_protocol::InventoryLocation::Root,
982                None,
983                InventorySection::Person,
984            );
985        }
986        rows
987    }
988
989    /// Legacy alias used by the HUD sidebar summary (worn + on-person, unchanged).
990    pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
991        let mut rows = self.worn_rows();
992        rows.extend(self.person_rows());
993        rows.into_iter().map(|r| (r.depth, r.stack)).collect()
994    }
995
996    /// Placed chests within `CONTAINER_RANGE_M`, nearest first. Contents are only
997    /// populated when `accessible` — this is what makes a chest's contents
998    /// disappear the moment you walk away or it's locked without your key.
999    pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
1000        let (px, py) = self.player_position();
1001        let mut list: Vec<NearbyContainer> = self
1002            .placed_containers
1003            .iter()
1004            .filter_map(|c| {
1005                let distance_m = (c.x - px).hypot(c.y - py);
1006                if distance_m > CONTAINER_RANGE_M {
1007                    return None;
1008                }
1009                let mut rows = Vec::new();
1010                let from = flatland_protocol::InventoryLocation::Placed {
1011                    container_id: c.id.clone(),
1012                };
1013                rows.push(InventoryRow {
1014                    depth: 0,
1015                    stack: flatland_protocol::ItemStack {
1016                        template_id: c.template_id.clone(),
1017                        quantity: 1,
1018                        item_instance_id: c.item_instance_id,
1019                        props: Default::default(),
1020                        contents: Vec::new(),
1021                        display_name: Some(c.display_name.clone()),
1022                        category: Some("container".into()),
1023                        base_mass: None,
1024                        base_volume: None,
1025                        capacity_volume: c.capacity_volume,
1026                        stackable: None,
1027                    },
1028                    from: from.clone(),
1029                    from_parent_instance_id: None,
1030                    is_equip_shell: false,
1031                    is_chest_shell: true,
1032                    section: InventorySection::Nearby,
1033                });
1034                if c.accessible {
1035                    for child in &c.contents {
1036                        push_inventory_rows(
1037                            &mut rows,
1038                            1,
1039                            child,
1040                            &from,
1041                            c.item_instance_id,
1042                            InventorySection::Nearby,
1043                        );
1044                    }
1045                }
1046                Some(NearbyContainer {
1047                    view: c.clone(),
1048                    distance_m,
1049                    rows,
1050                })
1051            })
1052            .collect();
1053        list.sort_by(|a, b| {
1054            a.distance_m
1055                .partial_cmp(&b.distance_m)
1056                .unwrap_or(std::cmp::Ordering::Equal)
1057        });
1058        list
1059    }
1060
1061    /// Nearest placed chest within `max_dist`, regardless of accessibility.
1062    pub fn nearest_placed_container(
1063        &self,
1064        max_dist: f32,
1065    ) -> Option<flatland_protocol::PlacedContainerView> {
1066        let (px, py) = self.player_position();
1067        self.placed_containers
1068            .iter()
1069            .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
1070            .min_by(|a, b| {
1071                let da = (a.x - px).hypot(a.y - py);
1072                let db = (b.x - px).hypot(b.y - py);
1073                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
1074            })
1075            .cloned()
1076    }
1077
1078    /// Full ordered list of *selectable* rows: worn ++ on-person ++ each nearby
1079    /// accessible chest's contents (nearest chest first). This single order drives
1080    /// `inventory_menu_index`; the renderer must build its grouped headers by
1081    /// walking `worn_rows()` / `person_rows()` / `nearby_containers()` in the same
1082    /// sequence so the highlighted row always matches.
1083    pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
1084        let mut rows = self.worn_rows();
1085        rows.extend(self.person_rows());
1086        for nc in self.nearby_containers() {
1087            rows.extend(nc.rows);
1088        }
1089        rows
1090    }
1091
1092    pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
1093        self.inventory_selectable_rows()
1094            .into_iter()
1095            .nth(self.inventory_menu_index)
1096    }
1097
1098    /// Build the "move to…" destination list for an item currently at `from`.
1099    pub fn move_destinations_for(
1100        &self,
1101        from: &flatland_protocol::InventoryLocation,
1102        from_parent_instance_id: Option<uuid::Uuid>,
1103        moving_instance_id: Option<uuid::Uuid>,
1104        moving_template_id: &str,
1105    ) -> Vec<MoveOption> {
1106        let mut opts = Vec::new();
1107        if *from != flatland_protocol::InventoryLocation::Root {
1108            opts.push(MoveOption {
1109                label: "On your person (loose)".into(),
1110                kind: MoveOptionKind::Move {
1111                    location: flatland_protocol::InventoryLocation::Root,
1112                    parent_instance_id: None,
1113                },
1114            });
1115        }
1116        for (slot, item) in &self.worn {
1117            if item.category.as_deref() != Some("container") {
1118                continue;
1119            }
1120            let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
1121            let shell_name = item
1122                .display_name
1123                .clone()
1124                .unwrap_or_else(|| item.template_id.clone());
1125
1126            // Backpack and other worn volume containers — store directly inside the shell.
1127            if *slot != BodySlot::Waist
1128                && item.item_instance_id != moving_instance_id
1129                && Self::is_volume_container_stack(item)
1130            {
1131                Self::push_move_destination(
1132                    &mut opts,
1133                    format!("{shell_name} (worn {})", body_slot_label(*slot)),
1134                    location.clone(),
1135                    item.item_instance_id,
1136                    from,
1137                    from_parent_instance_id,
1138                );
1139            }
1140
1141            // Belt loops only accept pouch attachments — not loose materials.
1142            if *slot == BodySlot::Waist
1143                && Self::attaches_to_belt_loop(moving_template_id)
1144                && item.item_instance_id != moving_instance_id
1145            {
1146                Self::push_move_destination(
1147                    &mut opts,
1148                    format!("{shell_name} (belt loop)"),
1149                    location.clone(),
1150                    item.item_instance_id,
1151                    from,
1152                    from_parent_instance_id,
1153                );
1154            }
1155
1156            let context = if *slot == BodySlot::Waist {
1157                format!("on {shell_name}")
1158            } else {
1159                format!("in {shell_name}")
1160            };
1161            Self::append_nested_container_destinations(
1162                &mut opts,
1163                location,
1164                item,
1165                &context,
1166                from,
1167                from_parent_instance_id,
1168                moving_instance_id,
1169            );
1170        }
1171        for nc in self.nearby_containers() {
1172            if !nc.view.accessible {
1173                continue;
1174            }
1175            let location = flatland_protocol::InventoryLocation::Placed {
1176                container_id: nc.view.id.clone(),
1177            };
1178            Self::push_move_destination(
1179                &mut opts,
1180                format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
1181                location,
1182                nc.view.item_instance_id,
1183                from,
1184                from_parent_instance_id,
1185            );
1186        }
1187        let allow_drop = moving_instance_id
1188            .and_then(|id| self.stack_for_instance(id))
1189            .map(|stack| !self.key_drop_blocked(&stack))
1190            .unwrap_or(moving_template_id != KEY_TEMPLATE);
1191        if allow_drop {
1192            opts.push(MoveOption {
1193                label: "Drop on the ground".into(),
1194                kind: MoveOptionKind::Drop,
1195            });
1196        }
1197        opts.push(MoveOption {
1198            label: "Cancel".into(),
1199            kind: MoveOptionKind::Cancel,
1200        });
1201        opts
1202    }
1203
1204    fn is_same_container_dest(
1205        dest_location: &flatland_protocol::InventoryLocation,
1206        dest_parent: Option<uuid::Uuid>,
1207        from: &flatland_protocol::InventoryLocation,
1208        from_parent: Option<uuid::Uuid>,
1209    ) -> bool {
1210        dest_location == from && dest_parent == from_parent
1211    }
1212
1213    fn push_move_destination(
1214        opts: &mut Vec<MoveOption>,
1215        label: String,
1216        location: flatland_protocol::InventoryLocation,
1217        parent_instance_id: Option<uuid::Uuid>,
1218        from: &flatland_protocol::InventoryLocation,
1219        from_parent_instance_id: Option<uuid::Uuid>,
1220    ) {
1221        if Self::is_same_container_dest(
1222            &location,
1223            parent_instance_id,
1224            from,
1225            from_parent_instance_id,
1226        ) {
1227            return;
1228        }
1229        opts.push(MoveOption {
1230            label,
1231            kind: MoveOptionKind::Move {
1232                location,
1233                parent_instance_id,
1234            },
1235        });
1236    }
1237
1238    fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
1239        stack.capacity_volume.is_some_and(|c| c > 0.0)
1240    }
1241
1242    fn attaches_to_belt_loop(template_id: &str) -> bool {
1243        matches!(template_id, "leather_pouch" | "dimensional_pouch")
1244    }
1245
1246    fn append_nested_container_destinations(
1247        opts: &mut Vec<MoveOption>,
1248        location: flatland_protocol::InventoryLocation,
1249        container: &flatland_protocol::ItemStack,
1250        context: &str,
1251        from: &flatland_protocol::InventoryLocation,
1252        from_parent_instance_id: Option<uuid::Uuid>,
1253        moving_instance_id: Option<uuid::Uuid>,
1254    ) {
1255        for child in &container.contents {
1256            if Self::is_volume_container_stack(child)
1257                && child.item_instance_id != moving_instance_id
1258            {
1259                let name = child
1260                    .display_name
1261                    .clone()
1262                    .unwrap_or_else(|| child.template_id.clone());
1263                Self::push_move_destination(
1264                    opts,
1265                    format!("{name} ({context})"),
1266                    location.clone(),
1267                    child.item_instance_id,
1268                    from,
1269                    from_parent_instance_id,
1270                );
1271            }
1272            let nested_context = format!(
1273                "in {}",
1274                child
1275                    .display_name
1276                    .as_deref()
1277                    .unwrap_or(&child.template_id)
1278            );
1279            Self::append_nested_container_destinations(
1280                opts,
1281                location.clone(),
1282                child,
1283                &nested_context,
1284                from,
1285                from_parent_instance_id,
1286                moving_instance_id,
1287            );
1288        }
1289    }
1290
1291    fn clamp_inventory_indices(&mut self) {
1292        let n = self.inventory_selectable_rows().len();
1293        self.inventory_menu_index = if n == 0 {
1294            0
1295        } else {
1296            self.inventory_menu_index.min(n - 1)
1297        };
1298        if let Some(picker) = &self.move_picker {
1299            let pn = picker.options.len();
1300            self.move_picker_index = if pn == 0 {
1301                0
1302            } else {
1303                self.move_picker_index.min(pn - 1)
1304            };
1305        }
1306    }
1307
1308    fn apply_snapshot_fields(&mut self, snapshot: &flatland_protocol::Snapshot, entity_id: EntityId) {
1309        self.tick = snapshot.tick;
1310        self.chunk_rev = snapshot.chunk_rev;
1311        self.content_rev = snapshot.content_rev;
1312        self.resource_nodes = snapshot.resource_nodes.clone();
1313        self.ground_drops = snapshot.ground_drops.clone();
1314        self.placed_containers = snapshot.placed_containers.clone();
1315        self.world_width_m = snapshot.world_width_m;
1316        self.world_height_m = snapshot.world_height_m;
1317        self.world_clock = snapshot.world_clock;
1318        self.terrain_zones = snapshot.terrain_zones.clone();
1319        self.buildings = snapshot.buildings.clone();
1320        self.doors = snapshot.doors.clone();
1321        self.npcs = snapshot.npcs.clone();
1322        self.blueprints = snapshot.blueprints.clone();
1323        self.sync_inventory_from_stacks(&snapshot.inventory);
1324        self.player = snapshot
1325            .entities
1326            .iter()
1327            .find(|e| e.id == entity_id)
1328            .cloned();
1329        self.entities = snapshot.entities.clone();
1330    }
1331
1332    /// Re-derive inventory selection state after a snapshot/tick — chests that
1333    /// went out of range or got locked simply vanish from the row list, and the
1334    /// move picker (if any) closes once its item is no longer reachable.
1335    fn refresh_inventory_ui(&mut self) {
1336        if let Some(picker) = &self.move_picker {
1337            let instance_id = picker.item_instance_id;
1338            let still_exists = self
1339                .inventory_selectable_rows()
1340                .iter()
1341                .any(|r| r.stack.item_instance_id == Some(instance_id));
1342            if !still_exists {
1343                self.move_picker = None;
1344                self.show_move_picker = false;
1345            }
1346        }
1347        if let Some(picker) = &self.destroy_picker {
1348            let instance_id = picker.item_instance_id;
1349            let still_exists = self
1350                .inventory_selectable_rows()
1351                .iter()
1352                .any(|r| r.stack.item_instance_id == Some(instance_id));
1353            if !still_exists {
1354                self.destroy_picker = None;
1355                self.show_destroy_picker = false;
1356                self.destroy_confirm_pending = false;
1357            }
1358        }
1359        self.clamp_inventory_indices();
1360    }
1361
1362    fn apply_combat_hud(&mut self, combat: &CombatHud) {
1363        self.in_combat = combat.in_combat;
1364        self.auto_attack = combat.auto_attack;
1365        self.combat_has_los = combat.has_los;
1366        self.attack_cd_ticks = combat.attack_cd_ticks;
1367        self.gcd_ticks = combat.gcd_ticks;
1368        self.weapon_ability_id = combat.ability_id.clone();
1369        self.mainhand_template_id = combat.mainhand_template_id.clone();
1370        self.mainhand_label = combat.mainhand_label.clone();
1371        self.worn = combat.worn.iter().cloned().collect();
1372        self.carry_mass = combat.carry_mass;
1373        self.carry_mass_max = combat.carry_mass_max;
1374        self.encumbrance = combat.encumbrance;
1375        self.cast_progress = combat.cast.clone();
1376        self.ability_cooldowns = combat.ability_cooldowns.clone();
1377        self.blocking_active = combat.blocking_active;
1378        self.max_target_slots = combat.max_target_slots.max(1);
1379        self.combat_slots = combat.slots.clone();
1380        self.rotation_presets = combat.rotation_presets.clone();
1381        self.combat_target_detail = combat.target.clone();
1382        self.combat_target = combat.target_entity_id;
1383        if let Some(label) = &combat.target_label {
1384            self.combat_target_label = Some(label.clone());
1385        } else if let Some(id) = combat.target_entity_id {
1386            self.combat_target_label = self
1387                .entities
1388                .iter()
1389                .find(|e| e.id == id)
1390                .map(|e| e.label.clone())
1391                .or_else(|| self.combat_target_label.clone());
1392        }
1393        self.refresh_inventory_ui();
1394    }
1395
1396    /// Entity id assigned to a combat slot (from server HUD).
1397    pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
1398        self.combat_slots
1399            .iter()
1400            .find(|s| s.slot_index == slot)
1401            .and_then(|s| s.target_entity_id)
1402            .or_else(|| {
1403                if slot == 1 {
1404                    self.combat_target
1405                } else {
1406                    None
1407                }
1408            })
1409    }
1410
1411    /// Hostile wildlife / monsters for T1 (`Tab`).
1412    pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
1413        self.combat_candidates()
1414    }
1415
1416    /// Allies first, then monsters, for T2 (`Shift+Tab`). Includes self for heals.
1417    pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
1418        let (px, py) = self.player_position();
1419        let dist = |id: EntityId| {
1420            self.entities
1421                .iter()
1422                .find(|e| e.id == id)
1423                .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
1424                .unwrap_or(f32::MAX)
1425        };
1426
1427        let mut allies = Vec::new();
1428        // Self first — heal_touch on T2.
1429        if let Some(me) = self.player.as_ref() {
1430            let alive = me
1431                .vitals
1432                .as_ref()
1433                .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1434                .unwrap_or(true);
1435            if alive {
1436                allies.push((self.entity_id, "Yourself".into()));
1437            }
1438        }
1439        for entity in &self.entities {
1440            if entity.id == self.entity_id {
1441                continue;
1442            }
1443            if entity.vitals.is_some() {
1444                let alive = entity
1445                    .vitals
1446                    .as_ref()
1447                    .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1448                    .unwrap_or(true);
1449                if alive {
1450                    allies.push((entity.id, entity.label.clone()));
1451                }
1452            }
1453        }
1454        allies.sort_by(|(a, _), (b, _)| {
1455            if *a == self.entity_id {
1456                return std::cmp::Ordering::Less;
1457            }
1458            if *b == self.entity_id {
1459                return std::cmp::Ordering::Greater;
1460            }
1461            dist(*a)
1462                .partial_cmp(&dist(*b))
1463                .unwrap_or(std::cmp::Ordering::Equal)
1464        });
1465
1466        let mut monsters = self.combat_candidates();
1467        monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
1468        allies.into_iter().chain(monsters).collect()
1469    }
1470
1471    fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
1472        match slot_index {
1473            2 => self.t2_candidates(),
1474            _ => self.t1_candidates(),
1475        }
1476    }
1477
1478    /// Full client reset after Welcome (initial connect or reconnect).
1479    pub(crate) fn restore_from_welcome(
1480        &mut self,
1481        session_id: SessionId,
1482        entity_id: EntityId,
1483        snapshot: &flatland_protocol::Snapshot,
1484    ) {
1485        self.clear_harvest_state();
1486        self.disconnect_reason = None;
1487        self.show_stats = false;
1488        self.show_craft_menu = false;
1489        self.show_shop_menu = false;
1490        self.shop_catalog = None;
1491        self.show_inventory_menu = false;
1492        self.session_id = session_id;
1493        self.entity_id = entity_id;
1494        self.connected = true;
1495        self.apply_snapshot_fields(snapshot, entity_id);
1496        if let Some(combat) = &snapshot.combat {
1497            self.apply_combat_hud(combat);
1498            let stacks = self.inventory_stacks.clone();
1499            self.sync_inventory_from_stacks(&stacks);
1500        }
1501    }
1502
1503    fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
1504        self.tick = delta.tick;
1505        self.world_clock = delta.world_clock;
1506
1507        // Degenerate AOI tick (observer missing server-side): keep welcome snapshot layers.
1508        if delta.entities.is_empty() {
1509            if let Some(combat) = &delta.combat {
1510                self.apply_combat_hud(combat);
1511                let stacks = self.inventory_stacks.clone();
1512                self.sync_inventory_from_stacks(&stacks);
1513            }
1514            return;
1515        }
1516        if !delta.buildings.is_empty() {
1517            self.buildings = delta.buildings.clone();
1518        }
1519        if !delta.blueprints.is_empty() {
1520            self.blueprints = delta.blueprints.clone();
1521        }
1522        self.sync_inventory_from_stacks(&delta.inventory);
1523
1524        if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
1525            self.player = Some(updated.clone());
1526        }
1527        self.entities = delta.entities.clone();
1528        if self.player.is_none() {
1529            self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
1530        }
1531
1532        if let Some(player) = self.player.as_ref() {
1533            let (px, py) = (player.transform.position.x, player.transform.position.y);
1534            let stale_inside = player.inside_building.is_some()
1535                && self.building_interior_at(px, py).is_none()
1536                && !is_instanced_world_coord(px, py, self.world_width_m, self.world_height_m);
1537            if stale_inside {
1538                self.player.as_mut().unwrap().inside_building = None;
1539            }
1540        }
1541
1542        // Static world layers: ticks often omit these (indoors, or unchanged).
1543        // Never wipe the welcome snapshot with an empty vec.
1544        if !delta.resource_nodes.is_empty() {
1545            self.resource_nodes = delta.resource_nodes.clone();
1546        }
1547        if self.player.as_ref().is_none_or(|p| p.inside_building.is_none()) {
1548            self.ground_drops = delta.ground_drops.clone();
1549            self.placed_containers = delta.placed_containers.clone();
1550        }
1551        if !delta.doors.is_empty() {
1552            self.doors = delta.doors.clone();
1553        }
1554        if !delta.npcs.is_empty() {
1555            self.npcs = delta.npcs.clone();
1556        }
1557        if let Some(combat) = &delta.combat {
1558            self.apply_combat_hud(combat);
1559            let stacks = self.inventory_stacks.clone();
1560            self.sync_inventory_from_stacks(&stacks);
1561        } else {
1562            self.refresh_inventory_ui();
1563        }
1564    }
1565
1566    /// Wildlife and other combat targets visible in AOI (`NpcView.entity_id`).
1567    pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
1568        let (px, py) = self.player_position();
1569        let mut out = Vec::new();
1570        for npc in &self.npcs {
1571            let Some(eid) = npc.entity_id else {
1572                continue;
1573            };
1574            let alive = npc
1575                .life_state
1576                .is_none_or(|s| s == LifeState::Alive);
1577            let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
1578            if alive && has_hp {
1579                out.push((eid, npc.label.clone()));
1580            }
1581        }
1582        out.sort_by(|(a_id, a_label), (b_id, b_label)| {
1583            let dist = |id: EntityId| {
1584                self.entities
1585                    .iter()
1586                    .find(|e| e.id == id)
1587                    .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
1588                    .unwrap_or(f32::MAX)
1589            };
1590            dist(*a_id)
1591                .partial_cmp(&dist(*b_id))
1592                .unwrap_or(std::cmp::Ordering::Equal)
1593                .then_with(|| a_label.cmp(b_label))
1594                .then_with(|| a_id.cmp(b_id))
1595        });
1596        out
1597    }
1598
1599    pub fn refresh_combat_target_label(&mut self) {
1600        let Some(id) = self.combat_target else {
1601            return;
1602        };
1603        if let Some((_, label)) = self
1604            .combat_candidates()
1605            .into_iter()
1606            .find(|(eid, _)| *eid == id)
1607        {
1608            self.combat_target_label = Some(label);
1609        } else if let Some(label) = self.entities.iter().find(|e| e.id == id).map(|e| e.label.clone())
1610        {
1611            self.combat_target_label = Some(label);
1612        }
1613    }
1614
1615    /// Nearest interactable target for `f` (doors, NPCs, well, shallow water).
1616    pub fn nearest_interact_target(&self) -> Option<String> {
1617        let (px, py) = self.player_position();
1618        let inside = self.effective_inside_building();
1619
1620        #[derive(Clone, Copy, PartialEq, Eq)]
1621        enum Kind {
1622            Npc,
1623            ExitDoor,
1624            EnterDoor,
1625            Well,
1626            Water,
1627        }
1628
1629        fn kind_priority(kind: Kind) -> u8 {
1630            match kind {
1631                Kind::Npc => 0,
1632                Kind::ExitDoor => 1,
1633                Kind::EnterDoor => 2,
1634                Kind::Well => 3,
1635                Kind::Water => 4,
1636            }
1637        }
1638
1639        let mut best: Option<(f32, Kind, String)> = None;
1640
1641        let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
1642            if dist > max {
1643                return;
1644            }
1645            let replace = match best {
1646                None => true,
1647                Some((bd, _bk, _)) if dist < bd - 0.05 => true,
1648                Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
1649                    kind_priority(kind) < kind_priority(bk)
1650                }
1651                _ => false,
1652            };
1653            if replace {
1654                best = Some((dist, kind, id));
1655            }
1656        };
1657
1658        for npc in &self.npcs {
1659            consider(
1660                distance(px, py, npc.x, npc.y),
1661                INTERACTION_RADIUS_M,
1662                Kind::Npc,
1663                npc.id.clone(),
1664            );
1665        }
1666
1667        for door in &self.doors {
1668            if inside.is_none() && door.is_exit {
1669                continue;
1670            }
1671            if let Some(ref bid) = inside {
1672                if door.building_id != *bid {
1673                    continue;
1674                }
1675            }
1676            let max = if inside.is_some() && door.is_exit {
1677                INTERACTION_RADIUS_M
1678            } else {
1679                DOOR_INTERACTION_RADIUS_M
1680            };
1681            let kind = if door.is_exit {
1682                Kind::ExitDoor
1683            } else {
1684                Kind::EnterDoor
1685            };
1686            consider(
1687                distance(px, py, door.x, door.y),
1688                max,
1689                kind,
1690                door.id.clone(),
1691            );
1692        }
1693
1694        if inside.is_none() {
1695            for building in &self.buildings {
1696                if !building.tags.iter().any(|t| t == "well") {
1697                    continue;
1698                }
1699                consider(
1700                    distance(px, py, building.x, building.y),
1701                    INTERACTION_RADIUS_M,
1702                    Kind::Well,
1703                    building.id.clone(),
1704                );
1705            }
1706            if self.in_shallow_water() {
1707                consider(0.0, INTERACTION_RADIUS_M, Kind::Water, "water_source".into());
1708            }
1709        }
1710
1711        best.map(|(_, _, id)| id)
1712    }
1713
1714    /// Human-readable template name from synced catalog hints.
1715    pub fn template_display_name(&self, template_id: &str) -> String {
1716        self.inventory_hints
1717            .get(template_id)
1718            .map(|h| h.display_name.clone())
1719            .filter(|n| !n.is_empty())
1720            .unwrap_or_else(|| humanize_template_id(template_id))
1721    }
1722
1723    /// Chest label for the location panel — generic type unless this player owns it.
1724    pub fn placed_container_public_label(
1725        &self,
1726        c: &flatland_protocol::PlacedContainerView,
1727    ) -> String {
1728        let is_owner = match (self.character_id, c.owner_character_id) {
1729            (Some(me), Some(owner)) => me == owner,
1730            _ => false,
1731        };
1732        if is_owner {
1733            c.display_name.clone()
1734        } else {
1735            self.template_display_name(&c.template_id)
1736        }
1737    }
1738
1739    /// Display name of the chest a `container_key` opens, when known on the client.
1740    pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
1741        if stack.template_id != KEY_TEMPLATE {
1742            return None;
1743        }
1744        if let Some(name) = stack
1745            .props
1746            .get(PROP_OPENS_CONTAINER_NAME)
1747            .filter(|n| !n.is_empty())
1748        {
1749            return Some(name.clone());
1750        }
1751        let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
1752        self.container_name_for_lock_id(opens)
1753    }
1754
1755    /// Keys always show the catalog name — never a chest rename or stray `custom_name`.
1756    pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
1757        if stack.template_id == KEY_TEMPLATE {
1758            self.template_display_name(KEY_TEMPLATE)
1759        } else {
1760            stack
1761                .display_name
1762                .clone()
1763                .unwrap_or_else(|| stack.template_id.clone())
1764        }
1765    }
1766
1767    /// Hint suffix for a key row (`[key for …]` or `[key — unpaired]`).
1768    pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
1769        if stack.template_id != KEY_TEMPLATE {
1770            return String::new();
1771        }
1772        match self.key_pair_chest_label(stack) {
1773            Some(chest) if self.key_drop_blocked(stack) => {
1774                format!(" [key for {chest} — can't drop while locked]")
1775            }
1776            Some(chest) => format!(" [key for {chest}]"),
1777            None => " [key — unpaired]".into(),
1778        }
1779    }
1780
1781    /// Resolve a lock id to a container label (placed chest or one still on your person).
1782    pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
1783        for c in &self.placed_containers {
1784            if c.lock_id.as_deref() == Some(lock) {
1785                return Some(c.display_name.clone());
1786            }
1787        }
1788        Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
1789            self.worn.values().find_map(|worn| {
1790                Self::container_name_in_stacks(std::slice::from_ref(worn), lock)
1791            })
1792        })
1793    }
1794
1795    /// Keys cannot be dropped while their paired chest is locked.
1796    pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
1797        if stack.template_id != KEY_TEMPLATE {
1798            return false;
1799        }
1800        let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
1801            return false;
1802        };
1803        for c in &self.placed_containers {
1804            if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
1805                return true;
1806            }
1807        }
1808        if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
1809            return true;
1810        }
1811        self.worn.values().any(|worn| {
1812            Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens)
1813        })
1814    }
1815
1816    fn container_name_in_stacks(
1817        stacks: &[flatland_protocol::ItemStack],
1818        lock: &str,
1819    ) -> Option<String> {
1820        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
1821            for s in stacks {
1822                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
1823                    return Some(GameState::stack_container_label(s));
1824                }
1825                if let Some(name) = walk(&s.contents, lock) {
1826                    return Some(name);
1827                }
1828            }
1829            None
1830        }
1831        walk(stacks, lock)
1832    }
1833
1834    fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
1835        stack
1836            .props
1837            .get(PROP_CUSTOM_NAME)
1838            .cloned()
1839            .or_else(|| stack.display_name.clone())
1840            .unwrap_or_else(|| stack.template_id.clone())
1841    }
1842
1843    fn has_locked_container_with_lock(
1844        stacks: &[flatland_protocol::ItemStack],
1845        lock: &str,
1846    ) -> bool {
1847        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
1848            for s in stacks {
1849                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
1850                    return true;
1851                }
1852                if walk(&s.contents, lock) {
1853                    return true;
1854                }
1855            }
1856            false
1857        }
1858        walk(stacks, lock)
1859    }
1860
1861    fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
1862        if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
1863            return Some(stack.clone());
1864        }
1865        for worn in self.worn.values() {
1866            if worn.item_instance_id == Some(instance_id) {
1867                return Some(worn.clone());
1868            }
1869            if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
1870                return Some(stack.clone());
1871            }
1872        }
1873        None
1874    }
1875
1876    /// Terrain, interactables, and map objects near the player for the HUD location panel.
1877    pub fn location_context_lines(&self) -> Vec<ContextLine> {
1878        let (px, py) = self.player_position();
1879        let mut lines = Vec::new();
1880
1881        if let Some(kind) = self.terrain_at(px, py) {
1882            lines.push(ContextLine {
1883                on_top: true,
1884                text: format!("Terrain: {}", terrain_kind_label(kind)),
1885            });
1886        }
1887
1888        if let Some(building) = self.building_interior_at(px, py) {
1889            lines.push(ContextLine {
1890                on_top: true,
1891                text: format!("Inside: {}", building.label),
1892            });
1893        } else if let Some(id) = self.effective_inside_building() {
1894            if let Some(b) = self.buildings.iter().find(|b| b.id == id) {
1895                lines.push(ContextLine {
1896                    on_top: false,
1897                    text: format!("Near: {} (exterior)", b.label),
1898                });
1899            }
1900        }
1901
1902        let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
1903
1904        for node in &self.resource_nodes {
1905            let dist = distance(px, py, node.x, node.y);
1906            if dist > NEARBY_SCAN_M {
1907                continue;
1908            }
1909            let on_top = dist <= ON_TOP_RADIUS_M;
1910            let state = match node.state {
1911                flatland_protocol::ResourceNodeState::Available => " — press , to harvest",
1912                flatland_protocol::ResourceNodeState::Harvesting => " (being harvested)",
1913                flatland_protocol::ResourceNodeState::Cooldown => " (depleted)",
1914            };
1915            let prefix = if on_top { "On" } else { "Near" };
1916            nearby.push((
1917                dist,
1918                ContextLine {
1919                    on_top,
1920                    text: format!(
1921                        "{prefix}: {} ({dist:.1}m){state}",
1922                        node.label
1923                    ),
1924                },
1925            ));
1926        }
1927
1928        for drop in &self.ground_drops {
1929            let dist = distance(px, py, drop.x, drop.y);
1930            if dist > INTERACTION_RADIUS_M {
1931                continue;
1932            }
1933            let on_top = dist <= ON_TOP_RADIUS_M;
1934            let name = self.template_display_name(&drop.template_id);
1935            let prefix = if on_top { "On" } else { "Near" };
1936            let qty = if drop.quantity > 1 {
1937                format!(" ×{}", drop.quantity)
1938            } else {
1939                String::new()
1940            };
1941            nearby.push((
1942                dist,
1943                ContextLine {
1944                    on_top,
1945                    text: format!(
1946                        "{prefix}: {name}{qty} ({dist:.1}m) — p pickup"
1947                    ),
1948                },
1949            ));
1950        }
1951
1952        for c in &self.placed_containers {
1953            let dist = distance(px, py, c.x, c.y);
1954            if dist > CONTAINER_RANGE_M {
1955                continue;
1956            }
1957            let on_top = dist <= ON_TOP_RADIUS_M;
1958            let name = self.placed_container_public_label(c);
1959            let lock = if c.locked { " [locked]" } else { "" };
1960            let prefix = if on_top { "On" } else { "Near" };
1961            nearby.push((
1962                dist,
1963                ContextLine {
1964                    on_top,
1965                    text: format!("{prefix}: {name}{lock} ({dist:.1}m)"),
1966                },
1967            ));
1968        }
1969
1970        for npc in &self.npcs {
1971            let dist = distance(px, py, npc.x, npc.y);
1972            if dist > NEARBY_SCAN_M {
1973                continue;
1974            }
1975            let on_top = dist <= ON_TOP_RADIUS_M;
1976            let prefix = if on_top { "On" } else { "Near" };
1977            nearby.push((
1978                dist,
1979                ContextLine {
1980                    on_top,
1981                    text: format!(
1982                        "{prefix}: {} ({dist:.1}m) — f talk",
1983                        npc.label
1984                    ),
1985                },
1986            ));
1987        }
1988
1989        for door in &self.doors {
1990            let dist = distance(px, py, door.x, door.y);
1991            if dist > DOOR_INTERACTION_RADIUS_M {
1992                continue;
1993            }
1994            let building = self
1995                .buildings
1996                .iter()
1997                .find(|b| b.id == door.building_id)
1998                .map(|b| b.label.as_str())
1999                .unwrap_or(door.building_id.as_str());
2000            let action = if door.is_exit { "exit" } else { "enter" };
2001            nearby.push((
2002                dist,
2003                ContextLine {
2004                    on_top: dist <= ON_TOP_RADIUS_M,
2005                    text: format!("{building} door ({dist:.1}m) — f {action}"),
2006                },
2007            ));
2008        }
2009
2010        if self.in_shallow_water() {
2011            let already = self
2012                .terrain_at(px, py)
2013                .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
2014            if !already {
2015                nearby.push((
2016                    0.0,
2017                    ContextLine {
2018                        on_top: true,
2019                        text: "Shallow water — f fill bottle".into(),
2020                    },
2021                ));
2022            } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
2023                line.text.push_str(" — f fill bottle");
2024            }
2025        }
2026
2027        for entity in &self.entities {
2028            if entity.id == self.entity_id {
2029                continue;
2030            }
2031            let dist = distance(px, py, entity.transform.position.x, entity.transform.position.y);
2032            if dist > NEARBY_SCAN_M {
2033                continue;
2034            }
2035            let label = if entity.label.is_empty() {
2036                format!("entity {}", entity.id)
2037            } else {
2038                entity.label.clone()
2039            };
2040            nearby.push((
2041                dist,
2042                ContextLine {
2043                    on_top: dist <= ON_TOP_RADIUS_M,
2044                    text: format!("Near: {label} ({dist:.1}m)"),
2045                },
2046            ));
2047        }
2048
2049        nearby.sort_by(|a, b| {
2050            a.0.partial_cmp(&b.0)
2051                .unwrap_or(std::cmp::Ordering::Equal)
2052                .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
2053        });
2054        lines.extend(nearby.into_iter().map(|(_, l)| l));
2055
2056        if lines.is_empty() {
2057            lines.push(ContextLine {
2058                on_top: false,
2059                text: "(nothing notable nearby)".into(),
2060            });
2061        }
2062
2063        lines
2064    }
2065}
2066
2067/// HUD line for the location / nearby panel.
2068#[derive(Debug, Clone)]
2069pub struct ContextLine {
2070    pub on_top: bool,
2071    pub text: String,
2072}
2073
2074const ON_TOP_RADIUS_M: f32 = 0.65;
2075const NEARBY_SCAN_M: f32 = 5.0;
2076
2077fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
2078    use flatland_protocol::TerrainKindView;
2079    match kind {
2080        TerrainKindView::Grass => "Grass",
2081        TerrainKindView::Hill => "Hills",
2082        TerrainKindView::Bog => "Bog",
2083        TerrainKindView::ShallowWater => "Shallow water",
2084        TerrainKindView::DeepWater => "Deep water",
2085    }
2086}
2087
2088fn humanize_template_id(template_id: &str) -> String {
2089    template_id
2090        .split('_')
2091        .map(|word| {
2092            let mut chars = word.chars();
2093            match chars.next() {
2094                None => String::new(),
2095                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
2096            }
2097        })
2098        .collect::<Vec<_>>()
2099        .join(" ")
2100}
2101
2102fn point_in_building_interior(px: f32, py: f32, building: &BuildingView) -> bool {
2103    px >= building.interior_origin_x
2104        && px <= building.interior_origin_x + building.width_m
2105        && py >= building.interior_origin_y
2106        && py <= building.interior_origin_y + building.depth_m
2107}
2108
2109/// Coordinates in a separate interior instance (outside the outdoor segment bounds).
2110fn is_instanced_world_coord(px: f32, py: f32, world_w: f32, world_h: f32) -> bool {
2111    if world_w > 0.0 && world_h > 0.0 {
2112        px < 0.0 || py < 0.0 || px > world_w || py > world_h
2113    } else {
2114        px > 256.0 || py > 256.0
2115    }
2116}
2117
2118/// Keep in sync with `flatland_sim::INTERACTION_RADIUS_M`.
2119const HARVEST_RANGE_M: f32 = 1.5;
2120
2121pub struct GameClient<S: PlayConnection> {
2122    session: S,
2123    seq: Seq,
2124    pub state: GameState,
2125    last_move_forward: f32,
2126    last_move_strafe: f32,
2127}
2128
2129impl<S: PlayConnection> GameClient<S> {
2130    pub fn new(session: S) -> Self {
2131        let session_id = session.session_id();
2132        let entity_id = session.entity_id();
2133        Self {
2134            session,
2135            seq: 0,
2136            last_move_forward: 0.0,
2137            last_move_strafe: 0.0,
2138            state: GameState {
2139                session_id,
2140                entity_id,
2141                character_id: None,
2142                tick: 0,
2143                chunk_rev: 0,
2144                content_rev: 0,
2145                entities: Vec::new(),
2146                player: None,
2147                resource_nodes: Vec::new(),
2148                ground_drops: Vec::new(),
2149            placed_containers: Vec::new(),
2150                buildings: Vec::new(),
2151                doors: Vec::new(),
2152                npcs: Vec::new(),
2153                blueprints: Vec::new(),
2154                world_width_m: 0.0,
2155                world_height_m: 0.0,
2156                terrain_zones: Vec::new(),
2157                world_clock: flatland_protocol::WorldClock::default(),
2158                inventory: std::collections::HashMap::new(),
2159                inventory_hints: std::collections::HashMap::new(),
2160                logs: VecDeque::new(),
2161                intents_sent: 0,
2162                ticks_received: 0,
2163                connected: false,
2164                disconnect_reason: None,
2165                show_stats: false,
2166                show_craft_menu: false,
2167                craft_menu_index: 0,
2168                craft_batch_quantity: 1,
2169                show_shop_menu: false,
2170                shop_catalog: None,
2171                shop_tab: ShopTab::default(),
2172                shop_menu_index: 0,
2173                shop_quantity: 1,
2174                show_inventory_menu: false,
2175                inventory_menu_index: 0,
2176                show_move_picker: false,
2177                show_rename_prompt: false,
2178                rename_buffer: String::new(),
2179                move_picker_index: 0,
2180                move_picker: None,
2181                show_destroy_picker: false,
2182                destroy_confirm_pending: false,
2183                destroy_picker: None,
2184                combat_target: None,
2185                combat_target_label: None,
2186                in_combat: false,
2187                auto_attack: true,
2188                combat_has_los: false,
2189                attack_cd_ticks: 0,
2190                gcd_ticks: 0,
2191                weapon_ability_id: "unarmed".into(),
2192                mainhand_template_id: None,
2193                mainhand_label: None,
2194                worn: BTreeMap::new(),
2195                carry_mass: 0.0,
2196                carry_mass_max: 0.0,
2197                encumbrance: flatland_protocol::EncumbranceState::Light,
2198                inventory_stacks: Vec::new(),
2199                combat_target_detail: None,
2200                cast_progress: None,
2201                ability_cooldowns: Vec::new(),
2202                blocking_active: false,
2203                max_target_slots: 1,
2204                combat_slots: Vec::new(),
2205                rotation_presets: Vec::new(),
2206                show_loadout_menu: false,
2207                show_rotation_editor: false,
2208                loadout_menu_index: 0,
2209                rotation_editor: RotationEditorState::default(),
2210                harvest_in_progress: false,
2211                harvest_started_at: None,
2212                pending_craft_ack: None,
2213            },
2214        }
2215    }
2216
2217    pub fn entity_id(&self) -> EntityId {
2218        self.state.entity_id
2219    }
2220
2221    pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
2222        if self.state.connected {
2223            return Ok(());
2224        }
2225
2226        loop {
2227            match self.session.next_event().await {
2228                Some(SessionEvent::Welcome {
2229                    session_id,
2230                    entity_id,
2231                    snapshot,
2232                }) => {
2233                    self.state.restore_from_welcome(session_id, entity_id, &snapshot);
2234                    self.state.push_log(format!(
2235                        "Connected — session {session_id}, entity {entity_id}"
2236                    ));
2237                    return Ok(());
2238                }
2239                Some(SessionEvent::Disconnected { .. }) => {
2240                    anyhow::bail!("disconnected before welcome");
2241                }
2242                Some(_) => continue,
2243                None => anyhow::bail!("session closed before welcome"),
2244            }
2245        }
2246    }
2247
2248    /// Drain all pending server events (non-blocking).
2249    pub fn drain_events(&mut self) {
2250        while let Some(event) = self.session.try_next_event() {
2251            if self.handle_event_sync(event).is_err() {
2252                break;
2253            }
2254        }
2255    }
2256
2257    /// Wait for the next server event.
2258    pub async fn next_event(&mut self) -> Option<SessionEvent> {
2259        self.session.next_event().await
2260    }
2261
2262    pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
2263        self.handle_event_sync(event)
2264    }
2265
2266    fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
2267        match event {
2268            SessionEvent::Welcome {
2269                session_id,
2270                entity_id,
2271                snapshot,
2272            } => {
2273                let resumed = self.state.connected;
2274                self.state
2275                    .restore_from_welcome(session_id, entity_id, &snapshot);
2276                if resumed {
2277                    self.state.push_log(format!(
2278                        "Session restored — session {session_id}, entity {entity_id}"
2279                    ));
2280                }
2281            }
2282            SessionEvent::ContentUpdated { snapshot } => {
2283                self.state.apply_snapshot_fields(&snapshot, self.state.entity_id);
2284                self.state.push_log(format!(
2285                    "World updated (content rev {})",
2286                    snapshot.content_rev
2287                ));
2288            }
2289            SessionEvent::Tick(delta) => {
2290                self.state.apply_tick_fields(&delta, self.state.entity_id);
2291                self.state.ticks_received += 1;
2292            }
2293            SessionEvent::IntentAck {
2294                entity_id,
2295                seq,
2296                tick,
2297            } => {
2298                crate::harvest_trace!(
2299                    entity_id,
2300                    seq,
2301                    tick,
2302                    "client received intent ack"
2303                );
2304                if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
2305                    if *craft_seq == seq {
2306                        let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
2307                        if batches > 1 {
2308                            self.state
2309                                .push_log(format!("Crafting {label} ×{batches}…"));
2310                        } else {
2311                            self.state.push_log(format!("Crafting {label}…"));
2312                        }
2313                    }
2314                }
2315            }
2316            SessionEvent::Chat(msg) => {
2317                let label = match msg.channel {
2318                    flatland_protocol::ChatChannel::Nearby => "nearby",
2319                    flatland_protocol::ChatChannel::WhisperStone => "whisper",
2320                };
2321                self.state.push_log(format!(
2322                    "[{label}] {}: {}",
2323                    msg.from_name, msg.text
2324                ));
2325            }
2326            SessionEvent::HarvestResult(result) => {
2327                self.state.clear_harvest_state();
2328                crate::harvest_trace!(
2329                    entity_id = self.state.entity_id,
2330                    node_id = %result.node_id,
2331                    template = %result.item_template,
2332                    quantity = result.quantity,
2333                    client_tick = self.state.tick,
2334                    "client applied harvest result"
2335                );
2336                *self
2337                    .state
2338                    .inventory
2339                    .entry(result.item_template.clone())
2340                    .or_insert(0) += result.quantity;
2341                self.state.push_log(format!(
2342                    "Harvested {} x{}",
2343                    result.item_template, result.quantity
2344                ));
2345            }
2346            SessionEvent::CraftResult(result) => {
2347                for stack in &result.consumed {
2348                    if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
2349                        *qty = qty.saturating_sub(stack.quantity);
2350                        if *qty == 0 {
2351                            self.state.inventory.remove(&stack.template_id);
2352                        }
2353                    }
2354                }
2355                for stack in &result.outputs {
2356                    *self
2357                        .state
2358                        .inventory
2359                        .entry(stack.template_id.clone())
2360                        .or_insert(0) += stack.quantity;
2361                }
2362                if let Some(output) = result.outputs.first() {
2363                    if result.batch_total > 1 {
2364                        self.state.push_log(format!(
2365                            "Crafted {} x{} ({}/{})",
2366                            output.template_id,
2367                            output.quantity,
2368                            result.batch_index,
2369                            result.batch_total
2370                        ));
2371                    } else {
2372                        self.state.push_log(format!(
2373                            "Crafted {} x{}",
2374                            output.template_id, output.quantity
2375                        ));
2376                    }
2377                } else {
2378                    self.state.push_log(format!("Craft finished: {}", result.blueprint_id));
2379                }
2380            }
2381            SessionEvent::Death(notice) => {
2382                self.state.clear_harvest_state();
2383                self.state.push_log(notice.message.clone());
2384                self.state.push_log(format!(
2385                    "Respawned at ({:.1}, {:.1})",
2386                    notice.respawn_x, notice.respawn_y
2387                ));
2388            }
2389            SessionEvent::Interaction(notice) => {
2390                if notice.message.starts_with("Harvest failed:") {
2391                    self.state.clear_harvest_state();
2392                }
2393                if notice.message.starts_with("Can't do that:") {
2394                    self.state.pending_craft_ack = None;
2395                }
2396                if notice.message.starts_with("Cast failed:") {
2397                    self.state.cast_progress = None;
2398                }
2399                if notice.message.contains("slain the") {
2400                    self.state.combat_target = None;
2401                    self.state.combat_target_label = None;
2402                }
2403                self.state.push_log(notice.message.clone());
2404            }
2405            SessionEvent::ShopOpened(catalog) => {
2406                self.state.apply_shop_catalog(catalog);
2407            }
2408            SessionEvent::UseResult(result) => {
2409                if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
2410                    *qty = qty.saturating_sub(1);
2411                    if *qty == 0 {
2412                        self.state.inventory.remove(&result.template_id);
2413                    }
2414                }
2415                self.state.push_log(format!(
2416                    "Consumed {} (+{:.0} hunger, +{:.0} thirst)",
2417                    result.template_id, result.hunger_restored, result.thirst_restored
2418                ));
2419            }
2420            SessionEvent::Disconnected { reason } => {
2421                self.state.clear_harvest_state();
2422                self.state.connected = false;
2423                self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
2424                if let Some(r) = &self.state.disconnect_reason {
2425                    self.state.push_log(format!("Disconnected: {r}"));
2426                } else {
2427                    self.state.push_log("Disconnected from server");
2428                }
2429            }
2430        }
2431        Ok(())
2432    }
2433
2434    pub fn is_connected(&self) -> bool {
2435        self.state.connected
2436    }
2437
2438    pub fn close_overlays(&mut self) {
2439        self.state.show_stats = false;
2440        self.state.show_craft_menu = false;
2441        self.state.show_shop_menu = false;
2442        self.state.shop_catalog = None;
2443        self.state.show_inventory_menu = false;
2444        self.state.show_loadout_menu = false;
2445        self.state.show_rotation_editor = false;
2446        self.state.rotation_editor.reset();
2447        self.state.show_rename_prompt = false;
2448        self.state.rename_buffer.clear();
2449        self.state.show_move_picker = false;
2450        self.state.move_picker = None;
2451        self.state.show_destroy_picker = false;
2452        self.state.destroy_confirm_pending = false;
2453        self.state.destroy_picker = None;
2454    }
2455
2456    /// Esc / back — pop one UI layer instead of closing every overlay at once.
2457    pub fn back_on_esc(&mut self) -> bool {
2458        if self.state.show_rename_prompt {
2459            self.cancel_rename_prompt();
2460            return true;
2461        }
2462        if self.state.show_destroy_picker {
2463            if self.state.destroy_confirm_pending {
2464                self.cancel_destroy_confirm();
2465            } else {
2466                self.close_destroy_picker();
2467            }
2468            return true;
2469        }
2470        if self.state.show_move_picker {
2471            self.close_move_picker();
2472            return true;
2473        }
2474        if self.state.show_rotation_editor {
2475            match self.state.rotation_editor.mode {
2476                RotationEditorMode::List => {
2477                    self.state.show_rotation_editor = false;
2478                    self.state.rotation_editor.reset();
2479                }
2480                RotationEditorMode::EditLabel => {
2481                    self.state.rotation_editor.label_buffer.clear();
2482                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
2483                }
2484                RotationEditorMode::PickAbility => {
2485                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
2486                }
2487                RotationEditorMode::EditSequence => {
2488                    self.state.rotation_editor.draft = None;
2489                    self.state.rotation_editor.mode = RotationEditorMode::List;
2490                }
2491            }
2492            return true;
2493        }
2494        if self.state.show_inventory_menu {
2495            self.close_inventory_menu();
2496            return true;
2497        }
2498        if self.state.show_craft_menu {
2499            self.close_craft_menu();
2500            return true;
2501        }
2502        if self.state.show_shop_menu {
2503            self.close_shop_menu();
2504            return true;
2505        }
2506        if self.state.show_loadout_menu {
2507            self.state.show_loadout_menu = false;
2508            return true;
2509        }
2510        if self.state.show_stats {
2511            self.state.show_stats = false;
2512            return true;
2513        }
2514        false
2515    }
2516
2517    pub fn toggle_stats(&mut self) {
2518        self.state.show_stats = !self.state.show_stats;
2519        if self.state.show_stats {
2520            self.state.show_craft_menu = false;
2521            self.state.show_shop_menu = false;
2522            self.state.shop_catalog = None;
2523            self.state.show_inventory_menu = false;
2524        }
2525    }
2526
2527    pub fn open_inventory_menu(&mut self) {
2528        self.state.show_inventory_menu = true;
2529        self.state.show_craft_menu = false;
2530        self.state.show_shop_menu = false;
2531        self.state.shop_catalog = None;
2532        self.state.show_stats = false;
2533        self.state.show_move_picker = false;
2534        self.state.move_picker = None;
2535        self.state.show_destroy_picker = false;
2536        self.state.destroy_confirm_pending = false;
2537        self.state.destroy_picker = None;
2538        self.state.show_rename_prompt = false;
2539        self.state.rename_buffer.clear();
2540        self.state.clamp_inventory_indices();
2541    }
2542
2543    pub fn close_inventory_menu(&mut self) {
2544        self.state.show_inventory_menu = false;
2545        self.state.show_move_picker = false;
2546        self.state.move_picker = None;
2547        self.state.show_destroy_picker = false;
2548        self.state.destroy_confirm_pending = false;
2549        self.state.destroy_picker = None;
2550        self.state.show_rename_prompt = false;
2551        self.state.rename_buffer.clear();
2552    }
2553
2554    pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
2555        let Some(row) = self.state.inventory_selected_row() else {
2556            anyhow::bail!("inventory empty");
2557        };
2558        if !self.state.row_is_renameable_container(&row) {
2559            anyhow::bail!("only storage containers can be renamed");
2560        }
2561        let current = row
2562            .stack
2563            .display_name
2564            .clone()
2565            .unwrap_or_else(|| row.stack.template_id.clone());
2566        self.state.rename_buffer = current;
2567        self.state.show_rename_prompt = true;
2568        self.state.show_move_picker = false;
2569        self.state.show_destroy_picker = false;
2570        self.state.destroy_confirm_pending = false;
2571        Ok(())
2572    }
2573
2574    pub fn cancel_rename_prompt(&mut self) {
2575        self.state.show_rename_prompt = false;
2576        self.state.rename_buffer.clear();
2577    }
2578
2579    pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
2580        let name = self.state.rename_buffer.trim().to_string();
2581        if name.is_empty() {
2582            anyhow::bail!("name cannot be empty");
2583        }
2584        let Some(row) = self.state.inventory_selected_row() else {
2585            anyhow::bail!("inventory empty");
2586        };
2587        let Some(instance_id) = row.stack.item_instance_id else {
2588            anyhow::bail!("item has no instance id");
2589        };
2590        self.seq += 1;
2591        self.session
2592            .submit_intent(Intent::RenameContainer {
2593                entity_id: self.state.entity_id,
2594                item_instance_id: instance_id,
2595                location: row.from.clone(),
2596                name,
2597                seq: self.seq,
2598            })
2599            .await?;
2600        self.state.intents_sent += 1;
2601        self.state.show_rename_prompt = false;
2602        self.state.rename_buffer.clear();
2603        Ok(())
2604    }
2605
2606    pub fn toggle_inventory_menu(&mut self) {
2607        if self.state.show_inventory_menu {
2608            self.close_inventory_menu();
2609        } else {
2610            self.open_inventory_menu();
2611        }
2612    }
2613
2614    /// ↑/↓ in the inventory browser, or within the "move to…" picker when open.
2615    pub fn inventory_menu_move(&mut self, delta: i32) {
2616        if self.state.show_move_picker {
2617            let n = self
2618                .state
2619                .move_picker
2620                .as_ref()
2621                .map(|p| p.options.len())
2622                .unwrap_or(0);
2623            if n == 0 {
2624                return;
2625            }
2626            let idx = self.state.move_picker_index as i32;
2627            self.state.move_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
2628            self.state.clamp_move_picker_quantity();
2629            return;
2630        }
2631        let n = self.state.inventory_selectable_rows().len();
2632        if n == 0 {
2633            return;
2634        }
2635        let idx = self.state.inventory_menu_index as i32;
2636        self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
2637    }
2638
2639    /// Enter: unequip a worn bag, equip a weapon, wear/place a loose bag or
2640    /// chest, drink/eat a loose consumable — or fall back to the "move to…"
2641    /// destination picker for anything else (including items already inside a
2642    /// worn bag or a nearby chest).
2643    pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
2644        if self.state.show_destroy_picker {
2645            if self.state.destroy_confirm_pending {
2646                return self.confirm_destroy_item().await;
2647            }
2648            return self.request_destroy_confirm();
2649        }
2650        if self.state.show_move_picker {
2651            return self.confirm_move_picker().await;
2652        }
2653        let Some(row) = self.state.inventory_selected_row() else {
2654            anyhow::bail!("inventory empty");
2655        };
2656        if row.is_equip_shell {
2657            let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
2658                anyhow::bail!("not a worn item");
2659            };
2660            return self.equip_worn(slot, None).await;
2661        }
2662        if row.is_chest_shell {
2663            let flatland_protocol::InventoryLocation::Placed { container_id } = row.from else {
2664                anyhow::bail!("not a placed chest");
2665            };
2666            return self.toggle_placed_chest_lock(&container_id).await;
2667        }
2668        let template_id = row.stack.template_id.clone();
2669        let instance_id = row.stack.item_instance_id;
2670        let category = self.state.inventory_item_category(&template_id);
2671        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
2672
2673        if category == Some("weapon") {
2674            return self.equip_mainhand(Some(template_id)).await;
2675        }
2676        if (category == Some("container") || category == Some("armor")) && on_person {
2677            if let Some(inst) = instance_id {
2678                if template_id.contains("chest") {
2679                    return self.place_container(inst).await;
2680                }
2681                // Pouches no longer equip directly — they clip onto a worn belt's loops
2682                // instead, so fall through to the move picker (offers "belt loop" when a
2683                // belt is worn). Backpacks/belts/armor equip straight to their body slot.
2684                if let Some(slot) = guess_body_slot(&template_id) {
2685                    return self.equip_worn(slot, Some(inst)).await;
2686                }
2687            }
2688        }
2689        if category == Some("consumable") && on_person {
2690            return self.use_item(&template_id).await;
2691        }
2692        // Anything else (materials, pouches, items nested in a bag/chest, weapons
2693        // you'd rather stash than wield, ...) — offer explicit places to move it
2694        // instead of guessing.
2695        self.open_move_picker()
2696    }
2697
2698    /// `m`: always open the "move to…" picker for the selected item, even for
2699    /// weapons/wearables that Enter would otherwise equip/wear directly.
2700    pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
2701        let Some(row) = self.state.inventory_selected_row() else {
2702            anyhow::bail!("inventory empty");
2703        };
2704        if row.is_equip_shell {
2705            anyhow::bail!("this is a worn bag — press Enter to unequip it");
2706        }
2707        if row.is_chest_shell {
2708            anyhow::bail!("select the chest and press Enter or l to lock/unlock it");
2709        }
2710        let Some(instance_id) = row.stack.item_instance_id else {
2711            anyhow::bail!("item has no instance id");
2712        };
2713        let options = self.state.move_destinations_for(
2714            &row.from,
2715            row.from_parent_instance_id,
2716            row.stack.item_instance_id,
2717            &row.stack.template_id,
2718        );
2719        let item_label = row
2720            .stack
2721            .display_name
2722            .clone()
2723            .unwrap_or_else(|| row.stack.template_id.clone());
2724        self.state.move_picker = Some(MovePicker {
2725            item_instance_id: instance_id,
2726            from: row.from,
2727            item_label,
2728            template_id: row.stack.template_id.clone(),
2729            stack_quantity: row.stack.quantity,
2730            quantity: row.stack.quantity,
2731            options,
2732        });
2733        self.state.move_picker_index = 0;
2734        self.state.show_move_picker = true;
2735        self.state.show_destroy_picker = false;
2736        self.state.destroy_confirm_pending = false;
2737        self.state.destroy_picker = None;
2738        Ok(())
2739    }
2740
2741    pub fn close_move_picker(&mut self) {
2742        self.state.show_move_picker = false;
2743        self.state.move_picker = None;
2744    }
2745
2746    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
2747        self.state.move_picker_adjust_quantity(delta);
2748    }
2749
2750    pub fn move_picker_set_quantity_max(&mut self) {
2751        self.state.move_picker_set_quantity_max();
2752    }
2753
2754    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
2755        self.state.destroy_picker_adjust_quantity(delta);
2756    }
2757
2758    pub fn destroy_picker_set_quantity_max(&mut self) {
2759        self.state.destroy_picker_set_quantity_max();
2760    }
2761
2762    async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
2763        let Some(picker) = self.state.move_picker.clone() else {
2764            self.close_move_picker();
2765            return Ok(());
2766        };
2767        let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
2768            self.close_move_picker();
2769            return Ok(());
2770        };
2771        match option.kind {
2772            MoveOptionKind::Cancel => {
2773                self.close_move_picker();
2774            }
2775            MoveOptionKind::Drop => {
2776                self.close_move_picker();
2777                if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
2778                    if self.state.key_drop_blocked(&stack) {
2779                        anyhow::bail!("cannot drop the key while its chest is locked");
2780                    }
2781                }
2782                self.drop_item(picker.item_instance_id, picker.from).await?;
2783                self.state.push_log(format!("Dropped {}", picker.item_label));
2784            }
2785            MoveOptionKind::Move {
2786                location,
2787                parent_instance_id,
2788            } => {
2789                self.close_move_picker();
2790                let qty = if picker.quantity >= picker.stack_quantity {
2791                    None
2792                } else {
2793                    Some(picker.quantity)
2794                };
2795                self.move_item(
2796                    picker.item_instance_id,
2797                    picker.from,
2798                    location,
2799                    parent_instance_id,
2800                    qty,
2801                )
2802                .await?;
2803                let moved = qty.unwrap_or(picker.stack_quantity);
2804                if moved >= picker.stack_quantity {
2805                    self.state.push_log(format!("Moved {}", picker.item_label));
2806                } else {
2807                    self.state.push_log(format!(
2808                        "Moved {} ×{} of {}",
2809                        picker.item_label, moved, picker.stack_quantity
2810                    ));
2811                }
2812            }
2813        }
2814        Ok(())
2815    }
2816
2817    /// `d`: drop the selected item on the ground immediately (no picker).
2818    pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
2819        let Some(row) = self.state.inventory_selected_row() else {
2820            anyhow::bail!("inventory empty");
2821        };
2822        if row.is_equip_shell {
2823            anyhow::bail!("unequip the bag first (Enter), then drop from your person");
2824        }
2825        if row.is_chest_shell {
2826            anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
2827        }
2828        let Some(inst) = row.stack.item_instance_id else {
2829            anyhow::bail!("item has no instance id");
2830        };
2831        if self.state.key_drop_blocked(&row.stack) {
2832            anyhow::bail!("cannot drop the key while its chest is locked");
2833        }
2834        let label = row
2835            .stack
2836            .display_name
2837            .clone()
2838            .unwrap_or_else(|| row.stack.template_id.clone());
2839        self.drop_item(inst, row.from).await?;
2840        self.state.push_log(format!("Dropped {label}"));
2841        Ok(())
2842    }
2843
2844    pub async fn drop_item(
2845        &mut self,
2846        item_instance_id: uuid::Uuid,
2847        from: flatland_protocol::InventoryLocation,
2848    ) -> anyhow::Result<()> {
2849        self.seq += 1;
2850        self.session
2851            .submit_intent(Intent::DropItem {
2852                entity_id: self.state.entity_id,
2853                item_instance_id,
2854                from,
2855                seq: self.seq,
2856            })
2857            .await?;
2858        self.state.intents_sent += 1;
2859        Ok(())
2860    }
2861
2862    /// `x`: open permanent-delete picker for the selected item (quantity + confirm).
2863    pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
2864        let Some(row) = self.state.inventory_selected_row() else {
2865            anyhow::bail!("inventory empty");
2866        };
2867        if row.is_equip_shell {
2868            anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
2869        }
2870        if row.is_chest_shell {
2871            anyhow::bail!("can't destroy a placed chest from the inventory list");
2872        }
2873        let Some(instance_id) = row.stack.item_instance_id else {
2874            anyhow::bail!("item has no instance id");
2875        };
2876        if self.state.key_drop_blocked(&row.stack) {
2877            anyhow::bail!("cannot destroy the key while its chest is locked");
2878        }
2879        let item_label = row
2880            .stack
2881            .display_name
2882            .clone()
2883            .unwrap_or_else(|| row.stack.template_id.clone());
2884        self.state.destroy_picker = Some(DestroyPicker {
2885            item_instance_id: instance_id,
2886            from: row.from,
2887            item_label,
2888            stack_quantity: row.stack.quantity,
2889            quantity: row.stack.quantity,
2890        });
2891        self.state.destroy_confirm_pending = false;
2892        self.state.show_destroy_picker = true;
2893        self.state.show_move_picker = false;
2894        self.state.move_picker = None;
2895        Ok(())
2896    }
2897
2898    pub fn close_destroy_picker(&mut self) {
2899        self.state.show_destroy_picker = false;
2900        self.state.destroy_confirm_pending = false;
2901        self.state.destroy_picker = None;
2902    }
2903
2904    pub fn cancel_destroy_confirm(&mut self) {
2905        self.state.destroy_confirm_pending = false;
2906    }
2907
2908    pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
2909        if self.state.destroy_picker.is_none() {
2910            self.close_destroy_picker();
2911            return Ok(());
2912        }
2913        self.state.destroy_confirm_pending = true;
2914        Ok(())
2915    }
2916
2917    pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
2918        let Some(picker) = self.state.destroy_picker.clone() else {
2919            self.close_destroy_picker();
2920            return Ok(());
2921        };
2922        let qty = if picker.quantity >= picker.stack_quantity {
2923            None
2924        } else {
2925            Some(picker.quantity)
2926        };
2927        self.destroy_item(picker.item_instance_id, picker.from, qty)
2928            .await?;
2929        let destroyed = qty.unwrap_or(picker.stack_quantity);
2930        if destroyed >= picker.stack_quantity {
2931            self.state
2932                .push_log(format!("Destroyed {}", picker.item_label));
2933        } else {
2934            self.state.push_log(format!(
2935                "Destroyed {} ×{} of {}",
2936                picker.item_label, destroyed, picker.stack_quantity
2937            ));
2938        }
2939        self.close_destroy_picker();
2940        Ok(())
2941    }
2942
2943    pub async fn destroy_item(
2944        &mut self,
2945        item_instance_id: uuid::Uuid,
2946        from: flatland_protocol::InventoryLocation,
2947        quantity: Option<u32>,
2948    ) -> anyhow::Result<()> {
2949        self.seq += 1;
2950        self.session
2951            .submit_intent(Intent::DestroyItem {
2952                entity_id: self.state.entity_id,
2953                item_instance_id,
2954                from,
2955                quantity,
2956                seq: self.seq,
2957            })
2958            .await?;
2959        self.state.intents_sent += 1;
2960        Ok(())
2961    }
2962
2963    /// `l`: lock/unlock a placed chest — selected chest in inventory UI, else nearest.
2964    pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
2965        if let Some(row) = self.state.inventory_selected_row() {
2966            if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
2967                return self.toggle_placed_chest_lock(container_id).await;
2968            }
2969        }
2970        self.toggle_nearby_chest_lock().await
2971    }
2972
2973    pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
2974        let chest = self
2975            .state
2976            .placed_containers
2977            .iter()
2978            .find(|c| c.id == container_id)
2979            .cloned()
2980            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
2981        let (px, py) = self.state.player_position();
2982        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
2983            anyhow::bail!("too far from {}", chest.display_name);
2984        }
2985        if !chest.accessible && chest.locked {
2986            anyhow::bail!(
2987                "need the matching key for {} (each crafted chest has its own key)",
2988                chest.display_name
2989            );
2990        }
2991        let lock = !chest.locked;
2992        self.set_container_locked(
2993            flatland_protocol::InventoryLocation::Placed {
2994                container_id: chest.id.clone(),
2995            },
2996            lock,
2997        )
2998        .await?;
2999        self.state.push_log(if lock {
3000            format!("Locked {}", chest.display_name)
3001        } else {
3002            format!("Unlocked {}", chest.display_name)
3003        });
3004        Ok(())
3005    }
3006
3007    /// `l` outside inventory: lock/unlock the nearest placed chest (within `CONTAINER_RANGE_M`).
3008    pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
3009        let chest = self
3010            .state
3011            .nearest_placed_container(CONTAINER_RANGE_M)
3012            .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
3013        self.toggle_placed_chest_lock(&chest.id).await
3014    }
3015
3016    pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
3017        self.equip_mainhand(None).await
3018    }
3019
3020    pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
3021        let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
3022        for slot in slots {
3023            self.equip_worn(slot, None).await?;
3024        }
3025        Ok(())
3026    }
3027
3028    pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
3029        let (px, py) = self.state.player_position();
3030        let nearest = self
3031            .state
3032            .placed_containers
3033            .iter()
3034            .min_by(|a, b| {
3035                let da = (a.x - px).hypot(a.y - py);
3036                let db = (b.x - px).hypot(b.y - py);
3037                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3038            })
3039            .cloned();
3040        let Some(chest) = nearest else {
3041            anyhow::bail!("no chest nearby");
3042        };
3043        if (chest.x - px).hypot(chest.y - py) > 2.0 {
3044            anyhow::bail!("too far from chest");
3045        }
3046        self.pickup_container(chest.id).await
3047    }
3048
3049    pub async fn equip_worn(
3050        &mut self,
3051        slot: BodySlot,
3052        instance_id: Option<uuid::Uuid>,
3053    ) -> anyhow::Result<()> {
3054        self.seq += 1;
3055        self.session
3056            .submit_intent(Intent::EquipWorn {
3057                entity_id: self.state.entity_id,
3058                slot,
3059                instance_id,
3060                seq: self.seq,
3061            })
3062            .await?;
3063        self.state.intents_sent += 1;
3064        Ok(())
3065    }
3066
3067    pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
3068        self.seq += 1;
3069        self.session
3070            .submit_intent(Intent::PlaceContainer {
3071                entity_id: self.state.entity_id,
3072                item_instance_id,
3073                seq: self.seq,
3074            })
3075            .await?;
3076        self.state.intents_sent += 1;
3077        Ok(())
3078    }
3079
3080    pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
3081        self.seq += 1;
3082        self.session
3083            .submit_intent(Intent::PickupContainer {
3084                entity_id: self.state.entity_id,
3085                container_id,
3086                seq: self.seq,
3087            })
3088            .await?;
3089        self.state.intents_sent += 1;
3090        Ok(())
3091    }
3092
3093    pub async fn move_item(
3094        &mut self,
3095        item_instance_id: uuid::Uuid,
3096        from: flatland_protocol::InventoryLocation,
3097        to: flatland_protocol::InventoryLocation,
3098        to_parent_instance_id: Option<uuid::Uuid>,
3099        quantity: Option<u32>,
3100    ) -> anyhow::Result<()> {
3101        self.seq += 1;
3102        self.session
3103            .submit_intent(Intent::MoveItem {
3104                entity_id: self.state.entity_id,
3105                item_instance_id,
3106                from,
3107                to,
3108                to_parent_instance_id,
3109                quantity,
3110                seq: self.seq,
3111            })
3112            .await?;
3113        self.state.intents_sent += 1;
3114        Ok(())
3115    }
3116
3117    pub async fn set_container_locked(
3118        &mut self,
3119        location: flatland_protocol::InventoryLocation,
3120        locked: bool,
3121    ) -> anyhow::Result<()> {
3122        self.seq += 1;
3123        self.session
3124            .submit_intent(Intent::SetContainerLocked {
3125                entity_id: self.state.entity_id,
3126                location,
3127                locked,
3128                seq: self.seq,
3129            })
3130            .await?;
3131        self.state.intents_sent += 1;
3132        Ok(())
3133    }
3134
3135    pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
3136        if !self.state.is_alive() {
3137            anyhow::bail!("you are dead");
3138        }
3139        self.seq += 1;
3140        self.session
3141            .submit_intent(Intent::Use {
3142                entity_id: self.state.entity_id,
3143                template_id: template_id.to_string(),
3144                seq: self.seq,
3145            })
3146            .await?;
3147        self.state.intents_sent += 1;
3148        Ok(())
3149    }
3150
3151    pub fn open_craft_menu(&mut self) {
3152        self.state.show_craft_menu = true;
3153        self.state.show_shop_menu = false;
3154        self.state.shop_catalog = None;
3155        self.state.show_stats = false;
3156        self.state.show_inventory_menu = false;
3157        if self.state.blueprints.is_empty() {
3158            self.state.craft_menu_index = 0;
3159            self.state.craft_batch_quantity = 1;
3160            return;
3161        }
3162        self.state.craft_menu_index = self
3163            .state
3164            .craft_menu_index
3165            .min(self.state.blueprints.len() - 1);
3166        if let Some(idx) = self
3167            .state
3168            .blueprints
3169            .iter()
3170            .position(|bp| self.state.can_craft_blueprint(bp))
3171        {
3172            self.state.craft_menu_index = idx;
3173        }
3174        self.state.clamp_craft_batch_quantity();
3175    }
3176
3177    pub fn close_craft_menu(&mut self) {
3178        self.state.show_craft_menu = false;
3179    }
3180
3181    pub fn close_shop_menu(&mut self) {
3182        self.state.show_shop_menu = false;
3183        self.state.shop_catalog = None;
3184    }
3185
3186    pub fn shop_tab_toggle(&mut self) {
3187        self.state.shop_tab = match self.state.shop_tab {
3188            ShopTab::Buy => ShopTab::Sell,
3189            ShopTab::Sell => ShopTab::Buy,
3190        };
3191        self.state.shop_menu_index = 0;
3192        self.state.clamp_shop_selection();
3193    }
3194
3195    pub fn shop_menu_move(&mut self, delta: i32) {
3196        self.state.shop_menu_move(delta);
3197    }
3198
3199    pub fn shop_quantity_adjust(&mut self, delta: i32) {
3200        self.state.shop_quantity_adjust(delta);
3201    }
3202
3203    pub fn shop_quantity_set_max(&mut self) {
3204        self.state.shop_quantity_set_max();
3205    }
3206
3207    pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
3208        if !self.state.is_alive() {
3209            anyhow::bail!("you are dead");
3210        }
3211        let Some(catalog) = self.state.shop_catalog.clone() else {
3212            anyhow::bail!("no shop open");
3213        };
3214        self.seq += 1;
3215        let seq = self.seq;
3216        match self.state.shop_tab {
3217            ShopTab::Buy => {
3218                let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
3219                    anyhow::bail!("nothing selected");
3220                };
3221                if offer.already_owned {
3222                    anyhow::bail!("already owned");
3223                }
3224                self.session
3225                    .submit_intent(Intent::ShopBuy {
3226                        entity_id: self.state.entity_id,
3227                        npc_id: catalog.npc_id.clone(),
3228                        offer_id: offer.offer_id.clone(),
3229                        quantity: self.state.shop_quantity,
3230                        seq,
3231                    })
3232                    .await?;
3233            }
3234            ShopTab::Sell => {
3235                let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
3236                    anyhow::bail!("nothing to sell");
3237                };
3238                self.session
3239                    .submit_intent(Intent::ShopSell {
3240                        entity_id: self.state.entity_id,
3241                        npc_id: catalog.npc_id.clone(),
3242                        template_id: line.template_id.clone(),
3243                        quantity: self.state.shop_quantity.min(line.quantity),
3244                        seq,
3245                    })
3246                    .await?;
3247            }
3248        }
3249        self.state.intents_sent += 1;
3250        Ok(())
3251    }
3252
3253    pub fn craft_menu_move(&mut self, delta: i32) {
3254        let n = self.state.blueprints.len();
3255        if n == 0 {
3256            return;
3257        }
3258        let idx = self.state.craft_menu_index as i32;
3259        let next = (idx + delta).rem_euclid(n as i32);
3260        self.state.craft_menu_index = next as usize;
3261        self.state.clamp_craft_batch_quantity();
3262    }
3263
3264    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
3265        self.state.craft_batch_adjust_quantity(delta);
3266    }
3267
3268    pub fn craft_batch_set_max(&mut self) {
3269        self.state.craft_batch_set_max();
3270    }
3271
3272    pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
3273        let Some(blueprint) = self
3274            .state
3275            .blueprints
3276            .get(self.state.craft_menu_index)
3277            .cloned()
3278        else {
3279            anyhow::bail!("no blueprints known");
3280        };
3281        if !self.state.can_craft_blueprint(&blueprint) {
3282            let hint = self
3283                .state
3284                .craft_missing_hint(&blueprint)
3285                .unwrap_or_else(|| "missing materials".into());
3286            anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
3287        }
3288        let count = self.state.craft_batch_quantity;
3289        let max = self.state.max_craft_batches(&blueprint);
3290        if max == 0 {
3291            anyhow::bail!("cannot craft {}", blueprint.label);
3292        }
3293        let batches = count.min(max);
3294        self.craft(&blueprint.id, Some(batches)).await?;
3295        self.state.show_craft_menu = false;
3296        Ok(())
3297    }
3298
3299    pub async fn move_by(
3300        &mut self,
3301        forward: f32,
3302        strafe: f32,
3303        vertical: f32,
3304        sprint: bool,
3305    ) -> anyhow::Result<()> {
3306        if !self.state.is_alive() {
3307            anyhow::bail!("you are dead");
3308        }
3309        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
3310            self.last_move_forward = forward;
3311            self.last_move_strafe = strafe;
3312        }
3313        self.seq += 1;
3314        self.session
3315            .submit_intent(Intent::Move {
3316                entity_id: self.state.entity_id,
3317                forward,
3318                strafe,
3319                vertical,
3320                sprint,
3321                seq: self.seq,
3322            })
3323            .await?;
3324        self.state.intents_sent += 1;
3325        Ok(())
3326    }
3327
3328    pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
3329        if !self.state.connected {
3330            crate::harvest_trace!("harvest_nearest rejected: not connected");
3331            anyhow::bail!("not connected");
3332        }
3333        if !self.state.is_alive() {
3334            crate::harvest_trace!("harvest_nearest rejected: player dead");
3335            anyhow::bail!("you are dead");
3336        }
3337        if self.state.harvest_in_progress {
3338            if self.state.harvest_state_stale() {
3339                self.state.clear_harvest_state();
3340            } else {
3341                anyhow::bail!("already harvesting");
3342            }
3343        }
3344        let (px, py) = self
3345            .state
3346            .player
3347            .as_ref()
3348            .map(|p| (p.transform.position.x, p.transform.position.y))
3349            .unwrap_or((0.0, 0.0));
3350
3351        let available = self
3352            .state
3353            .resource_nodes
3354            .iter()
3355            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
3356            .count();
3357        let node_id = self
3358            .state
3359            .resource_nodes
3360            .iter()
3361            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
3362            .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
3363            .min_by(|a, b| {
3364                let da = distance(px, py, a.x, a.y);
3365                let db = distance(px, py, b.x, b.y);
3366                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3367            })
3368            .map(|n| n.id.clone());
3369
3370        let Some(node_id) = node_id else {
3371            let has_loot = self.state.ground_drops.iter().any(|d| {
3372                distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M
3373            });
3374            if has_loot {
3375                return self.pickup_nearest().await;
3376            }
3377            anyhow::bail!(
3378                "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press p to pick up"
3379            );
3380        };
3381
3382        self.seq += 1;
3383        let seq = self.seq;
3384        crate::harvest_trace!(
3385            entity_id = self.state.entity_id,
3386            node_id = %node_id,
3387            seq,
3388            px,
3389            py,
3390            available_nodes = available,
3391            "submitting harvest intent"
3392        );
3393        self.session
3394            .submit_intent(Intent::Harvest {
3395                entity_id: self.state.entity_id,
3396                node_id,
3397                seq,
3398            })
3399            .await?;
3400        self.state.intents_sent += 1;
3401        self.state.harvest_in_progress = true;
3402        self.state.harvest_started_at = Some(Instant::now());
3403        self.state.push_log("Harvesting…");
3404        crate::harvest_trace!(entity_id = self.state.entity_id, seq, "harvest intent queued to session");
3405        Ok(())
3406    }
3407
3408    pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
3409        if !self.state.is_alive() {
3410            anyhow::bail!("you are dead");
3411        }
3412        let blueprint_id = self
3413            .state
3414            .blueprints
3415            .iter()
3416            .find(|bp| self.state.can_craft_blueprint(bp))
3417            .map(|bp| bp.id.clone())
3418            .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
3419        self.craft(&blueprint_id, None).await
3420    }
3421
3422    pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
3423        if !self.state.is_alive() {
3424            anyhow::bail!("you are dead");
3425        }
3426        self.seq += 1;
3427        self.session
3428            .submit_intent(Intent::Craft {
3429                entity_id: self.state.entity_id,
3430                blueprint_id: blueprint_id.to_string(),
3431                count,
3432                seq: self.seq,
3433            })
3434            .await?;
3435        self.state.intents_sent += 1;
3436        let (label, batches) = self
3437            .state
3438            .blueprints
3439            .iter()
3440            .find(|b| b.id == blueprint_id)
3441            .map(|b| {
3442                let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
3443                (b.label.as_str(), n)
3444            })
3445            .unwrap_or((blueprint_id, count.unwrap_or(1)));
3446        self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
3447        Ok(())
3448    }
3449
3450    pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
3451        if !self.state.is_alive() {
3452            anyhow::bail!("you are dead");
3453        }
3454        let target_id = match self.state.nearest_interact_target() {
3455            Some(id) => id,
3456            None => {
3457                let (px, py) = self.state.player_position();
3458                let has_loot = self.state.ground_drops.iter().any(|d| {
3459                    distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M
3460                });
3461                if has_loot {
3462                    return self.pickup_nearest().await;
3463                }
3464                anyhow::bail!("nothing to interact with nearby (stand on * loot and press p)");
3465            }
3466        };
3467        self.seq += 1;
3468        self.session
3469            .submit_intent(Intent::Interact {
3470                entity_id: self.state.entity_id,
3471                target_id: target_id.clone(),
3472                seq: self.seq,
3473            })
3474            .await?;
3475        self.state.intents_sent += 1;
3476        Ok(())
3477    }
3478
3479    pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
3480        self.seq += 1;
3481        self.session
3482            .submit_intent(Intent::TestDamage {
3483                entity_id: self.state.entity_id,
3484                amount,
3485                seq: self.seq,
3486            })
3487            .await?;
3488        self.state.intents_sent += 1;
3489        Ok(())
3490    }
3491
3492    pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
3493        self.cycle_combat_target_slot(1, reverse).await
3494    }
3495
3496    pub async fn cycle_combat_target_slot(
3497        &mut self,
3498        slot_index: u8,
3499        reverse: bool,
3500    ) -> anyhow::Result<()> {
3501        if !self.state.is_alive() {
3502            anyhow::bail!("you are dead");
3503        }
3504        let candidates = self.state.candidates_for_slot(slot_index);
3505        if candidates.is_empty() {
3506            anyhow::bail!("no targets nearby");
3507        }
3508        let current = self.state.target_for_slot(slot_index);
3509        let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
3510        let next_idx = match idx {
3511            None => 0,
3512            Some(i) if reverse => {
3513                if i == 0 {
3514                    candidates.len() - 1
3515                } else {
3516                    i - 1
3517                }
3518            }
3519            Some(i) => (i + 1) % candidates.len(),
3520        };
3521        if idx == Some(next_idx) && candidates.len() == 1 {
3522            self.clear_combat_target_slot(slot_index).await?;
3523            return Ok(());
3524        }
3525        let (target_id, label) = candidates[next_idx].clone();
3526        self.set_combat_target_slot(slot_index, target_id, &label)
3527            .await
3528    }
3529
3530    pub async fn set_combat_target_slot(
3531        &mut self,
3532        slot_index: u8,
3533        target_id: EntityId,
3534        label: &str,
3535    ) -> anyhow::Result<()> {
3536        if !self.state.is_alive() {
3537            anyhow::bail!("you are dead");
3538        }
3539        self.seq += 1;
3540        self.session
3541            .submit_intent(Intent::SetTargetSlot {
3542                entity_id: self.state.entity_id,
3543                slot_index,
3544                target_id,
3545                seq: self.seq,
3546            })
3547            .await?;
3548        self.state.intents_sent += 1;
3549        if slot_index == 1 {
3550            self.state.combat_target = Some(target_id);
3551            self.state.combat_target_label = Some(label.to_string());
3552        }
3553        self.state
3554            .push_log(format!("Slot {slot_index} target: {label}"));
3555        Ok(())
3556    }
3557
3558    pub async fn set_combat_target(
3559        &mut self,
3560        target_id: EntityId,
3561        label: &str,
3562    ) -> anyhow::Result<()> {
3563        self.set_combat_target_slot(1, target_id, label).await
3564    }
3565
3566    pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
3567        if slot_index == 1 && self.state.combat_target.is_none() {
3568            return Ok(());
3569        }
3570        self.seq += 1;
3571        self.session
3572            .submit_intent(Intent::ClearTargetSlot {
3573                entity_id: self.state.entity_id,
3574                slot_index,
3575                seq: self.seq,
3576            })
3577            .await?;
3578        if slot_index == 1 {
3579            self.state.combat_target = None;
3580            self.state.combat_target_label = None;
3581        }
3582        self.state.intents_sent += 1;
3583        self.state
3584            .push_log(format!("Slot {slot_index} target cleared"));
3585        Ok(())
3586    }
3587
3588    pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
3589        self.clear_combat_target_slot(1).await
3590    }
3591
3592    pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
3593        if !self.state.is_alive() {
3594            anyhow::bail!("you are dead");
3595        }
3596        self.seq += 1;
3597        self.session
3598            .submit_intent(Intent::AdvanceRotation {
3599                entity_id: self.state.entity_id,
3600                slot_index,
3601                seq: self.seq,
3602            })
3603            .await?;
3604        self.state.intents_sent += 1;
3605        Ok(())
3606    }
3607
3608    pub async fn assign_slot_preset(&mut self, slot_index: u8, preset_id: &str) -> anyhow::Result<()> {
3609        if !self.state.is_alive() {
3610            anyhow::bail!("you are dead");
3611        }
3612        self.seq += 1;
3613        self.session
3614            .submit_intent(Intent::AssignSlotPreset {
3615                entity_id: self.state.entity_id,
3616                slot_index,
3617                preset_id: preset_id.to_string(),
3618                seq: self.seq,
3619            })
3620            .await?;
3621        self.state.intents_sent += 1;
3622        if let Some(slot) = self
3623            .state
3624            .combat_slots
3625            .iter_mut()
3626            .find(|s| s.slot_index == slot_index)
3627        {
3628            slot.preset_id = Some(preset_id.to_string());
3629            if let Some(preset) = self.state.rotation_presets.iter().find(|p| p.id == preset_id) {
3630                slot.preset_label = Some(preset.label.clone());
3631                slot.rotation = preset.abilities.clone();
3632                slot.rotation_index = 0;
3633            }
3634        }
3635        self.state
3636            .push_log(format!("T{slot_index} loadout → {preset_id}"));
3637        Ok(())
3638    }
3639
3640    pub async fn cast_ability(
3641        &mut self,
3642        ability_id: &str,
3643        target_id: Option<EntityId>,
3644    ) -> anyhow::Result<()> {
3645        if !self.state.is_alive() {
3646            anyhow::bail!("you are dead");
3647        }
3648        let target_id = target_id
3649            .or_else(|| self.state.target_for_slot(2))
3650            .or_else(|| self.state.target_for_slot(1))
3651            .unwrap_or(self.state.entity_id);
3652        self.seq += 1;
3653        self.session
3654            .submit_intent(Intent::Cast {
3655                entity_id: self.state.entity_id,
3656                ability_id: ability_id.to_string(),
3657                target_id,
3658                seq: self.seq,
3659            })
3660            .await?;
3661        self.state.intents_sent += 1;
3662        self.state
3663            .push_log(format!("Cast {ability_id} → {target_id}"));
3664        Ok(())
3665    }
3666
3667    pub async fn upsert_rotation_preset(
3668        &mut self,
3669        preset: RotationPreset,
3670    ) -> anyhow::Result<()> {
3671        self.seq += 1;
3672        self.session
3673            .submit_intent(Intent::UpsertRotationPreset {
3674                entity_id: self.state.entity_id,
3675                preset: preset.clone(),
3676                seq: self.seq,
3677            })
3678            .await?;
3679        self.state.intents_sent += 1;
3680        if let Some(existing) = self
3681            .state
3682            .rotation_presets
3683            .iter_mut()
3684            .find(|p| p.id == preset.id)
3685        {
3686            *existing = preset.clone();
3687        } else {
3688            self.state.rotation_presets.push(preset.clone());
3689        }
3690        for slot in &mut self.state.combat_slots {
3691            if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
3692                slot.preset_label = Some(preset.label.clone());
3693                slot.rotation = preset.abilities.clone();
3694            }
3695        }
3696        self.state
3697            .push_log(format!("Saved rotation: {}", preset.label));
3698        Ok(())
3699    }
3700
3701    pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
3702        self.seq += 1;
3703        self.session
3704            .submit_intent(Intent::DeleteRotationPreset {
3705                entity_id: self.state.entity_id,
3706                preset_id: preset_id.to_string(),
3707                seq: self.seq,
3708            })
3709            .await?;
3710        self.state.intents_sent += 1;
3711        self.state
3712            .rotation_presets
3713            .retain(|p| p.id != preset_id);
3714        for slot in &mut self.state.combat_slots {
3715            if slot.preset_id.as_deref() == Some(preset_id) {
3716                slot.preset_id = None;
3717                slot.preset_label = None;
3718                slot.rotation.clear();
3719                slot.rotation_index = 0;
3720            }
3721        }
3722        self.state
3723            .push_log(format!("Deleted rotation: {preset_id}"));
3724        Ok(())
3725    }
3726
3727    pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
3728        if !self.state.is_alive() {
3729            anyhow::bail!("you are dead");
3730        }
3731        let enabled = !self
3732            .state
3733            .combat_slots
3734            .iter()
3735            .find(|s| s.slot_index == slot_index)
3736            .map(|s| s.auto_enabled)
3737            .unwrap_or(false);
3738        self.seq += 1;
3739        self.session
3740            .submit_intent(Intent::SetAutoAttack {
3741                entity_id: self.state.entity_id,
3742                slot_index,
3743                enabled,
3744                seq: self.seq,
3745            })
3746            .await?;
3747        if slot_index == 1 {
3748            self.state.auto_attack = enabled;
3749        }
3750        self.state.intents_sent += 1;
3751        self.state.push_log(format!(
3752            "T{slot_index} auto {}",
3753            if enabled { "ON" } else { "OFF" }
3754        ));
3755        Ok(())
3756    }
3757
3758    pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
3759        if !self.state.connected {
3760            anyhow::bail!("not connected");
3761        }
3762        if !self.state.is_alive() {
3763            anyhow::bail!("you are dead");
3764        }
3765        let (px, py) = self.state.player_position();
3766        if self
3767            .state
3768            .ground_drops
3769            .iter()
3770            .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
3771        {
3772            anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press p");
3773        }
3774        self.seq += 1;
3775        self.session
3776            .submit_intent(Intent::Pickup {
3777                entity_id: self.state.entity_id,
3778                drop_id: None,
3779                seq: self.seq,
3780            })
3781            .await?;
3782        self.state.intents_sent += 1;
3783        Ok(())
3784    }
3785
3786    pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
3787        self.toggle_auto_attack_slot(1).await
3788    }
3789
3790    pub async fn dodge(&mut self) -> anyhow::Result<()> {
3791        if !self.state.is_alive() {
3792            anyhow::bail!("you are dead");
3793        }
3794        self.seq += 1;
3795        self.session
3796            .submit_intent(Intent::Dodge {
3797                entity_id: self.state.entity_id,
3798                seq: self.seq,
3799            })
3800            .await?;
3801        self.state.intents_sent += 1;
3802        self.state.push_log("Dodge!");
3803        Ok(())
3804    }
3805
3806    pub async fn lunge(&mut self) -> anyhow::Result<()> {
3807        if !self.state.is_alive() {
3808            anyhow::bail!("you are dead");
3809        }
3810        let (forward, strafe) = self.last_move_axes();
3811        self.seq += 1;
3812        self.session
3813            .submit_intent(Intent::Lunge {
3814                entity_id: self.state.entity_id,
3815                forward,
3816                strafe,
3817                seq: self.seq,
3818            })
3819            .await?;
3820        self.state.intents_sent += 1;
3821        self.state.push_log("Lunge!");
3822        Ok(())
3823    }
3824
3825    /// Remembered WASD axes for lunge when not currently moving.
3826    pub fn last_move_axes(&self) -> (f32, f32) {
3827        (self.last_move_forward, self.last_move_strafe)
3828    }
3829
3830    pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
3831        if !self.state.is_alive() {
3832            anyhow::bail!("you are dead");
3833        }
3834        self.seq += 1;
3835        self.session
3836            .submit_intent(Intent::Block {
3837                entity_id: self.state.entity_id,
3838                enabled,
3839                seq: self.seq,
3840            })
3841            .await?;
3842        self.state.intents_sent += 1;
3843        if enabled {
3844            self.state.push_log("Blocking");
3845        }
3846        Ok(())
3847    }
3848
3849    pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
3850        if !self.state.is_alive() {
3851            anyhow::bail!("you are dead");
3852        }
3853        self.seq += 1;
3854        self.session
3855            .submit_intent(Intent::EquipMainhand {
3856                entity_id: self.state.entity_id,
3857                template_id,
3858                seq: self.seq,
3859            })
3860            .await?;
3861        self.state.intents_sent += 1;
3862        Ok(())
3863    }
3864
3865    pub async fn say(&mut self, channel: flatland_protocol::ChatChannel, text: &str) -> anyhow::Result<()> {
3866        self.seq += 1;
3867        self.session
3868            .submit_intent(Intent::Say {
3869                entity_id: self.state.entity_id,
3870                channel,
3871                text: text.to_string(),
3872                seq: self.seq,
3873            })
3874            .await?;
3875        self.state.intents_sent += 1;
3876        Ok(())
3877    }
3878
3879    pub async fn stop(&mut self) -> anyhow::Result<()> {
3880        self.seq += 1;
3881        self.session
3882            .submit_intent(Intent::Stop {
3883                entity_id: self.state.entity_id,
3884                seq: self.seq,
3885            })
3886            .await?;
3887        self.state.intents_sent += 1;
3888        Ok(())
3889    }
3890
3891    pub fn disconnect(&self) {
3892        self.session.disconnect();
3893    }
3894}
3895
3896fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
3897    let dx = ax - bx;
3898    let dy = ay - by;
3899    (dx * dx + dy * dy).sqrt()
3900}
3901
3902#[cfg(test)]
3903mod tests {
3904    use std::collections::BTreeMap;
3905
3906    use super::*;
3907    use flatland_protocol::{
3908        BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
3909    };
3910
3911    fn sample_state() -> GameState {
3912        let mut state = GameState {
3913            session_id: 1,
3914            entity_id: 1,
3915            character_id: None,
3916            tick: 0,
3917            chunk_rev: 0,
3918            content_rev: 0,
3919            entities: vec![EntityState {
3920                id: 1,
3921                label: "You".into(),
3922                transform: Transform {
3923                    position: WorldCoord::surface(128.0, 128.0),
3924                    yaw: 0.0,
3925                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
3926                },
3927                vitals: None,
3928                attributes: None,
3929                skills: None,
3930                inside_building: None,
3931            }],
3932            player: None,
3933            resource_nodes: vec![ResourceNodeView {
3934                id: "oak-1".into(),
3935                label: "Oak".into(),
3936                x: 130.0,
3937                y: 128.0,
3938                z: 0.0,
3939                item_template: "oak_log".into(),
3940                state: ResourceNodeState::Available,
3941                blocking: true,
3942                blocking_radius_m: 0.8,
3943            }],
3944            ground_drops: vec![],
3945            placed_containers: vec![],
3946            buildings: vec![BuildingView {
3947                id: "broker-hut".into(),
3948                label: "Broker".into(),
3949                x: 148.0,
3950                y: 118.0,
3951                width_m: 8.0,
3952                depth_m: 6.0,
3953                interior_origin_x: 404.0,
3954                interior_origin_y: 403.0,
3955                tags: vec![],
3956            }],
3957            doors: vec![flatland_protocol::DoorView {
3958                id: "door-1".into(),
3959                building_id: "broker-hut".into(),
3960                x: 148.0,
3961                y: 118.0,
3962                open: false,
3963                is_exit: false,
3964            }],
3965            npcs: vec![],
3966            blueprints: vec![],
3967            world_width_m: 256.0,
3968            world_height_m: 256.0,
3969            terrain_zones: Vec::new(),
3970            world_clock: flatland_protocol::WorldClock::default(),
3971            inventory: std::collections::HashMap::new(),
3972            inventory_hints: std::collections::HashMap::new(),
3973            logs: VecDeque::new(),
3974            intents_sent: 0,
3975            ticks_received: 0,
3976            connected: true,
3977            disconnect_reason: None,
3978            show_stats: false,
3979            show_craft_menu: false,
3980            craft_menu_index: 0,
3981            craft_batch_quantity: 1,
3982            show_shop_menu: false,
3983            shop_catalog: None,
3984            shop_tab: ShopTab::default(),
3985            shop_menu_index: 0,
3986            shop_quantity: 1,
3987            show_inventory_menu: false,
3988            inventory_menu_index: 0,
3989            show_move_picker: false,
3990            show_rename_prompt: false,
3991            rename_buffer: String::new(),
3992            move_picker_index: 0,
3993            move_picker: None,
3994            show_destroy_picker: false,
3995            destroy_confirm_pending: false,
3996            destroy_picker: None,
3997            combat_target: None,
3998            combat_target_label: None,
3999            in_combat: false,
4000            auto_attack: true,
4001            combat_has_los: false,
4002            attack_cd_ticks: 0,
4003            gcd_ticks: 0,
4004            weapon_ability_id: "unarmed".into(),
4005            mainhand_template_id: None,
4006            mainhand_label: None,
4007            worn: BTreeMap::new(),
4008            carry_mass: 0.0,
4009            carry_mass_max: 0.0,
4010            encumbrance: flatland_protocol::EncumbranceState::Light,
4011            inventory_stacks: Vec::new(),
4012            combat_target_detail: None,
4013            cast_progress: None,
4014            ability_cooldowns: Vec::new(),
4015            blocking_active: false,
4016            max_target_slots: 1,
4017            combat_slots: Vec::new(),
4018            rotation_presets: Vec::new(),
4019            show_loadout_menu: false,
4020            show_rotation_editor: false,
4021            loadout_menu_index: 0,
4022            rotation_editor: RotationEditorState::default(),
4023            harvest_in_progress: false,
4024            harvest_started_at: None,
4025            pending_craft_ack: None,
4026        };
4027        state.player = state.entities.first().cloned();
4028        state
4029    }
4030
4031    #[test]
4032    fn empty_entity_tick_preserves_welcome_snapshot() {
4033        let mut state = sample_state();
4034        state.inventory.insert("carrot".into(), 3);
4035        let delta = TickDelta {
4036            tick: 1,
4037            entities: vec![],
4038            resource_nodes: vec![],
4039            ground_drops: vec![],
4040            placed_containers: vec![],
4041            buildings: vec![],
4042            doors: vec![],
4043            npcs: vec![],
4044            inventory: vec![],
4045            blueprints: vec![],
4046            world_clock: flatland_protocol::WorldClock::default(),
4047            combat: None,
4048        };
4049
4050        state.apply_tick_fields(&delta, 1);
4051
4052        assert_eq!(state.entities.len(), 1);
4053        assert!(state.player.is_some());
4054        assert_eq!(state.inventory.get("carrot"), Some(&3));
4055        assert_eq!(state.resource_nodes.len(), 1);
4056    }
4057
4058    #[test]
4059    fn tick_preserves_world_layers_when_delta_omits_them() {
4060        let mut state = sample_state();
4061        let delta = TickDelta {
4062            tick: 1,
4063            entities: state.entities.clone(),
4064            resource_nodes: vec![],
4065            ground_drops: vec![],
4066            placed_containers: vec![],
4067            buildings: vec![],
4068            doors: vec![],
4069            npcs: vec![],
4070            inventory: vec![],
4071            blueprints: vec![],
4072            world_clock: flatland_protocol::WorldClock::default(),
4073            combat: None,
4074        };
4075
4076        state.apply_tick_fields(&delta, 1);
4077
4078        assert_eq!(state.resource_nodes.len(), 1);
4079        assert_eq!(state.buildings.len(), 1);
4080        assert_eq!(state.doors.len(), 1);
4081    }
4082
4083    #[test]
4084    fn tick_updates_resource_nodes_when_server_sends_them() {
4085        let mut state = sample_state();
4086        let delta = TickDelta {
4087            tick: 1,
4088            entities: state.entities.clone(),
4089            resource_nodes: vec![ResourceNodeView {
4090                id: "oak-1".into(),
4091                label: "Oak".into(),
4092                x: 130.0,
4093                y: 128.0,
4094                z: 0.0,
4095                item_template: "oak_log".into(),
4096                state: ResourceNodeState::Cooldown,
4097                blocking: true,
4098                blocking_radius_m: 0.8,
4099            }],
4100            buildings: vec![],
4101            doors: vec![],
4102            npcs: vec![],
4103            inventory: vec![],
4104            blueprints: vec![],
4105            world_clock: flatland_protocol::WorldClock::default(),
4106            ground_drops: vec![],
4107            placed_containers: vec![],
4108            combat: None,
4109        };
4110
4111        state.apply_tick_fields(&delta, 1);
4112
4113        assert!(matches!(
4114            state.resource_nodes[0].state,
4115            ResourceNodeState::Cooldown
4116        ));
4117    }
4118
4119    #[test]
4120    fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
4121        let mut state = GameState {
4122            session_id: 1,
4123            entity_id: 1,
4124            character_id: None,
4125            tick: 0,
4126            chunk_rev: 0,
4127            content_rev: 0,
4128            entities: vec![EntityState {
4129                id: 1,
4130                label: "You".into(),
4131                transform: Transform {
4132                    position: WorldCoord::surface(406.0, 403.0),
4133                    yaw: 0.0,
4134                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
4135                },
4136                vitals: None,
4137                attributes: None,
4138                skills: None,
4139                inside_building: None,
4140            }],
4141            player: None,
4142            resource_nodes: vec![],
4143            ground_drops: vec![],
4144            placed_containers: vec![],
4145            buildings: vec![BuildingView {
4146                id: "broker_hut".into(),
4147                label: "Broker".into(),
4148                x: 158.0,
4149                y: 124.0,
4150                width_m: 8.0,
4151                depth_m: 6.0,
4152                interior_origin_x: 400.0,
4153                interior_origin_y: 400.0,
4154                tags: vec![],
4155            }],
4156            doors: vec![flatland_protocol::DoorView {
4157                id: "broker_hut_exit".into(),
4158                building_id: "broker_hut".into(),
4159                x: 404.0,
4160                y: 400.0,
4161                open: true,
4162                is_exit: true,
4163            }],
4164            npcs: vec![flatland_protocol::NpcView {
4165                id: "ada_broker".into(),
4166                label: "Ada".into(),
4167                x: 406.0,
4168                y: 403.0,
4169                building_id: Some("broker_hut".into()),
4170                role: "broker".into(),
4171                entity_id: None,
4172                life_state: None,
4173                hp_pct: None,
4174            }],
4175            blueprints: vec![],
4176            world_width_m: 256.0,
4177            world_height_m: 256.0,
4178            terrain_zones: Vec::new(),
4179            world_clock: flatland_protocol::WorldClock::default(),
4180            inventory: std::collections::HashMap::new(),
4181            inventory_hints: std::collections::HashMap::new(),
4182            logs: VecDeque::new(),
4183            intents_sent: 0,
4184            ticks_received: 0,
4185            connected: true,
4186            disconnect_reason: None,
4187            show_stats: false,
4188            show_craft_menu: false,
4189            craft_menu_index: 0,
4190            craft_batch_quantity: 1,
4191            show_shop_menu: false,
4192            shop_catalog: None,
4193            shop_tab: ShopTab::default(),
4194            shop_menu_index: 0,
4195            shop_quantity: 1,
4196            show_inventory_menu: false,
4197            inventory_menu_index: 0,
4198            show_move_picker: false,
4199            show_rename_prompt: false,
4200            rename_buffer: String::new(),
4201            move_picker_index: 0,
4202            move_picker: None,
4203            show_destroy_picker: false,
4204            destroy_confirm_pending: false,
4205            destroy_picker: None,
4206            combat_target: None,
4207            combat_target_label: None,
4208            in_combat: false,
4209            auto_attack: true,
4210            combat_has_los: false,
4211            attack_cd_ticks: 0,
4212            gcd_ticks: 0,
4213            weapon_ability_id: "unarmed".into(),
4214            mainhand_template_id: None,
4215            mainhand_label: None,
4216            worn: BTreeMap::new(),
4217            carry_mass: 0.0,
4218            carry_mass_max: 0.0,
4219            encumbrance: flatland_protocol::EncumbranceState::Light,
4220            inventory_stacks: Vec::new(),
4221            combat_target_detail: None,
4222            cast_progress: None,
4223            ability_cooldowns: Vec::new(),
4224            blocking_active: false,
4225            max_target_slots: 1,
4226            combat_slots: Vec::new(),
4227            rotation_presets: Vec::new(),
4228            show_loadout_menu: false,
4229            show_rotation_editor: false,
4230            loadout_menu_index: 0,
4231            rotation_editor: RotationEditorState::default(),
4232            harvest_in_progress: false,
4233            harvest_started_at: None,
4234            pending_craft_ack: None,
4235        };
4236        state.player = state.entities.first().cloned();
4237        assert_eq!(
4238            state.nearest_interact_target().as_deref(),
4239            Some("ada_broker")
4240        );
4241    }
4242
4243    #[test]
4244    fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
4245        let mut state = sample_state();
4246        // Player is at (128, 128) per sample_state(). One chest just inside
4247        // CONTAINER_RANGE_M, one clearly beyond it.
4248        state.placed_containers = vec![
4249            flatland_protocol::PlacedContainerView {
4250                id: "near".into(),
4251                template_id: "wooden_chest_small".into(),
4252                display_name: "Wooden Chest".into(),
4253                x: 130.0,
4254                y: 128.0,
4255                z: 0.0,
4256                locked: true,
4257                accessible: true,
4258                owner_character_id: None,
4259                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
4260                lock_id: None,
4261                capacity_volume: None,
4262                item_instance_id: Some(uuid::Uuid::from_u128(1)),
4263            },
4264            flatland_protocol::PlacedContainerView {
4265                id: "far".into(),
4266                template_id: "wooden_chest_small".into(),
4267                display_name: "Distant Chest".into(),
4268                x: 128.0 + CONTAINER_RANGE_M + 5.0,
4269                y: 128.0,
4270                z: 0.0,
4271                locked: false,
4272                accessible: true,
4273                owner_character_id: None,
4274                contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
4275                lock_id: None,
4276                capacity_volume: None,
4277                item_instance_id: Some(uuid::Uuid::from_u128(2)),
4278            },
4279        ];
4280
4281        let nearby = state.nearby_containers();
4282        assert_eq!(nearby.len(), 1, "far chest must not appear once out of range");
4283        assert_eq!(nearby[0].view.id, "near");
4284        assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
4285        assert!(nearby[0].rows[0].is_chest_shell);
4286
4287        // The same chest, but locked and inaccessible (no key held), must hide
4288        // contents but still show the selectable chest shell row.
4289        state.placed_containers[0].accessible = false;
4290        let nearby = state.nearby_containers();
4291        assert_eq!(nearby.len(), 1);
4292        assert_eq!(nearby[0].rows.len(), 1);
4293        assert!(nearby[0].rows[0].is_chest_shell);
4294    }
4295
4296    #[test]
4297    fn placed_container_public_label_hides_owner_custom_name() {
4298        let owner = uuid::Uuid::from_u128(99);
4299        let mut state = sample_state();
4300        state.character_id = Some(uuid::Uuid::from_u128(1));
4301        state.inventory_hints.insert(
4302            "wooden_chest_medium".into(),
4303            InventoryHint {
4304                display_name: "Medium Wooden Chest".into(),
4305                category: "container".into(),
4306                base_mass: None,
4307                base_volume: None,
4308                capacity_volume: None,
4309                stackable: false,
4310            },
4311        );
4312        let chest = flatland_protocol::PlacedContainerView {
4313            id: "c1".into(),
4314            template_id: "wooden_chest_medium".into(),
4315            display_name: "Barry's Loot #a3f2".into(),
4316            x: 128.0,
4317            y: 128.0,
4318            z: 0.0,
4319            locked: false,
4320            accessible: true,
4321            owner_character_id: Some(owner),
4322            contents: vec![],
4323            lock_id: None,
4324            capacity_volume: None,
4325            item_instance_id: None,
4326        };
4327        assert_eq!(
4328            state.placed_container_public_label(&chest),
4329            "Medium Wooden Chest"
4330        );
4331        state.character_id = Some(owner);
4332        assert_eq!(
4333            state.placed_container_public_label(&chest),
4334            "Barry's Loot #a3f2"
4335        );
4336    }
4337
4338    #[test]
4339    fn location_context_lists_nearby_resource_node() {
4340        let mut state = sample_state();
4341        state.player = state.entities.first().cloned();
4342        state.resource_nodes[0].x = 128.2;
4343        state.resource_nodes[0].y = 128.0;
4344        let lines = state.location_context_lines();
4345        assert!(
4346            lines.iter().any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
4347            "expected resource node in context: {:?}",
4348            lines
4349        );
4350    }
4351
4352    #[test]
4353    fn inventory_selectable_rows_orders_worn_before_person_before_nearby() {
4354        let mut state = sample_state();
4355        state.worn.insert(
4356            BodySlot::Back,
4357            flatland_protocol::ItemStack {
4358                template_id: "travel_backpack".into(),
4359                quantity: 1,
4360                item_instance_id: Some(uuid::Uuid::from_u128(3)),
4361                props: Default::default(),
4362                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
4363                display_name: None,
4364                category: None,
4365                base_mass: None,
4366                base_volume: None,
4367                capacity_volume: None,
4368                stackable: None,
4369            },
4370        );
4371        state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
4372        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
4373            id: "chest-1".into(),
4374            template_id: "wooden_chest_small".into(),
4375            display_name: "Wooden Chest".into(),
4376            x: 129.0,
4377            y: 128.0,
4378            z: 0.0,
4379            locked: false,
4380            accessible: true,
4381            owner_character_id: None,
4382            contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
4383            lock_id: None,
4384            capacity_volume: None,
4385            item_instance_id: Some(uuid::Uuid::from_u128(4)),
4386        }];
4387
4388        let rows = state.inventory_selectable_rows();
4389        let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
4390        assert_eq!(
4391            sections,
4392            vec![
4393                InventorySection::Worn,   // backpack shell
4394                InventorySection::Worn,   // iron_ore nested in backpack
4395                InventorySection::Person, // lumber
4396                InventorySection::Nearby, // chest shell
4397                InventorySection::Nearby, // wood_axe in chest
4398            ]
4399        );
4400        assert_eq!(rows[0].stack.template_id, "travel_backpack");
4401        assert!(rows[0].is_equip_shell);
4402        assert_eq!(rows[1].stack.template_id, "iron_ore");
4403        assert_eq!(rows[1].depth, 1);
4404        assert_eq!(rows[2].stack.template_id, "lumber");
4405        assert!(rows[3].is_chest_shell);
4406        assert_eq!(rows[4].stack.template_id, "wood_axe");
4407        assert_eq!(rows[4].depth, 1);
4408    }
4409
4410    #[test]
4411    fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
4412        let mut state = sample_state();
4413        let back_id = uuid::Uuid::from_u128(5);
4414        state.worn.insert(
4415            BodySlot::Back,
4416            flatland_protocol::ItemStack {
4417                template_id: "travel_backpack".into(),
4418                quantity: 1,
4419                item_instance_id: Some(back_id),
4420                props: Default::default(),
4421                contents: Vec::new(),
4422                display_name: None,
4423                category: Some("container".into()),
4424                base_mass: None,
4425                base_volume: None,
4426                capacity_volume: Some(80.0),
4427                stackable: None,
4428            },
4429        );
4430        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
4431            id: "chest-1".into(),
4432            template_id: "wooden_chest_small".into(),
4433            display_name: "Wooden Chest".into(),
4434            x: 129.0,
4435            y: 128.0,
4436            z: 0.0,
4437            locked: false,
4438            accessible: true,
4439            owner_character_id: None,
4440            contents: Vec::new(),
4441            lock_id: None,
4442            capacity_volume: None,
4443            item_instance_id: Some(uuid::Uuid::from_u128(6)),
4444        }];
4445
4446        // Item currently sitting loose on the person (Root): backpack + nearby
4447        // chest should both be offered, plus Drop/Cancel, but not "Root" itself.
4448        let opts = state.move_destinations_for(
4449            &flatland_protocol::InventoryLocation::Root,
4450            None,
4451            None,
4452            "lumber",
4453        );
4454        assert!(!opts.iter().any(|o| matches!(
4455            &o.kind,
4456            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
4457        )));
4458        assert!(opts.iter().any(|o| matches!(
4459            &o.kind,
4460            MoveOptionKind::Move { location, parent_instance_id, .. }
4461                if *location == flatland_protocol::InventoryLocation::Worn {
4462                    slot: BodySlot::Back,
4463                } && *parent_instance_id == Some(back_id)
4464        )));
4465        assert!(opts.iter().any(|o| matches!(
4466            &o.kind,
4467            MoveOptionKind::Move { location, .. }
4468                if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
4469        )));
4470        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
4471        assert!(matches!(
4472            opts[opts.len() - 2].kind,
4473            MoveOptionKind::Drop
4474        ));
4475
4476        // Item currently inside the worn backpack: the backpack itself must be
4477        // excluded from its own destination list (can't move an item into the
4478        // container it's already in).
4479        let from_backpack = flatland_protocol::InventoryLocation::Worn {
4480            slot: BodySlot::Back,
4481        };
4482        let opts = state.move_destinations_for(
4483            &from_backpack,
4484            Some(back_id),
4485            None,
4486            "iron_ore",
4487        );
4488        assert!(!opts.iter().any(|o| matches!(
4489            &o.kind,
4490            MoveOptionKind::Move { location, parent_instance_id, .. }
4491                if *location == from_backpack && *parent_instance_id == Some(back_id)
4492        )));
4493        assert!(opts.iter().any(|o| matches!(
4494            &o.kind,
4495            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
4496        )));
4497    }
4498
4499    #[test]
4500    fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
4501        let mut state = sample_state();
4502        // Insert out of display order — BTreeMap iteration must still yield the
4503        // canonical Head/Body/Arms/Legs/Feet/Back/Waist order regardless.
4504        state.worn.insert(
4505            BodySlot::Waist,
4506            flatland_protocol::ItemStack {
4507                template_id: "simple_belt".into(),
4508                quantity: 1,
4509                item_instance_id: Some(uuid::Uuid::from_u128(10)),
4510                props: Default::default(),
4511                contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
4512                display_name: None,
4513                category: Some("container".into()),
4514                base_mass: None,
4515                base_volume: None,
4516                capacity_volume: None,
4517                stackable: None,
4518            },
4519        );
4520        state.worn.insert(
4521            BodySlot::Head,
4522            flatland_protocol::ItemStack {
4523                template_id: "cloth_cap".into(),
4524                quantity: 1,
4525                item_instance_id: Some(uuid::Uuid::from_u128(11)),
4526                props: Default::default(),
4527                contents: Vec::new(),
4528                display_name: None,
4529                category: Some("armor".into()),
4530                base_mass: None,
4531                base_volume: None,
4532                capacity_volume: None,
4533                stackable: None,
4534            },
4535        );
4536        state.worn.insert(
4537            BodySlot::Back,
4538            flatland_protocol::ItemStack {
4539                template_id: "travel_backpack".into(),
4540                quantity: 1,
4541                item_instance_id: Some(uuid::Uuid::from_u128(12)),
4542                props: Default::default(),
4543                contents: Vec::new(),
4544                display_name: None,
4545                category: Some("container".into()),
4546                base_mass: None,
4547                base_volume: None,
4548                capacity_volume: None,
4549                stackable: None,
4550            },
4551        );
4552
4553        let rows = state.worn_rows();
4554        // Head, then Back, then Waist (+ nested pouch) — enum declaration order.
4555        assert_eq!(rows.len(), 4);
4556        assert_eq!(rows[0].stack.template_id, "cloth_cap");
4557        assert!(rows[0].is_equip_shell);
4558        assert_eq!(rows[1].stack.template_id, "travel_backpack");
4559        assert!(rows[1].is_equip_shell);
4560        assert_eq!(rows[2].stack.template_id, "simple_belt");
4561        assert!(rows[2].is_equip_shell);
4562        assert_eq!(rows[3].stack.template_id, "leather_pouch");
4563        assert_eq!(rows[3].depth, 1);
4564        assert!(!rows[3].is_equip_shell);
4565    }
4566
4567    #[test]
4568    fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
4569        let mut state = sample_state();
4570        state.worn.insert(
4571            BodySlot::Waist,
4572            flatland_protocol::ItemStack {
4573                template_id: "simple_belt".into(),
4574                quantity: 1,
4575                item_instance_id: Some(uuid::Uuid::from_u128(20)),
4576                props: Default::default(),
4577                contents: Vec::new(),
4578                display_name: Some("Simple Belt".into()),
4579                category: Some("container".into()),
4580                base_mass: None,
4581                base_volume: None,
4582                capacity_volume: None,
4583                stackable: None,
4584            },
4585        );
4586        state.worn.insert(
4587            BodySlot::Head,
4588            flatland_protocol::ItemStack {
4589                template_id: "cloth_cap".into(),
4590                quantity: 1,
4591                item_instance_id: Some(uuid::Uuid::from_u128(21)),
4592                props: Default::default(),
4593                contents: Vec::new(),
4594                display_name: Some("Cloth Cap".into()),
4595                category: Some("armor".into()),
4596                base_mass: None,
4597                base_volume: None,
4598                capacity_volume: None,
4599                stackable: None,
4600            },
4601        );
4602
4603        let opts = state.move_destinations_for(
4604            &flatland_protocol::InventoryLocation::Root,
4605            None,
4606            None,
4607            "leather_pouch",
4608        );
4609        assert!(
4610            opts.iter().any(|o| matches!(
4611                &o.kind,
4612                MoveOptionKind::Move { location, .. }
4613                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
4614            )),
4615            "belt loop must be offered when moving a pouch"
4616        );
4617        assert!(
4618            !opts.iter().any(|o| matches!(
4619                &o.kind,
4620                MoveOptionKind::Move { location, .. }
4621                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
4622            )),
4623            "armor slots can't hold other items and must not appear as move destinations"
4624        );
4625        let belt_opt = opts
4626            .iter()
4627            .find(|o| matches!(
4628                &o.kind,
4629                MoveOptionKind::Move { location, .. }
4630                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
4631            ))
4632            .unwrap();
4633        assert!(belt_opt.label.contains("belt loop"));
4634
4635        let opts = state.move_destinations_for(
4636            &flatland_protocol::InventoryLocation::Root,
4637            None,
4638            None,
4639            "lumber",
4640        );
4641        assert!(
4642            !opts.iter().any(|o| o.label.contains("belt loop")),
4643            "loose materials must not target the belt shell — only nested pouches"
4644        );
4645    }
4646
4647    #[test]
4648    fn move_destinations_for_offers_dimensional_pouch_on_belt() {
4649        let mut state = sample_state();
4650        let belt_id = uuid::Uuid::from_u128(30);
4651        let pouch_id = uuid::Uuid::from_u128(31);
4652        state.worn.insert(
4653            BodySlot::Waist,
4654            flatland_protocol::ItemStack {
4655                template_id: "simple_belt".into(),
4656                quantity: 1,
4657                item_instance_id: Some(belt_id),
4658                props: Default::default(),
4659                contents: vec![flatland_protocol::ItemStack {
4660                    template_id: "dimensional_pouch".into(),
4661                    quantity: 1,
4662                    item_instance_id: Some(pouch_id),
4663                    props: Default::default(),
4664                    contents: Vec::new(),
4665                    display_name: Some("Dimensional Pouch".into()),
4666                    category: Some("container".into()),
4667                    base_mass: None,
4668                    base_volume: None,
4669                    capacity_volume: Some(200.0),
4670                    stackable: None,
4671                }],
4672                display_name: Some("Simple Belt".into()),
4673                category: Some("container".into()),
4674                base_mass: None,
4675                base_volume: None,
4676                capacity_volume: None,
4677                stackable: None,
4678            },
4679        );
4680
4681        let opts = state.move_destinations_for(
4682            &flatland_protocol::InventoryLocation::Root,
4683            None,
4684            None,
4685            "iron_ore",
4686        );
4687        assert!(
4688            opts.iter().any(|o| matches!(
4689                &o.kind,
4690                MoveOptionKind::Move {
4691                    location,
4692                    parent_instance_id,
4693                    ..
4694                } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
4695                    && *parent_instance_id == Some(pouch_id)
4696            )),
4697            "dimensional pouch clipped on belt must accept loose items"
4698        );
4699        assert!(
4700            opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
4701            "destination label should name the pouch"
4702        );
4703    }
4704
4705    #[test]
4706    fn container_volume_label_on_placed_chest_shell() {
4707        let mut state = sample_state();
4708        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
4709            id: "chest-1".into(),
4710            template_id: "wooden_chest_small".into(),
4711            display_name: "Camp Chest".into(),
4712            x: 129.0,
4713            y: 128.0,
4714            z: 0.0,
4715            locked: false,
4716            accessible: true,
4717            owner_character_id: None,
4718            contents: vec![flatland_protocol::ItemStack {
4719                template_id: "iron_ore".into(),
4720                quantity: 2,
4721                item_instance_id: None,
4722                props: Default::default(),
4723                contents: Vec::new(),
4724                display_name: None,
4725                category: None,
4726                base_mass: None,
4727                base_volume: Some(2.0),
4728                capacity_volume: None,
4729                stackable: None,
4730            }],
4731            lock_id: None,
4732            capacity_volume: Some(60.0),
4733            item_instance_id: Some(uuid::Uuid::from_u128(4)),
4734        }];
4735        let nearby = state.nearby_containers();
4736        let label = state.container_volume_label(&nearby[0].rows[0]);
4737        assert!(
4738            label.contains("vol 4/60"),
4739            "expected used/cap in label, got {label}"
4740        );
4741        assert!(label.contains("56 free"), "expected free space, got {label}");
4742    }
4743
4744    #[test]
4745    fn key_pair_chest_label_from_placed_lock_id() {
4746        let mut state = sample_state();
4747        let owner = uuid::Uuid::from_u128(77);
4748        state.character_id = Some(owner);
4749        let lock = uuid::Uuid::from_u128(99).to_string();
4750        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
4751            id: "chest-1".into(),
4752            template_id: "wooden_chest_small".into(),
4753            display_name: "Barry's Loot #a3f2".into(),
4754            x: 129.0,
4755            y: 128.0,
4756            z: 0.0,
4757            locked: true,
4758            accessible: true,
4759            owner_character_id: Some(owner),
4760            contents: Vec::new(),
4761            lock_id: Some(lock.clone()),
4762            capacity_volume: None,
4763            item_instance_id: Some(uuid::Uuid::from_u128(4)),
4764        }];
4765        let key_id = uuid::Uuid::from_u128(5);
4766        let key = flatland_protocol::ItemStack {
4767            template_id: KEY_TEMPLATE.into(),
4768            quantity: 1,
4769            item_instance_id: Some(key_id),
4770            props: BTreeMap::from([
4771                (PROP_OPENS_LOCK_ID.into(), lock),
4772                (PROP_OPENS_CONTAINER_NAME.into(), "Barry's Loot #a3f2".into()),
4773            ]),
4774            contents: Vec::new(),
4775            display_name: Some("Container Key".into()),
4776            category: Some("key".into()),
4777            base_mass: None,
4778            base_volume: None,
4779            capacity_volume: None,
4780            stackable: None,
4781        };
4782        state.inventory_stacks = vec![key.clone()];
4783        assert_eq!(
4784            state.key_pair_chest_label(&key).as_deref(),
4785            Some("Barry's Loot #a3f2")
4786        );
4787        assert!(state.key_drop_blocked(&key));
4788    }
4789
4790    #[test]
4791    fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
4792        let mut state = sample_state();
4793        let lock = uuid::Uuid::from_u128(101).to_string();
4794        let key = flatland_protocol::ItemStack {
4795            template_id: KEY_TEMPLATE.into(),
4796            quantity: 1,
4797            item_instance_id: Some(uuid::Uuid::from_u128(7)),
4798            props: BTreeMap::from([
4799                (PROP_OPENS_LOCK_ID.into(), lock),
4800                (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
4801            ]),
4802            contents: Vec::new(),
4803            display_name: None,
4804            category: Some("key".into()),
4805            base_mass: None,
4806            base_volume: None,
4807            capacity_volume: None,
4808            stackable: None,
4809        };
4810        state.placed_containers.clear();
4811        assert_eq!(
4812            state.key_pair_chest_label(&key).as_deref(),
4813            Some("Camp Stash")
4814        );
4815    }
4816
4817    #[test]
4818    fn key_drop_allowed_when_paired_chest_unlocked() {
4819        let mut state = sample_state();
4820        let lock = uuid::Uuid::from_u128(100).to_string();
4821        let key_id = uuid::Uuid::from_u128(6);
4822        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
4823            id: "chest-1".into(),
4824            template_id: "wooden_chest_small".into(),
4825            display_name: "Camp Chest".into(),
4826            x: 129.0,
4827            y: 128.0,
4828            z: 0.0,
4829            locked: false,
4830            accessible: true,
4831            owner_character_id: None,
4832            contents: Vec::new(),
4833            lock_id: Some(lock.clone()),
4834            capacity_volume: None,
4835            item_instance_id: None,
4836        }];
4837        let key = flatland_protocol::ItemStack {
4838            template_id: KEY_TEMPLATE.into(),
4839            quantity: 1,
4840            item_instance_id: Some(key_id),
4841            props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
4842            contents: Vec::new(),
4843            display_name: None,
4844            category: Some("key".into()),
4845            base_mass: None,
4846            base_volume: None,
4847            capacity_volume: None,
4848            stackable: None,
4849        };
4850        state.inventory_stacks = vec![key.clone()];
4851        assert!(!state.key_drop_blocked(&key));
4852        let opts = state.move_destinations_for(
4853            &flatland_protocol::InventoryLocation::Root,
4854            None,
4855            Some(key_id),
4856            KEY_TEMPLATE,
4857        );
4858        assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
4859    }
4860}