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