Skip to main content

flatland_client_lib/
game.rs

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