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