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