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