Skip to main content

flatland_client_lib/
game.rs

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