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,
7    DoorView, EntityId, EntityState, Intent, LifeState, NpcView, RotationPreset, Seq, SessionId,
8    TerrainKindView, TerrainZoneView, Tick,
9};
10
11use crate::session::{PlayConnection, SessionEvent};
12
13const MAX_LOG_LINES: usize = 200;
14const INTERACTION_RADIUS_M: f32 = 1.5;
15const DOOR_INTERACTION_RADIUS_M: f32 = 3.5;
16const HARVEST_CLIENT_TIMEOUT: Duration = Duration::from_secs(12);
17/// Matches `assets/config/server-settings.yaml` default for batch-cap UI estimates.
18const CRAFT_STAMINA_COST: f32 = 3.0;
19
20/// Catalog hints synced from server `ItemStack` wire rows.
21#[derive(Debug, Clone, Default)]
22pub struct InventoryHint {
23    pub display_name: String,
24    pub category: String,
25    pub base_mass: Option<f32>,
26    pub base_volume: Option<f32>,
27    pub capacity_volume: Option<f32>,
28    pub stackable: bool,
29}
30
31/// Rotation editor overlay mode (`plans/26` §C2.5).
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
33pub enum RotationEditorMode {
34    #[default]
35    List,
36    EditSequence,
37    PickAbility,
38    EditLabel,
39}
40
41/// Local rotation editor UI state (not persisted).
42#[derive(Debug, Clone, Default)]
43pub struct RotationEditorState {
44    pub mode: RotationEditorMode,
45    pub list_index: usize,
46    pub ability_index: usize,
47    pub picker_index: usize,
48    pub draft: Option<RotationPreset>,
49    pub label_buffer: String,
50}
51
52impl RotationEditorState {
53    pub fn reset(&mut self) {
54        *self = Self::default();
55    }
56}
57
58/// Max distance (m) a placed chest can be browsed/moved-into from the inventory
59/// UI. Mirrors `flatland_sim::interaction::CONTAINER_INTERACTION_RADIUS_M` so the
60/// client only ever shows chests the server will actually let you use — this is
61/// what makes a chest disappear from the menu as soon as you walk away.
62pub const CONTAINER_RANGE_M: f32 = 3.0;
63
64/// Broad section of the inventory browser a row belongs to (drives the grouped
65/// "Worn" / "On you" / "Nearby chest" headers in the UI).
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum InventorySection {
68    /// Inside a worn body-slot item (backpack, belt w/ clipped pouches, armor).
69    Worn,
70    /// Loose on your person — not worn, not inside a placed chest.
71    Person,
72    /// Inside a placed chest within reach.
73    Nearby,
74}
75
76/// Short display label for a body slot (`plans/08` §4.1) — shared by the inventory
77/// browser section headers and the move-destination picker.
78pub fn body_slot_label(slot: BodySlot) -> &'static str {
79    match slot {
80        BodySlot::Head => "Head",
81        BodySlot::Body => "Body",
82        BodySlot::Arms => "Arms",
83        BodySlot::Legs => "Legs",
84        BodySlot::Feet => "Feet",
85        BodySlot::Back => "Back",
86        BodySlot::Waist => "Waist",
87    }
88}
89
90/// Client-side naming heuristic for "Enter equips this" — the client doesn't sync the
91/// item catalog's `equip_slot`, so this guesses from `template_id` the same way the
92/// pre-generalization code already guessed backpack vs. pouch. Pouches deliberately
93/// aren't covered — they attach to belt loops via the move picker instead of equipping
94/// directly (`plans/08` §4.1).
95fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
96    if template_id.contains("backpack") {
97        Some(BodySlot::Back)
98    } else if template_id.contains("belt") {
99        Some(BodySlot::Waist)
100    } else if template_id.contains("cap") || template_id.contains("hat") || template_id.contains("helm") {
101        Some(BodySlot::Head)
102    } else if template_id.contains("shirt") || template_id.contains("robe") || template_id.contains("vest") {
103        Some(BodySlot::Body)
104    } else if template_id.contains("sleeves") || template_id.contains("gloves") || template_id.contains("gauntlets") {
105        Some(BodySlot::Arms)
106    } else if template_id.contains("pants") || template_id.contains("leggings") {
107        Some(BodySlot::Legs)
108    } else if template_id.contains("boots") || template_id.contains("shoes") {
109        Some(BodySlot::Feet)
110    } else {
111        None
112    }
113}
114
115/// One row in the inventory browser tree.
116#[derive(Debug, Clone)]
117pub struct InventoryRow {
118    pub depth: usize,
119    pub stack: flatland_protocol::ItemStack,
120    /// `MoveItem` source location for this stack.
121    pub from: flatland_protocol::InventoryLocation,
122    /// Parent container instance when nested (belt shell, backpack, chest, pouch).
123    pub from_parent_instance_id: Option<uuid::Uuid>,
124    /// Equipped bag/chest shell — unequip via Enter instead of the move picker.
125    pub is_equip_shell: bool,
126    /// Placed world chest shell — lock/unlock via Enter or `l`.
127    pub is_chest_shell: bool,
128    pub section: InventorySection,
129}
130
131/// A placed chest within `CONTAINER_RANGE_M`, with its contents pre-flattened for
132/// the browser (empty when locked without the matching key).
133#[derive(Debug, Clone)]
134pub struct NearbyContainer {
135    pub view: flatland_protocol::PlacedContainerView,
136    pub distance_m: f32,
137    pub rows: Vec<InventoryRow>,
138}
139
140/// A destination the currently-picked item could be moved to.
141#[derive(Debug, Clone)]
142pub struct MoveOption {
143    pub label: String,
144    pub kind: MoveOptionKind,
145}
146
147#[derive(Debug, Clone, PartialEq)]
148pub enum MoveOptionKind {
149    Move {
150        location: flatland_protocol::InventoryLocation,
151        parent_instance_id: Option<uuid::Uuid>,
152    },
153    Drop,
154    Cancel,
155}
156
157/// Active "move to…" destination picker state for the selected inventory item.
158#[derive(Debug, Clone)]
159pub struct MovePicker {
160    pub item_instance_id: uuid::Uuid,
161    pub from: flatland_protocol::InventoryLocation,
162    pub item_label: String,
163    pub template_id: String,
164    pub stack_quantity: u32,
165    pub quantity: u32,
166    pub options: Vec<MoveOption>,
167}
168
169fn push_inventory_rows(
170    rows: &mut Vec<InventoryRow>,
171    depth: usize,
172    stack: &flatland_protocol::ItemStack,
173    from: &flatland_protocol::InventoryLocation,
174    from_parent_instance_id: Option<uuid::Uuid>,
175    section: InventorySection,
176) {
177    rows.push(InventoryRow {
178        depth,
179        stack: stack.clone(),
180        from: from.clone(),
181        from_parent_instance_id,
182        is_equip_shell: false,
183        is_chest_shell: false,
184        section,
185    });
186    for child in &stack.contents {
187        push_inventory_rows(
188            rows,
189            depth + 1,
190            child,
191            from,
192            stack.item_instance_id,
193            section,
194        );
195    }
196}
197
198#[derive(Debug, Clone)]
199pub struct GameState {
200    pub session_id: SessionId,
201    pub entity_id: EntityId,
202    /// Logged-in character — used to show owner-only container labels.
203    pub character_id: Option<uuid::Uuid>,
204    pub tick: Tick,
205    pub chunk_rev: u64,
206    pub content_rev: u64,
207    pub entities: Vec<EntityState>,
208    pub player: Option<EntityState>,
209    pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
210    pub ground_drops: Vec<flatland_protocol::GroundDropView>,
211    pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
212    pub buildings: Vec<BuildingView>,
213    pub doors: Vec<DoorView>,
214    pub npcs: Vec<NpcView>,
215    pub blueprints: Vec<BlueprintView>,
216    pub world_width_m: f32,
217    pub world_height_m: f32,
218    pub terrain_zones: Vec<TerrainZoneView>,
219    pub world_clock: flatland_protocol::WorldClock,
220    pub inventory: std::collections::HashMap<String, u32>,
221    pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
222    pub logs: VecDeque<String>,
223    pub intents_sent: u64,
224    pub ticks_received: u64,
225    pub connected: bool,
226    pub disconnect_reason: Option<String>,
227    pub show_stats: bool,
228    pub show_craft_menu: bool,
229    pub craft_menu_index: usize,
230    /// How many timed crafts to queue when confirming the craft menu.
231    pub craft_batch_quantity: u32,
232    pub show_inventory_menu: bool,
233    pub inventory_menu_index: usize,
234    pub show_move_picker: bool,
235    pub move_picker_index: usize,
236    pub move_picker: Option<MovePicker>,
237    /// Rename prompt for a selected container (`n` in inventory).
238    pub show_rename_prompt: bool,
239    pub rename_buffer: String,
240    /// Slot-1 combat target (mirrors server after SetTarget).
241    pub combat_target: Option<EntityId>,
242    pub combat_target_label: Option<String>,
243    pub in_combat: bool,
244    pub auto_attack: bool,
245    pub combat_has_los: bool,
246    pub attack_cd_ticks: u64,
247    pub gcd_ticks: u64,
248    pub weapon_ability_id: String,
249    pub mainhand_template_id: Option<String>,
250    pub mainhand_label: Option<String>,
251    /// Worn body-slot items — backpack (`Back`), belt w/ clipped pouches (`Waist`), and
252    /// future armor. At most one item per slot (`plans/08` §4.1).
253    pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
254    pub carry_mass: f32,
255    pub carry_mass_max: f32,
256    pub encumbrance: flatland_protocol::EncumbranceState,
257    /// Full nested inventory stacks from the server (root only; worn are separate).
258    pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
259    pub combat_target_detail: Option<CombatTargetHud>,
260    pub cast_progress: Option<CastProgressHud>,
261    pub ability_cooldowns: Vec<AbilityCooldownHud>,
262    pub blocking_active: bool,
263    pub max_target_slots: u8,
264    pub combat_slots: Vec<CombatSlotHud>,
265    pub rotation_presets: Vec<RotationPreset>,
266    pub show_loadout_menu: bool,
267    pub show_rotation_editor: bool,
268    pub loadout_menu_index: usize,
269    pub rotation_editor: RotationEditorState,
270    /// True after a harvest intent is accepted until result/reject/disconnect.
271    pub harvest_in_progress: bool,
272    /// Wall-clock start of the current harvest; clears stale client state on timeout.
273    pub harvest_started_at: Option<Instant>,
274    /// Craft log deferred until the server acks the craft intent.
275    pub pending_craft_ack: Option<(u32, String, u32)>,
276}
277
278impl GameState {
279    pub fn push_log(&mut self, line: impl Into<String>) {
280        self.logs.push_back(line.into());
281        while self.logs.len() > MAX_LOG_LINES {
282            self.logs.pop_front();
283        }
284    }
285
286    pub fn is_alive(&self) -> bool {
287        self.player
288            .as_ref()
289            .and_then(|p| p.vitals)
290            .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
291            .unwrap_or(true)
292    }
293
294    pub fn clear_harvest_state(&mut self) {
295        self.harvest_in_progress = false;
296        self.harvest_started_at = None;
297    }
298
299    fn harvest_state_stale(&self) -> bool {
300        match self.harvest_started_at {
301            Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
302            None => self.harvest_in_progress,
303        }
304    }
305
306    pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
307        self.player.as_ref().and_then(|p| p.vitals)
308    }
309
310    pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
311        let materials_ok = blueprint.inputs.iter().all(|input| {
312            self.inventory
313                .get(&input.template_id)
314                .copied()
315                .unwrap_or(0)
316                >= input.quantity
317        });
318        let tools_ok = blueprint.required_tools.iter().all(|tool| {
319            self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1
320        });
321        let station_ok = match blueprint.station.as_deref() {
322            None | Some("hand") => true,
323            Some(tag) => self.player_at_station_tag(tag),
324        };
325        materials_ok && tools_ok && station_ok
326    }
327
328    pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
329        if !self.can_craft_blueprint(blueprint) {
330            return 0;
331        }
332        let mut limit = u32::MAX;
333        for input in &blueprint.inputs {
334            if input.quantity == 0 {
335                continue;
336            }
337            let have = self
338                .inventory
339                .get(&input.template_id)
340                .copied()
341                .unwrap_or(0);
342            limit = limit.min(have / input.quantity);
343        }
344        for tool in &blueprint.required_tools {
345            if tool.consumed {
346                let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
347                limit = limit.min(have);
348            }
349        }
350        let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
351        if CRAFT_STAMINA_COST > 0.0 {
352            limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
353        }
354        limit
355    }
356
357    pub fn clamp_craft_batch_quantity(&mut self) {
358        let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
359            self.craft_batch_quantity = 1;
360            return;
361        };
362        let max = self.max_craft_batches(bp).max(1);
363        self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
364    }
365
366    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
367        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
368            return;
369        };
370        let max = self.max_craft_batches(&bp).max(1);
371        let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
372        self.craft_batch_quantity = next as u32;
373    }
374
375    pub fn craft_batch_set_max(&mut self) {
376        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
377            return;
378        };
379        let max = self.max_craft_batches(&bp);
380        self.craft_batch_quantity = if max == 0 { 1 } else { max };
381    }
382
383    pub fn player_at_station_tag(&self, tag: &str) -> bool {
384        let (px, py) = self.player_position();
385        self.building_interior_at(px, py)
386            .is_some_and(|b| b.tags.iter().any(|t| t == tag))
387    }
388
389    /// Short hint for UI when a recipe cannot be started.
390    pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
391        if self.can_craft_blueprint(blueprint) {
392            return None;
393        }
394        let mut missing = Vec::new();
395        for input in &blueprint.inputs {
396            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
397            if have < input.quantity {
398                missing.push(format!("{}×{} (have {have})", input.quantity, input.template_id));
399            }
400        }
401        for tool in &blueprint.required_tools {
402            let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
403            if have < 1 {
404                missing.push(format!("tool: {}", tool.item));
405            }
406        }
407        if let Some(station) = blueprint.station.as_deref() {
408            if station != "hand" && !self.player_at_station_tag(station) {
409                missing.push(format!("station: {station} (enter building)"));
410            }
411        }
412        if missing.is_empty() {
413            None
414        } else {
415            Some(missing.join(", "))
416        }
417    }
418
419    pub fn player_entity(&self) -> Option<&EntityState> {
420        self.player
421            .as_ref()
422            .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
423    }
424
425    pub fn player_position(&self) -> (f32, f32) {
426        self.player_entity()
427            .map(|p| (p.transform.position.x, p.transform.position.y))
428            .unwrap_or((0.0, 0.0))
429    }
430
431    pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
432        let mut rows: Vec<(String, u32, String)> = self
433            .inventory
434            .iter()
435            .filter(|(_, q)| **q > 0)
436            .map(|(id, qty)| {
437                let label = self
438                    .inventory_hints
439                    .get(id)
440                    .map(|h| h.display_name.clone())
441                    .unwrap_or_else(|| id.clone());
442                (id.clone(), *qty, label)
443            })
444            .collect();
445        rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
446        rows
447    }
448
449    pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
450        self.inventory_hints
451            .get(template_id)
452            .map(|h| h.category.as_str())
453            .filter(|c| !c.is_empty())
454    }
455
456    pub fn item_base_mass(&self, template_id: &str) -> f32 {
457        self.inventory_hints
458            .get(template_id)
459            .and_then(|h| h.base_mass)
460            .unwrap_or(0.5)
461    }
462
463    pub fn item_base_volume(&self, template_id: &str) -> f32 {
464        self.inventory_hints
465            .get(template_id)
466            .and_then(|h| h.base_volume)
467            .unwrap_or(1.0)
468    }
469
470    pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
471        let unit = stack
472            .base_mass
473            .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
474        unit * stack.quantity as f32
475    }
476
477    fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
478        let unit = stack.base_volume.unwrap_or(1.0);
479        unit * stack.quantity as f32
480            + stack
481                .contents
482                .iter()
483                .map(Self::stack_tree_volume)
484                .sum::<f32>()
485    }
486
487    fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
488        contents.iter().map(Self::stack_tree_volume).sum()
489    }
490
491    /// Volume used / capacity / free space label for storage containers in the inventory UI.
492    pub fn container_volume_label(&self, row: &InventoryRow) -> String {
493        let (used, cap) = if row.is_chest_shell {
494            let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
495                return String::new();
496            };
497            let Some(chest) = self
498                .placed_containers
499                .iter()
500                .find(|c| c.id == *container_id)
501            else {
502                return String::new();
503            };
504            let cap = self
505                .inventory_hints
506                .get(&chest.template_id)
507                .and_then(|h| h.capacity_volume)
508                .unwrap_or(0.0);
509            if cap <= 0.0 {
510                return String::new();
511            }
512            (Self::contents_used_volume(&chest.contents), cap)
513        } else if row.stack.capacity_volume.is_some_and(|c| c > 0.0) {
514            let cap = row.stack.capacity_volume.unwrap_or(0.0);
515            (Self::contents_used_volume(&row.stack.contents), cap)
516        } else {
517            return String::new();
518        };
519        if cap <= 0.0 {
520            return String::new();
521        }
522        let free = (cap - used).max(0.0);
523        format!("  vol {used:.0}/{cap:.0} ({free:.0} free)")
524    }
525
526    pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
527        if row.is_chest_shell {
528            return true;
529        }
530        if row.is_equip_shell {
531            return self.inventory_item_category(&row.stack.template_id) == Some("container");
532        }
533        self.inventory_item_category(&row.stack.template_id) == Some("container")
534            || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
535    }
536
537    fn container_stack_for(
538        &self,
539        location: &flatland_protocol::InventoryLocation,
540        parent_instance_id: Option<uuid::Uuid>,
541    ) -> Option<flatland_protocol::ItemStack> {
542        match location {
543            flatland_protocol::InventoryLocation::Root => {
544                let pid = parent_instance_id?;
545                self.find_stack_by_instance(&self.inventory_stacks, pid)
546            }
547            flatland_protocol::InventoryLocation::Worn { slot } => {
548                let worn = self.worn.get(slot)?;
549                if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
550                    Some(worn.clone())
551                } else {
552                    self.find_stack_by_instance(&worn.contents, parent_instance_id?)
553                }
554            }
555            flatland_protocol::InventoryLocation::Placed { container_id } => {
556                let chest = self.placed_containers.iter().find(|c| c.id == *container_id)?;
557                if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
558                    Some(flatland_protocol::ItemStack {
559                        template_id: chest.template_id.clone(),
560                        quantity: 1,
561                        item_instance_id: chest.item_instance_id,
562                        props: Default::default(),
563                        contents: chest.contents.clone(),
564                        display_name: Some(chest.display_name.clone()),
565                        category: Some("container".into()),
566                        base_mass: None,
567                        base_volume: None,
568                        capacity_volume: self
569                            .inventory_hints
570                            .get(&chest.template_id)
571                            .and_then(|h| h.capacity_volume),
572                        stackable: None,
573                    })
574                } else {
575                    self.find_stack_by_instance(&chest.contents, parent_instance_id?)
576                }
577            }
578        }
579    }
580
581    fn find_stack_by_instance(
582        &self,
583        stacks: &[flatland_protocol::ItemStack],
584        instance_id: uuid::Uuid,
585    ) -> Option<flatland_protocol::ItemStack> {
586        for stack in stacks {
587            if stack.item_instance_id == Some(instance_id) {
588                return Some(stack.clone());
589            }
590            if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
591                return Some(found);
592            }
593        }
594        None
595    }
596
597    /// Client-side estimate of how many units can move to `to` (server clamps authoritatively).
598    pub fn max_movable_to(
599        &self,
600        template_id: &str,
601        stack_qty: u32,
602        from: &flatland_protocol::InventoryLocation,
603        to: &flatland_protocol::InventoryLocation,
604        parent_instance_id: Option<uuid::Uuid>,
605    ) -> u32 {
606        let unit_vol = self.item_base_volume(template_id);
607        let unit_mass = self.item_base_mass(template_id);
608        let mut limit = stack_qty;
609
610        if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
611            let cap = parent
612                .capacity_volume
613                .or_else(|| {
614                    self.inventory_hints
615                        .get(&parent.template_id)
616                        .and_then(|h| h.capacity_volume)
617                })
618                .unwrap_or(0.0);
619            if cap > 0.0 && unit_vol > 0.0 {
620                let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
621                limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
622            }
623        }
624
625        let to_person = matches!(
626            to,
627            flatland_protocol::InventoryLocation::Root | flatland_protocol::InventoryLocation::Worn { .. }
628        );
629        let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
630        if to_person && from_placed && unit_mass > 0.0 {
631            let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
632            if self.encumbrance == flatland_protocol::EncumbranceState::Over {
633                limit = 0;
634            } else {
635                limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
636            }
637        }
638
639        limit.max(0).min(stack_qty)
640    }
641
642    pub fn move_picker_max_at_selection(&self) -> u32 {
643        let Some(picker) = &self.move_picker else {
644            return 1;
645        };
646        let Some(opt) = picker.options.get(self.move_picker_index) else {
647            return picker.stack_quantity;
648        };
649        match &opt.kind {
650            MoveOptionKind::Cancel | MoveOptionKind::Drop => picker.stack_quantity,
651            MoveOptionKind::Move {
652                location,
653                parent_instance_id,
654            } => self.max_movable_to(
655                &picker.template_id,
656                picker.stack_quantity,
657                &picker.from,
658                location,
659                *parent_instance_id,
660            ),
661        }
662    }
663
664    pub fn clamp_move_picker_quantity(&mut self) {
665        let max = self.move_picker_max_at_selection();
666        if let Some(picker) = &mut self.move_picker {
667            if max == 0 {
668                picker.quantity = 1;
669            } else {
670                picker.quantity = picker.quantity.clamp(1, max);
671            }
672        }
673    }
674
675    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
676        let max = self.move_picker_max_at_selection().max(1);
677        if let Some(picker) = &mut self.move_picker {
678            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
679            picker.quantity = next as u32;
680        }
681    }
682
683    pub fn move_picker_set_quantity_max(&mut self) {
684        let max = self.move_picker_max_at_selection();
685        if let Some(picker) = &mut self.move_picker {
686            picker.quantity = if max == 0 { 1 } else { max.min(picker.stack_quantity) };
687        }
688    }
689
690    pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
691        let have = self.inventory.get(template_id).copied().unwrap_or(0);
692        (have, have >= need)
693    }
694
695    /// True when standing in a shallow-water terrain zone from the segment snapshot.
696    pub fn in_shallow_water(&self) -> bool {
697        let (px, py) = self.player_position();
698        self.terrain_at(px, py)
699            .is_some_and(|k| k == TerrainKindView::ShallowWater)
700    }
701
702    pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
703        self.terrain_zones.iter().find_map(|z| {
704            if x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1 {
705                Some(z.kind)
706            } else {
707                None
708            }
709        })
710    }
711
712    /// Building interior the player is standing in, if any (position-based).
713    pub fn building_interior_at(&self, px: f32, py: f32) -> Option<&BuildingView> {
714        self.buildings
715            .iter()
716            .find(|b| point_in_building_interior(px, py, b))
717    }
718
719    /// Interior instance map to render (ignores stale `inside_building` when outdoors).
720    pub fn effective_inside_building(&self) -> Option<String> {
721        let (px, py) = self.player_position();
722        if let Some(b) = self.building_interior_at(px, py) {
723            return Some(b.id.clone());
724        }
725        if is_instanced_world_coord(px, py, self.world_width_m, self.world_height_m) {
726            if let Some(id) = self
727                .player_entity()
728                .and_then(|p| p.inside_building.clone())
729            {
730                return Some(id);
731            }
732            if self.buildings.len() == 1 {
733                return Some(self.buildings[0].id.clone());
734            }
735        }
736        None
737    }
738
739    pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
740        self.inventory_stacks = stacks.to_vec();
741        self.inventory.clear();
742        self.inventory_hints.clear();
743        fn walk(
744            stacks: &[flatland_protocol::ItemStack],
745            inventory: &mut std::collections::HashMap<String, u32>,
746            hints: &mut std::collections::HashMap<String, InventoryHint>,
747        ) {
748            for stack in stacks {
749                *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
750                if stack.display_name.is_some()
751                    || stack.category.is_some()
752                    || stack.base_mass.is_some()
753                    || stack.base_volume.is_some()
754                {
755                    hints.insert(
756                        stack.template_id.clone(),
757                        InventoryHint {
758                            display_name: stack
759                                .display_name
760                                .clone()
761                                .unwrap_or_else(|| stack.template_id.clone()),
762                            category: stack.category.clone().unwrap_or_default(),
763                            base_mass: stack.base_mass,
764                            base_volume: stack.base_volume,
765                            capacity_volume: stack.capacity_volume,
766                            stackable: stack.stackable.unwrap_or(true),
767                        },
768                    );
769                }
770                walk(&stack.contents, inventory, hints);
771            }
772        }
773        walk(stacks, &mut self.inventory, &mut self.inventory_hints);
774        // Include worn items (and nested contents, e.g. belt-clipped pouches) in craft counts.
775        for item in self.worn.values() {
776            walk(std::slice::from_ref(item), &mut self.inventory, &mut self.inventory_hints);
777        }
778    }
779
780    /// Worn body-slot items — each shown as a shell row (unequip via Enter) followed by
781    /// its nested contents (e.g. pouches clipped onto a worn belt). `BodySlot` derives
782    /// `Ord` in display order (Head/Body/Arms/Legs/Feet/Back/Waist), so `BTreeMap`
783    /// iteration alone gives a stable row order.
784    pub fn worn_rows(&self) -> Vec<InventoryRow> {
785        let mut rows = Vec::new();
786        for (slot, item) in &self.worn {
787            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
788            rows.push(InventoryRow {
789                depth: 0,
790                stack: item.clone(),
791                from: from.clone(),
792                from_parent_instance_id: None,
793                is_equip_shell: true,
794                is_chest_shell: false,
795                section: InventorySection::Worn,
796            });
797            for child in &item.contents {
798                push_inventory_rows(
799                    &mut rows,
800                    1,
801                    child,
802                    &from,
803                    item.item_instance_id,
804                    InventorySection::Worn,
805                );
806            }
807        }
808        rows
809    }
810
811    /// Loose on-person inventory (not worn, not inside a placed chest).
812    pub fn person_rows(&self) -> Vec<InventoryRow> {
813        let mut rows = Vec::new();
814        for stack in &self.inventory_stacks {
815            push_inventory_rows(
816                &mut rows,
817                0,
818                stack,
819                &flatland_protocol::InventoryLocation::Root,
820                None,
821                InventorySection::Person,
822            );
823        }
824        rows
825    }
826
827    /// Legacy alias used by the HUD sidebar summary (worn + on-person, unchanged).
828    pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
829        let mut rows = self.worn_rows();
830        rows.extend(self.person_rows());
831        rows.into_iter().map(|r| (r.depth, r.stack)).collect()
832    }
833
834    /// Placed chests within `CONTAINER_RANGE_M`, nearest first. Contents are only
835    /// populated when `accessible` — this is what makes a chest's contents
836    /// disappear the moment you walk away or it's locked without your key.
837    pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
838        let (px, py) = self.player_position();
839        let mut list: Vec<NearbyContainer> = self
840            .placed_containers
841            .iter()
842            .filter_map(|c| {
843                let distance_m = (c.x - px).hypot(c.y - py);
844                if distance_m > CONTAINER_RANGE_M {
845                    return None;
846                }
847                let mut rows = Vec::new();
848                let from = flatland_protocol::InventoryLocation::Placed {
849                    container_id: c.id.clone(),
850                };
851                rows.push(InventoryRow {
852                    depth: 0,
853                    stack: flatland_protocol::ItemStack {
854                        template_id: c.template_id.clone(),
855                        quantity: 1,
856                        item_instance_id: c.item_instance_id,
857                        props: Default::default(),
858                        contents: Vec::new(),
859                        display_name: Some(c.display_name.clone()),
860                        category: Some("container".into()),
861                        base_mass: None,
862                        base_volume: None,
863                        capacity_volume: None,
864                        stackable: None,
865                    },
866                    from: from.clone(),
867                    from_parent_instance_id: None,
868                    is_equip_shell: false,
869                    is_chest_shell: true,
870                    section: InventorySection::Nearby,
871                });
872                if c.accessible {
873                    for child in &c.contents {
874                        push_inventory_rows(
875                            &mut rows,
876                            1,
877                            child,
878                            &from,
879                            c.item_instance_id,
880                            InventorySection::Nearby,
881                        );
882                    }
883                }
884                Some(NearbyContainer {
885                    view: c.clone(),
886                    distance_m,
887                    rows,
888                })
889            })
890            .collect();
891        list.sort_by(|a, b| {
892            a.distance_m
893                .partial_cmp(&b.distance_m)
894                .unwrap_or(std::cmp::Ordering::Equal)
895        });
896        list
897    }
898
899    /// Nearest placed chest within `max_dist`, regardless of accessibility.
900    pub fn nearest_placed_container(
901        &self,
902        max_dist: f32,
903    ) -> Option<flatland_protocol::PlacedContainerView> {
904        let (px, py) = self.player_position();
905        self.placed_containers
906            .iter()
907            .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
908            .min_by(|a, b| {
909                let da = (a.x - px).hypot(a.y - py);
910                let db = (b.x - px).hypot(b.y - py);
911                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
912            })
913            .cloned()
914    }
915
916    /// Full ordered list of *selectable* rows: worn ++ on-person ++ each nearby
917    /// accessible chest's contents (nearest chest first). This single order drives
918    /// `inventory_menu_index`; the renderer must build its grouped headers by
919    /// walking `worn_rows()` / `person_rows()` / `nearby_containers()` in the same
920    /// sequence so the highlighted row always matches.
921    pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
922        let mut rows = self.worn_rows();
923        rows.extend(self.person_rows());
924        for nc in self.nearby_containers() {
925            rows.extend(nc.rows);
926        }
927        rows
928    }
929
930    pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
931        self.inventory_selectable_rows()
932            .into_iter()
933            .nth(self.inventory_menu_index)
934    }
935
936    /// Build the "move to…" destination list for an item currently at `from`.
937    pub fn move_destinations_for(
938        &self,
939        from: &flatland_protocol::InventoryLocation,
940        from_parent_instance_id: Option<uuid::Uuid>,
941        moving_instance_id: Option<uuid::Uuid>,
942        moving_template_id: &str,
943    ) -> Vec<MoveOption> {
944        let mut opts = Vec::new();
945        if *from != flatland_protocol::InventoryLocation::Root {
946            opts.push(MoveOption {
947                label: "On your person (loose)".into(),
948                kind: MoveOptionKind::Move {
949                    location: flatland_protocol::InventoryLocation::Root,
950                    parent_instance_id: None,
951                },
952            });
953        }
954        for (slot, item) in &self.worn {
955            if item.category.as_deref() != Some("container") {
956                continue;
957            }
958            let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
959            let shell_name = item
960                .display_name
961                .clone()
962                .unwrap_or_else(|| item.template_id.clone());
963
964            // Backpack and other worn volume containers — store directly inside the shell.
965            if *slot != BodySlot::Waist
966                && item.item_instance_id != moving_instance_id
967                && Self::is_volume_container_stack(item)
968            {
969                Self::push_move_destination(
970                    &mut opts,
971                    format!("{shell_name} (worn {})", body_slot_label(*slot)),
972                    location.clone(),
973                    item.item_instance_id,
974                    from,
975                    from_parent_instance_id,
976                );
977            }
978
979            // Belt loops only accept pouch attachments — not loose materials.
980            if *slot == BodySlot::Waist
981                && Self::attaches_to_belt_loop(moving_template_id)
982                && item.item_instance_id != moving_instance_id
983            {
984                Self::push_move_destination(
985                    &mut opts,
986                    format!("{shell_name} (belt loop)"),
987                    location.clone(),
988                    item.item_instance_id,
989                    from,
990                    from_parent_instance_id,
991                );
992            }
993
994            let context = if *slot == BodySlot::Waist {
995                format!("on {shell_name}")
996            } else {
997                format!("in {shell_name}")
998            };
999            Self::append_nested_container_destinations(
1000                &mut opts,
1001                location,
1002                item,
1003                &context,
1004                from,
1005                from_parent_instance_id,
1006                moving_instance_id,
1007            );
1008        }
1009        for nc in self.nearby_containers() {
1010            if !nc.view.accessible {
1011                continue;
1012            }
1013            let location = flatland_protocol::InventoryLocation::Placed {
1014                container_id: nc.view.id.clone(),
1015            };
1016            Self::push_move_destination(
1017                &mut opts,
1018                format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
1019                location,
1020                nc.view.item_instance_id,
1021                from,
1022                from_parent_instance_id,
1023            );
1024        }
1025        opts.push(MoveOption {
1026            label: "Drop on the ground".into(),
1027            kind: MoveOptionKind::Drop,
1028        });
1029        opts.push(MoveOption {
1030            label: "Cancel".into(),
1031            kind: MoveOptionKind::Cancel,
1032        });
1033        opts
1034    }
1035
1036    fn is_same_container_dest(
1037        dest_location: &flatland_protocol::InventoryLocation,
1038        dest_parent: Option<uuid::Uuid>,
1039        from: &flatland_protocol::InventoryLocation,
1040        from_parent: Option<uuid::Uuid>,
1041    ) -> bool {
1042        dest_location == from && dest_parent == from_parent
1043    }
1044
1045    fn push_move_destination(
1046        opts: &mut Vec<MoveOption>,
1047        label: String,
1048        location: flatland_protocol::InventoryLocation,
1049        parent_instance_id: Option<uuid::Uuid>,
1050        from: &flatland_protocol::InventoryLocation,
1051        from_parent_instance_id: Option<uuid::Uuid>,
1052    ) {
1053        if Self::is_same_container_dest(
1054            &location,
1055            parent_instance_id,
1056            from,
1057            from_parent_instance_id,
1058        ) {
1059            return;
1060        }
1061        opts.push(MoveOption {
1062            label,
1063            kind: MoveOptionKind::Move {
1064                location,
1065                parent_instance_id,
1066            },
1067        });
1068    }
1069
1070    fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
1071        stack.capacity_volume.is_some_and(|c| c > 0.0)
1072    }
1073
1074    fn attaches_to_belt_loop(template_id: &str) -> bool {
1075        matches!(template_id, "leather_pouch" | "dimensional_pouch")
1076    }
1077
1078    fn append_nested_container_destinations(
1079        opts: &mut Vec<MoveOption>,
1080        location: flatland_protocol::InventoryLocation,
1081        container: &flatland_protocol::ItemStack,
1082        context: &str,
1083        from: &flatland_protocol::InventoryLocation,
1084        from_parent_instance_id: Option<uuid::Uuid>,
1085        moving_instance_id: Option<uuid::Uuid>,
1086    ) {
1087        for child in &container.contents {
1088            if Self::is_volume_container_stack(child)
1089                && child.item_instance_id != moving_instance_id
1090            {
1091                let name = child
1092                    .display_name
1093                    .clone()
1094                    .unwrap_or_else(|| child.template_id.clone());
1095                Self::push_move_destination(
1096                    opts,
1097                    format!("{name} ({context})"),
1098                    location.clone(),
1099                    child.item_instance_id,
1100                    from,
1101                    from_parent_instance_id,
1102                );
1103            }
1104            let nested_context = format!(
1105                "in {}",
1106                child
1107                    .display_name
1108                    .as_deref()
1109                    .unwrap_or(&child.template_id)
1110            );
1111            Self::append_nested_container_destinations(
1112                opts,
1113                location.clone(),
1114                child,
1115                &nested_context,
1116                from,
1117                from_parent_instance_id,
1118                moving_instance_id,
1119            );
1120        }
1121    }
1122
1123    fn clamp_inventory_indices(&mut self) {
1124        let n = self.inventory_selectable_rows().len();
1125        self.inventory_menu_index = if n == 0 {
1126            0
1127        } else {
1128            self.inventory_menu_index.min(n - 1)
1129        };
1130        if let Some(picker) = &self.move_picker {
1131            let pn = picker.options.len();
1132            self.move_picker_index = if pn == 0 {
1133                0
1134            } else {
1135                self.move_picker_index.min(pn - 1)
1136            };
1137        }
1138    }
1139
1140    fn apply_snapshot_fields(&mut self, snapshot: &flatland_protocol::Snapshot, entity_id: EntityId) {
1141        self.tick = snapshot.tick;
1142        self.chunk_rev = snapshot.chunk_rev;
1143        self.content_rev = snapshot.content_rev;
1144        self.resource_nodes = snapshot.resource_nodes.clone();
1145        self.ground_drops = snapshot.ground_drops.clone();
1146        self.placed_containers = snapshot.placed_containers.clone();
1147        self.world_width_m = snapshot.world_width_m;
1148        self.world_height_m = snapshot.world_height_m;
1149        self.world_clock = snapshot.world_clock;
1150        self.terrain_zones = snapshot.terrain_zones.clone();
1151        self.buildings = snapshot.buildings.clone();
1152        self.doors = snapshot.doors.clone();
1153        self.npcs = snapshot.npcs.clone();
1154        self.blueprints = snapshot.blueprints.clone();
1155        self.sync_inventory_from_stacks(&snapshot.inventory);
1156        self.player = snapshot
1157            .entities
1158            .iter()
1159            .find(|e| e.id == entity_id)
1160            .cloned();
1161        self.entities = snapshot.entities.clone();
1162    }
1163
1164    /// Re-derive inventory selection state after a snapshot/tick — chests that
1165    /// went out of range or got locked simply vanish from the row list, and the
1166    /// move picker (if any) closes once its item is no longer reachable.
1167    fn refresh_inventory_ui(&mut self) {
1168        if let Some(picker) = &self.move_picker {
1169            let instance_id = picker.item_instance_id;
1170            let still_exists = self
1171                .inventory_selectable_rows()
1172                .iter()
1173                .any(|r| r.stack.item_instance_id == Some(instance_id));
1174            if !still_exists {
1175                self.move_picker = None;
1176                self.show_move_picker = false;
1177            }
1178        }
1179        self.clamp_inventory_indices();
1180    }
1181
1182    fn apply_combat_hud(&mut self, combat: &CombatHud) {
1183        self.in_combat = combat.in_combat;
1184        self.auto_attack = combat.auto_attack;
1185        self.combat_has_los = combat.has_los;
1186        self.attack_cd_ticks = combat.attack_cd_ticks;
1187        self.gcd_ticks = combat.gcd_ticks;
1188        self.weapon_ability_id = combat.ability_id.clone();
1189        self.mainhand_template_id = combat.mainhand_template_id.clone();
1190        self.mainhand_label = combat.mainhand_label.clone();
1191        self.worn = combat.worn.iter().cloned().collect();
1192        self.carry_mass = combat.carry_mass;
1193        self.carry_mass_max = combat.carry_mass_max;
1194        self.encumbrance = combat.encumbrance;
1195        self.cast_progress = combat.cast.clone();
1196        self.ability_cooldowns = combat.ability_cooldowns.clone();
1197        self.blocking_active = combat.blocking_active;
1198        self.max_target_slots = combat.max_target_slots.max(1);
1199        self.combat_slots = combat.slots.clone();
1200        self.rotation_presets = combat.rotation_presets.clone();
1201        self.combat_target_detail = combat.target.clone();
1202        self.combat_target = combat.target_entity_id;
1203        if let Some(label) = &combat.target_label {
1204            self.combat_target_label = Some(label.clone());
1205        } else if let Some(id) = combat.target_entity_id {
1206            self.combat_target_label = self
1207                .entities
1208                .iter()
1209                .find(|e| e.id == id)
1210                .map(|e| e.label.clone())
1211                .or_else(|| self.combat_target_label.clone());
1212        }
1213        self.refresh_inventory_ui();
1214    }
1215
1216    /// Entity id assigned to a combat slot (from server HUD).
1217    pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
1218        self.combat_slots
1219            .iter()
1220            .find(|s| s.slot_index == slot)
1221            .and_then(|s| s.target_entity_id)
1222            .or_else(|| {
1223                if slot == 1 {
1224                    self.combat_target
1225                } else {
1226                    None
1227                }
1228            })
1229    }
1230
1231    /// Hostile wildlife / monsters for T1 (`Tab`).
1232    pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
1233        self.combat_candidates()
1234    }
1235
1236    /// Allies first, then monsters, for T2 (`Shift+Tab`). Includes self for heals.
1237    pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
1238        let (px, py) = self.player_position();
1239        let dist = |id: EntityId| {
1240            self.entities
1241                .iter()
1242                .find(|e| e.id == id)
1243                .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
1244                .unwrap_or(f32::MAX)
1245        };
1246
1247        let mut allies = Vec::new();
1248        // Self first — heal_touch on T2.
1249        if let Some(me) = self.player.as_ref() {
1250            let alive = me
1251                .vitals
1252                .as_ref()
1253                .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1254                .unwrap_or(true);
1255            if alive {
1256                allies.push((self.entity_id, "Yourself".into()));
1257            }
1258        }
1259        for entity in &self.entities {
1260            if entity.id == self.entity_id {
1261                continue;
1262            }
1263            if entity.vitals.is_some() {
1264                let alive = entity
1265                    .vitals
1266                    .as_ref()
1267                    .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1268                    .unwrap_or(true);
1269                if alive {
1270                    allies.push((entity.id, entity.label.clone()));
1271                }
1272            }
1273        }
1274        allies.sort_by(|(a, _), (b, _)| {
1275            if *a == self.entity_id {
1276                return std::cmp::Ordering::Less;
1277            }
1278            if *b == self.entity_id {
1279                return std::cmp::Ordering::Greater;
1280            }
1281            dist(*a)
1282                .partial_cmp(&dist(*b))
1283                .unwrap_or(std::cmp::Ordering::Equal)
1284        });
1285
1286        let mut monsters = self.combat_candidates();
1287        monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
1288        allies.into_iter().chain(monsters).collect()
1289    }
1290
1291    fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
1292        match slot_index {
1293            2 => self.t2_candidates(),
1294            _ => self.t1_candidates(),
1295        }
1296    }
1297
1298    /// Full client reset after Welcome (initial connect or reconnect).
1299    pub(crate) fn restore_from_welcome(
1300        &mut self,
1301        session_id: SessionId,
1302        entity_id: EntityId,
1303        snapshot: &flatland_protocol::Snapshot,
1304    ) {
1305        self.clear_harvest_state();
1306        self.disconnect_reason = None;
1307        self.show_stats = false;
1308        self.show_craft_menu = false;
1309        self.show_inventory_menu = false;
1310        self.session_id = session_id;
1311        self.entity_id = entity_id;
1312        self.connected = true;
1313        self.apply_snapshot_fields(snapshot, entity_id);
1314        if let Some(combat) = &snapshot.combat {
1315            self.apply_combat_hud(combat);
1316            let stacks = self.inventory_stacks.clone();
1317            self.sync_inventory_from_stacks(&stacks);
1318        }
1319    }
1320
1321    fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
1322        self.tick = delta.tick;
1323        self.world_clock = delta.world_clock;
1324
1325        // Degenerate AOI tick (observer missing server-side): keep welcome snapshot layers.
1326        if delta.entities.is_empty() {
1327            if let Some(combat) = &delta.combat {
1328                self.apply_combat_hud(combat);
1329                let stacks = self.inventory_stacks.clone();
1330                self.sync_inventory_from_stacks(&stacks);
1331            }
1332            return;
1333        }
1334        if !delta.buildings.is_empty() {
1335            self.buildings = delta.buildings.clone();
1336        }
1337        if !delta.blueprints.is_empty() {
1338            self.blueprints = delta.blueprints.clone();
1339        }
1340        self.sync_inventory_from_stacks(&delta.inventory);
1341
1342        if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
1343            self.player = Some(updated.clone());
1344        }
1345        self.entities = delta.entities.clone();
1346        if self.player.is_none() {
1347            self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
1348        }
1349
1350        if let Some(player) = self.player.as_ref() {
1351            let (px, py) = (player.transform.position.x, player.transform.position.y);
1352            let stale_inside = player.inside_building.is_some()
1353                && self.building_interior_at(px, py).is_none()
1354                && !is_instanced_world_coord(px, py, self.world_width_m, self.world_height_m);
1355            if stale_inside {
1356                self.player.as_mut().unwrap().inside_building = None;
1357            }
1358        }
1359
1360        // Static world layers: ticks often omit these (indoors, or unchanged).
1361        // Never wipe the welcome snapshot with an empty vec.
1362        if !delta.resource_nodes.is_empty() {
1363            self.resource_nodes = delta.resource_nodes.clone();
1364        }
1365        if self.player.as_ref().is_none_or(|p| p.inside_building.is_none()) {
1366            self.ground_drops = delta.ground_drops.clone();
1367            self.placed_containers = delta.placed_containers.clone();
1368        }
1369        if !delta.doors.is_empty() {
1370            self.doors = delta.doors.clone();
1371        }
1372        if !delta.npcs.is_empty() {
1373            self.npcs = delta.npcs.clone();
1374        }
1375        if let Some(combat) = &delta.combat {
1376            self.apply_combat_hud(combat);
1377            let stacks = self.inventory_stacks.clone();
1378            self.sync_inventory_from_stacks(&stacks);
1379        } else {
1380            self.refresh_inventory_ui();
1381        }
1382    }
1383
1384    /// Wildlife and other combat targets visible in AOI (`NpcView.entity_id`).
1385    pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
1386        let (px, py) = self.player_position();
1387        let mut out = Vec::new();
1388        for npc in &self.npcs {
1389            let Some(eid) = npc.entity_id else {
1390                continue;
1391            };
1392            let alive = npc
1393                .life_state
1394                .is_none_or(|s| s == LifeState::Alive);
1395            let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
1396            if alive && has_hp {
1397                out.push((eid, npc.label.clone()));
1398            }
1399        }
1400        out.sort_by(|(a_id, a_label), (b_id, b_label)| {
1401            let dist = |id: EntityId| {
1402                self.entities
1403                    .iter()
1404                    .find(|e| e.id == id)
1405                    .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
1406                    .unwrap_or(f32::MAX)
1407            };
1408            dist(*a_id)
1409                .partial_cmp(&dist(*b_id))
1410                .unwrap_or(std::cmp::Ordering::Equal)
1411                .then_with(|| a_label.cmp(b_label))
1412                .then_with(|| a_id.cmp(b_id))
1413        });
1414        out
1415    }
1416
1417    pub fn refresh_combat_target_label(&mut self) {
1418        let Some(id) = self.combat_target else {
1419            return;
1420        };
1421        if let Some((_, label)) = self
1422            .combat_candidates()
1423            .into_iter()
1424            .find(|(eid, _)| *eid == id)
1425        {
1426            self.combat_target_label = Some(label);
1427        } else if let Some(label) = self.entities.iter().find(|e| e.id == id).map(|e| e.label.clone())
1428        {
1429            self.combat_target_label = Some(label);
1430        }
1431    }
1432
1433    /// Nearest interactable target for `f` (doors, NPCs, well, shallow water).
1434    pub fn nearest_interact_target(&self) -> Option<String> {
1435        let (px, py) = self.player_position();
1436        let inside = self.effective_inside_building();
1437
1438        #[derive(Clone, Copy, PartialEq, Eq)]
1439        enum Kind {
1440            Npc,
1441            ExitDoor,
1442            EnterDoor,
1443            Well,
1444            Water,
1445        }
1446
1447        fn kind_priority(kind: Kind) -> u8 {
1448            match kind {
1449                Kind::Npc => 0,
1450                Kind::ExitDoor => 1,
1451                Kind::EnterDoor => 2,
1452                Kind::Well => 3,
1453                Kind::Water => 4,
1454            }
1455        }
1456
1457        let mut best: Option<(f32, Kind, String)> = None;
1458
1459        let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
1460            if dist > max {
1461                return;
1462            }
1463            let replace = match best {
1464                None => true,
1465                Some((bd, _bk, _)) if dist < bd - 0.05 => true,
1466                Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
1467                    kind_priority(kind) < kind_priority(bk)
1468                }
1469                _ => false,
1470            };
1471            if replace {
1472                best = Some((dist, kind, id));
1473            }
1474        };
1475
1476        for npc in &self.npcs {
1477            consider(
1478                distance(px, py, npc.x, npc.y),
1479                INTERACTION_RADIUS_M,
1480                Kind::Npc,
1481                npc.id.clone(),
1482            );
1483        }
1484
1485        for door in &self.doors {
1486            if inside.is_none() && door.is_exit {
1487                continue;
1488            }
1489            if let Some(ref bid) = inside {
1490                if door.building_id != *bid {
1491                    continue;
1492                }
1493            }
1494            let max = if inside.is_some() && door.is_exit {
1495                INTERACTION_RADIUS_M
1496            } else {
1497                DOOR_INTERACTION_RADIUS_M
1498            };
1499            let kind = if door.is_exit {
1500                Kind::ExitDoor
1501            } else {
1502                Kind::EnterDoor
1503            };
1504            consider(
1505                distance(px, py, door.x, door.y),
1506                max,
1507                kind,
1508                door.id.clone(),
1509            );
1510        }
1511
1512        if inside.is_none() {
1513            for building in &self.buildings {
1514                if !building.tags.iter().any(|t| t == "well") {
1515                    continue;
1516                }
1517                consider(
1518                    distance(px, py, building.x, building.y),
1519                    INTERACTION_RADIUS_M,
1520                    Kind::Well,
1521                    building.id.clone(),
1522                );
1523            }
1524            if self.in_shallow_water() {
1525                consider(0.0, INTERACTION_RADIUS_M, Kind::Water, "water_source".into());
1526            }
1527        }
1528
1529        best.map(|(_, _, id)| id)
1530    }
1531
1532    /// Human-readable template name from synced catalog hints.
1533    pub fn template_display_name(&self, template_id: &str) -> String {
1534        self.inventory_hints
1535            .get(template_id)
1536            .map(|h| h.display_name.clone())
1537            .filter(|n| !n.is_empty())
1538            .unwrap_or_else(|| humanize_template_id(template_id))
1539    }
1540
1541    /// Chest label for the location panel — generic type unless this player owns it.
1542    pub fn placed_container_public_label(
1543        &self,
1544        c: &flatland_protocol::PlacedContainerView,
1545    ) -> String {
1546        let is_owner = match (self.character_id, c.owner_character_id) {
1547            (Some(me), Some(owner)) => me == owner,
1548            _ => false,
1549        };
1550        if is_owner {
1551            c.display_name.clone()
1552        } else {
1553            self.template_display_name(&c.template_id)
1554        }
1555    }
1556
1557    /// Terrain, interactables, and map objects near the player for the HUD location panel.
1558    pub fn location_context_lines(&self) -> Vec<ContextLine> {
1559        let (px, py) = self.player_position();
1560        let mut lines = Vec::new();
1561
1562        if let Some(kind) = self.terrain_at(px, py) {
1563            lines.push(ContextLine {
1564                on_top: true,
1565                text: format!("Terrain: {}", terrain_kind_label(kind)),
1566            });
1567        }
1568
1569        if let Some(building) = self.building_interior_at(px, py) {
1570            lines.push(ContextLine {
1571                on_top: true,
1572                text: format!("Inside: {}", building.label),
1573            });
1574        } else if let Some(id) = self.effective_inside_building() {
1575            if let Some(b) = self.buildings.iter().find(|b| b.id == id) {
1576                lines.push(ContextLine {
1577                    on_top: false,
1578                    text: format!("Near: {} (exterior)", b.label),
1579                });
1580            }
1581        }
1582
1583        let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
1584
1585        for node in &self.resource_nodes {
1586            let dist = distance(px, py, node.x, node.y);
1587            if dist > NEARBY_SCAN_M {
1588                continue;
1589            }
1590            let on_top = dist <= ON_TOP_RADIUS_M;
1591            let state = match node.state {
1592                flatland_protocol::ResourceNodeState::Available => " — press , to harvest",
1593                flatland_protocol::ResourceNodeState::Harvesting => " (being harvested)",
1594                flatland_protocol::ResourceNodeState::Cooldown => " (depleted)",
1595            };
1596            let prefix = if on_top { "On" } else { "Near" };
1597            nearby.push((
1598                dist,
1599                ContextLine {
1600                    on_top,
1601                    text: format!(
1602                        "{prefix}: {} ({dist:.1}m){state}",
1603                        node.label
1604                    ),
1605                },
1606            ));
1607        }
1608
1609        for drop in &self.ground_drops {
1610            let dist = distance(px, py, drop.x, drop.y);
1611            if dist > INTERACTION_RADIUS_M {
1612                continue;
1613            }
1614            let on_top = dist <= ON_TOP_RADIUS_M;
1615            let name = self.template_display_name(&drop.template_id);
1616            let prefix = if on_top { "On" } else { "Near" };
1617            let qty = if drop.quantity > 1 {
1618                format!(" ×{}", drop.quantity)
1619            } else {
1620                String::new()
1621            };
1622            nearby.push((
1623                dist,
1624                ContextLine {
1625                    on_top,
1626                    text: format!(
1627                        "{prefix}: {name}{qty} ({dist:.1}m) — p pickup"
1628                    ),
1629                },
1630            ));
1631        }
1632
1633        for c in &self.placed_containers {
1634            let dist = distance(px, py, c.x, c.y);
1635            if dist > CONTAINER_RANGE_M {
1636                continue;
1637            }
1638            let on_top = dist <= ON_TOP_RADIUS_M;
1639            let name = self.placed_container_public_label(c);
1640            let lock = if c.locked { " [locked]" } else { "" };
1641            let prefix = if on_top { "On" } else { "Near" };
1642            nearby.push((
1643                dist,
1644                ContextLine {
1645                    on_top,
1646                    text: format!("{prefix}: {name}{lock} ({dist:.1}m)"),
1647                },
1648            ));
1649        }
1650
1651        for npc in &self.npcs {
1652            let dist = distance(px, py, npc.x, npc.y);
1653            if dist > NEARBY_SCAN_M {
1654                continue;
1655            }
1656            let on_top = dist <= ON_TOP_RADIUS_M;
1657            let prefix = if on_top { "On" } else { "Near" };
1658            nearby.push((
1659                dist,
1660                ContextLine {
1661                    on_top,
1662                    text: format!(
1663                        "{prefix}: {} ({dist:.1}m) — f talk",
1664                        npc.label
1665                    ),
1666                },
1667            ));
1668        }
1669
1670        for door in &self.doors {
1671            let dist = distance(px, py, door.x, door.y);
1672            if dist > DOOR_INTERACTION_RADIUS_M {
1673                continue;
1674            }
1675            let building = self
1676                .buildings
1677                .iter()
1678                .find(|b| b.id == door.building_id)
1679                .map(|b| b.label.as_str())
1680                .unwrap_or(door.building_id.as_str());
1681            let action = if door.is_exit { "exit" } else { "enter" };
1682            nearby.push((
1683                dist,
1684                ContextLine {
1685                    on_top: dist <= ON_TOP_RADIUS_M,
1686                    text: format!("{building} door ({dist:.1}m) — f {action}"),
1687                },
1688            ));
1689        }
1690
1691        if self.in_shallow_water() {
1692            let already = self
1693                .terrain_at(px, py)
1694                .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
1695            if !already {
1696                nearby.push((
1697                    0.0,
1698                    ContextLine {
1699                        on_top: true,
1700                        text: "Shallow water — f fill bottle".into(),
1701                    },
1702                ));
1703            } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
1704                line.text.push_str(" — f fill bottle");
1705            }
1706        }
1707
1708        for entity in &self.entities {
1709            if entity.id == self.entity_id {
1710                continue;
1711            }
1712            let dist = distance(px, py, entity.transform.position.x, entity.transform.position.y);
1713            if dist > NEARBY_SCAN_M {
1714                continue;
1715            }
1716            let label = if entity.label.is_empty() {
1717                format!("entity {}", entity.id)
1718            } else {
1719                entity.label.clone()
1720            };
1721            nearby.push((
1722                dist,
1723                ContextLine {
1724                    on_top: dist <= ON_TOP_RADIUS_M,
1725                    text: format!("Near: {label} ({dist:.1}m)"),
1726                },
1727            ));
1728        }
1729
1730        nearby.sort_by(|a, b| {
1731            a.0.partial_cmp(&b.0)
1732                .unwrap_or(std::cmp::Ordering::Equal)
1733                .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
1734        });
1735        lines.extend(nearby.into_iter().map(|(_, l)| l));
1736
1737        if lines.is_empty() {
1738            lines.push(ContextLine {
1739                on_top: false,
1740                text: "(nothing notable nearby)".into(),
1741            });
1742        }
1743
1744        lines
1745    }
1746}
1747
1748/// HUD line for the location / nearby panel.
1749#[derive(Debug, Clone)]
1750pub struct ContextLine {
1751    pub on_top: bool,
1752    pub text: String,
1753}
1754
1755const ON_TOP_RADIUS_M: f32 = 0.65;
1756const NEARBY_SCAN_M: f32 = 5.0;
1757
1758fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
1759    use flatland_protocol::TerrainKindView;
1760    match kind {
1761        TerrainKindView::Grass => "Grass",
1762        TerrainKindView::Hill => "Hills",
1763        TerrainKindView::Bog => "Bog",
1764        TerrainKindView::ShallowWater => "Shallow water",
1765        TerrainKindView::DeepWater => "Deep water",
1766    }
1767}
1768
1769fn humanize_template_id(template_id: &str) -> String {
1770    template_id
1771        .split('_')
1772        .map(|word| {
1773            let mut chars = word.chars();
1774            match chars.next() {
1775                None => String::new(),
1776                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
1777            }
1778        })
1779        .collect::<Vec<_>>()
1780        .join(" ")
1781}
1782
1783fn point_in_building_interior(px: f32, py: f32, building: &BuildingView) -> bool {
1784    px >= building.interior_origin_x
1785        && px <= building.interior_origin_x + building.width_m
1786        && py >= building.interior_origin_y
1787        && py <= building.interior_origin_y + building.depth_m
1788}
1789
1790/// Coordinates in a separate interior instance (outside the outdoor segment bounds).
1791fn is_instanced_world_coord(px: f32, py: f32, world_w: f32, world_h: f32) -> bool {
1792    if world_w > 0.0 && world_h > 0.0 {
1793        px < 0.0 || py < 0.0 || px > world_w || py > world_h
1794    } else {
1795        px > 256.0 || py > 256.0
1796    }
1797}
1798
1799/// Keep in sync with `flatland_sim::INTERACTION_RADIUS_M`.
1800const HARVEST_RANGE_M: f32 = 1.5;
1801
1802pub struct GameClient<S: PlayConnection> {
1803    session: S,
1804    seq: Seq,
1805    pub state: GameState,
1806    last_move_forward: f32,
1807    last_move_strafe: f32,
1808}
1809
1810impl<S: PlayConnection> GameClient<S> {
1811    pub fn new(session: S) -> Self {
1812        let session_id = session.session_id();
1813        let entity_id = session.entity_id();
1814        Self {
1815            session,
1816            seq: 0,
1817            last_move_forward: 0.0,
1818            last_move_strafe: 0.0,
1819            state: GameState {
1820                session_id,
1821                entity_id,
1822                character_id: None,
1823                tick: 0,
1824                chunk_rev: 0,
1825                content_rev: 0,
1826                entities: Vec::new(),
1827                player: None,
1828                resource_nodes: Vec::new(),
1829                ground_drops: Vec::new(),
1830            placed_containers: Vec::new(),
1831                buildings: Vec::new(),
1832                doors: Vec::new(),
1833                npcs: Vec::new(),
1834                blueprints: Vec::new(),
1835                world_width_m: 0.0,
1836                world_height_m: 0.0,
1837                terrain_zones: Vec::new(),
1838                world_clock: flatland_protocol::WorldClock::default(),
1839                inventory: std::collections::HashMap::new(),
1840                inventory_hints: std::collections::HashMap::new(),
1841                logs: VecDeque::new(),
1842                intents_sent: 0,
1843                ticks_received: 0,
1844                connected: false,
1845                disconnect_reason: None,
1846                show_stats: false,
1847                show_craft_menu: false,
1848                craft_menu_index: 0,
1849                craft_batch_quantity: 1,
1850                show_inventory_menu: false,
1851                inventory_menu_index: 0,
1852                show_move_picker: false,
1853                show_rename_prompt: false,
1854                rename_buffer: String::new(),
1855                move_picker_index: 0,
1856                move_picker: None,
1857                combat_target: None,
1858                combat_target_label: None,
1859                in_combat: false,
1860                auto_attack: true,
1861                combat_has_los: false,
1862                attack_cd_ticks: 0,
1863                gcd_ticks: 0,
1864                weapon_ability_id: "unarmed".into(),
1865                mainhand_template_id: None,
1866                mainhand_label: None,
1867                worn: BTreeMap::new(),
1868                carry_mass: 0.0,
1869                carry_mass_max: 0.0,
1870                encumbrance: flatland_protocol::EncumbranceState::Light,
1871                inventory_stacks: Vec::new(),
1872                combat_target_detail: None,
1873                cast_progress: None,
1874                ability_cooldowns: Vec::new(),
1875                blocking_active: false,
1876                max_target_slots: 1,
1877                combat_slots: Vec::new(),
1878                rotation_presets: Vec::new(),
1879                show_loadout_menu: false,
1880                show_rotation_editor: false,
1881                loadout_menu_index: 0,
1882                rotation_editor: RotationEditorState::default(),
1883                harvest_in_progress: false,
1884                harvest_started_at: None,
1885                pending_craft_ack: None,
1886            },
1887        }
1888    }
1889
1890    pub fn entity_id(&self) -> EntityId {
1891        self.state.entity_id
1892    }
1893
1894    pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
1895        if self.state.connected {
1896            return Ok(());
1897        }
1898
1899        loop {
1900            match self.session.next_event().await {
1901                Some(SessionEvent::Welcome {
1902                    session_id,
1903                    entity_id,
1904                    snapshot,
1905                }) => {
1906                    self.state.restore_from_welcome(session_id, entity_id, &snapshot);
1907                    self.state.push_log(format!(
1908                        "Connected — session {session_id}, entity {entity_id}"
1909                    ));
1910                    return Ok(());
1911                }
1912                Some(SessionEvent::Disconnected { .. }) => {
1913                    anyhow::bail!("disconnected before welcome");
1914                }
1915                Some(_) => continue,
1916                None => anyhow::bail!("session closed before welcome"),
1917            }
1918        }
1919    }
1920
1921    /// Drain all pending server events (non-blocking).
1922    pub fn drain_events(&mut self) {
1923        while let Some(event) = self.session.try_next_event() {
1924            if self.handle_event_sync(event).is_err() {
1925                break;
1926            }
1927        }
1928    }
1929
1930    /// Wait for the next server event.
1931    pub async fn next_event(&mut self) -> Option<SessionEvent> {
1932        self.session.next_event().await
1933    }
1934
1935    pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
1936        self.handle_event_sync(event)
1937    }
1938
1939    fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
1940        match event {
1941            SessionEvent::Welcome {
1942                session_id,
1943                entity_id,
1944                snapshot,
1945            } => {
1946                let resumed = self.state.connected;
1947                self.state
1948                    .restore_from_welcome(session_id, entity_id, &snapshot);
1949                if resumed {
1950                    self.state.push_log(format!(
1951                        "Session restored — session {session_id}, entity {entity_id}"
1952                    ));
1953                }
1954            }
1955            SessionEvent::ContentUpdated { snapshot } => {
1956                self.state.apply_snapshot_fields(&snapshot, self.state.entity_id);
1957                self.state.push_log(format!(
1958                    "World updated (content rev {})",
1959                    snapshot.content_rev
1960                ));
1961            }
1962            SessionEvent::Tick(delta) => {
1963                self.state.apply_tick_fields(&delta, self.state.entity_id);
1964                self.state.ticks_received += 1;
1965            }
1966            SessionEvent::IntentAck {
1967                entity_id,
1968                seq,
1969                tick,
1970            } => {
1971                crate::harvest_trace!(
1972                    entity_id,
1973                    seq,
1974                    tick,
1975                    "client received intent ack"
1976                );
1977                if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
1978                    if *craft_seq == seq {
1979                        let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
1980                        if batches > 1 {
1981                            self.state
1982                                .push_log(format!("Crafting {label} ×{batches}…"));
1983                        } else {
1984                            self.state.push_log(format!("Crafting {label}…"));
1985                        }
1986                    }
1987                }
1988            }
1989            SessionEvent::Chat(msg) => {
1990                let label = match msg.channel {
1991                    flatland_protocol::ChatChannel::Nearby => "nearby",
1992                    flatland_protocol::ChatChannel::WhisperStone => "whisper",
1993                };
1994                self.state.push_log(format!(
1995                    "[{label}] {}: {}",
1996                    msg.from_name, msg.text
1997                ));
1998            }
1999            SessionEvent::HarvestResult(result) => {
2000                self.state.clear_harvest_state();
2001                crate::harvest_trace!(
2002                    entity_id = self.state.entity_id,
2003                    node_id = %result.node_id,
2004                    template = %result.item_template,
2005                    quantity = result.quantity,
2006                    client_tick = self.state.tick,
2007                    "client applied harvest result"
2008                );
2009                *self
2010                    .state
2011                    .inventory
2012                    .entry(result.item_template.clone())
2013                    .or_insert(0) += result.quantity;
2014                self.state.push_log(format!(
2015                    "Harvested {} x{}",
2016                    result.item_template, result.quantity
2017                ));
2018            }
2019            SessionEvent::CraftResult(result) => {
2020                for stack in &result.consumed {
2021                    if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
2022                        *qty = qty.saturating_sub(stack.quantity);
2023                        if *qty == 0 {
2024                            self.state.inventory.remove(&stack.template_id);
2025                        }
2026                    }
2027                }
2028                for stack in &result.outputs {
2029                    *self
2030                        .state
2031                        .inventory
2032                        .entry(stack.template_id.clone())
2033                        .or_insert(0) += stack.quantity;
2034                }
2035                if let Some(output) = result.outputs.first() {
2036                    if result.batch_total > 1 {
2037                        self.state.push_log(format!(
2038                            "Crafted {} x{} ({}/{})",
2039                            output.template_id,
2040                            output.quantity,
2041                            result.batch_index,
2042                            result.batch_total
2043                        ));
2044                    } else {
2045                        self.state.push_log(format!(
2046                            "Crafted {} x{}",
2047                            output.template_id, output.quantity
2048                        ));
2049                    }
2050                } else {
2051                    self.state.push_log(format!("Craft finished: {}", result.blueprint_id));
2052                }
2053            }
2054            SessionEvent::Death(notice) => {
2055                self.state.clear_harvest_state();
2056                self.state.push_log(notice.message.clone());
2057                self.state.push_log(format!(
2058                    "Respawned at ({:.1}, {:.1})",
2059                    notice.respawn_x, notice.respawn_y
2060                ));
2061            }
2062            SessionEvent::Interaction(notice) => {
2063                if notice.message.starts_with("Harvest failed:") {
2064                    self.state.clear_harvest_state();
2065                }
2066                if notice.message.starts_with("Can't do that:") {
2067                    self.state.pending_craft_ack = None;
2068                }
2069                if notice.message.starts_with("Cast failed:") {
2070                    self.state.cast_progress = None;
2071                }
2072                if notice.message.contains("slain the") {
2073                    self.state.combat_target = None;
2074                    self.state.combat_target_label = None;
2075                }
2076                self.state.push_log(notice.message.clone());
2077            }
2078            SessionEvent::UseResult(result) => {
2079                if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
2080                    *qty = qty.saturating_sub(1);
2081                    if *qty == 0 {
2082                        self.state.inventory.remove(&result.template_id);
2083                    }
2084                }
2085                self.state.push_log(format!(
2086                    "Consumed {} (+{:.0} hunger, +{:.0} thirst)",
2087                    result.template_id, result.hunger_restored, result.thirst_restored
2088                ));
2089            }
2090            SessionEvent::Disconnected { reason } => {
2091                self.state.clear_harvest_state();
2092                self.state.connected = false;
2093                self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
2094                if let Some(r) = &self.state.disconnect_reason {
2095                    self.state.push_log(format!("Disconnected: {r}"));
2096                } else {
2097                    self.state.push_log("Disconnected from server");
2098                }
2099            }
2100        }
2101        Ok(())
2102    }
2103
2104    pub fn is_connected(&self) -> bool {
2105        self.state.connected
2106    }
2107
2108    pub fn close_overlays(&mut self) {
2109        self.state.show_stats = false;
2110        self.state.show_craft_menu = false;
2111        self.state.show_inventory_menu = false;
2112        self.state.show_loadout_menu = false;
2113        self.state.show_rotation_editor = false;
2114        self.state.rotation_editor.reset();
2115        self.state.show_rename_prompt = false;
2116        self.state.rename_buffer.clear();
2117        self.state.show_move_picker = false;
2118        self.state.move_picker = None;
2119    }
2120
2121    /// Esc / back — pop one UI layer instead of closing every overlay at once.
2122    pub fn back_on_esc(&mut self) -> bool {
2123        if self.state.show_rename_prompt {
2124            self.cancel_rename_prompt();
2125            return true;
2126        }
2127        if self.state.show_move_picker {
2128            self.close_move_picker();
2129            return true;
2130        }
2131        if self.state.show_rotation_editor {
2132            match self.state.rotation_editor.mode {
2133                RotationEditorMode::List => {
2134                    self.state.show_rotation_editor = false;
2135                    self.state.rotation_editor.reset();
2136                }
2137                RotationEditorMode::EditLabel => {
2138                    self.state.rotation_editor.label_buffer.clear();
2139                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
2140                }
2141                RotationEditorMode::PickAbility => {
2142                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
2143                }
2144                RotationEditorMode::EditSequence => {
2145                    self.state.rotation_editor.draft = None;
2146                    self.state.rotation_editor.mode = RotationEditorMode::List;
2147                }
2148            }
2149            return true;
2150        }
2151        if self.state.show_inventory_menu {
2152            self.close_inventory_menu();
2153            return true;
2154        }
2155        if self.state.show_craft_menu {
2156            self.close_craft_menu();
2157            return true;
2158        }
2159        if self.state.show_loadout_menu {
2160            self.state.show_loadout_menu = false;
2161            return true;
2162        }
2163        if self.state.show_stats {
2164            self.state.show_stats = false;
2165            return true;
2166        }
2167        false
2168    }
2169
2170    pub fn toggle_stats(&mut self) {
2171        self.state.show_stats = !self.state.show_stats;
2172        if self.state.show_stats {
2173            self.state.show_craft_menu = false;
2174            self.state.show_inventory_menu = false;
2175        }
2176    }
2177
2178    pub fn open_inventory_menu(&mut self) {
2179        self.state.show_inventory_menu = true;
2180        self.state.show_craft_menu = false;
2181        self.state.show_stats = false;
2182        self.state.show_move_picker = false;
2183        self.state.move_picker = None;
2184        self.state.show_rename_prompt = false;
2185        self.state.rename_buffer.clear();
2186        self.state.clamp_inventory_indices();
2187    }
2188
2189    pub fn close_inventory_menu(&mut self) {
2190        self.state.show_inventory_menu = false;
2191        self.state.show_move_picker = false;
2192        self.state.move_picker = None;
2193        self.state.show_rename_prompt = false;
2194        self.state.rename_buffer.clear();
2195    }
2196
2197    pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
2198        let Some(row) = self.state.inventory_selected_row() else {
2199            anyhow::bail!("inventory empty");
2200        };
2201        if !self.state.row_is_renameable_container(&row) {
2202            anyhow::bail!("only storage containers can be renamed");
2203        }
2204        let current = row
2205            .stack
2206            .display_name
2207            .clone()
2208            .unwrap_or_else(|| row.stack.template_id.clone());
2209        self.state.rename_buffer = current;
2210        self.state.show_rename_prompt = true;
2211        self.state.show_move_picker = false;
2212        Ok(())
2213    }
2214
2215    pub fn cancel_rename_prompt(&mut self) {
2216        self.state.show_rename_prompt = false;
2217        self.state.rename_buffer.clear();
2218    }
2219
2220    pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
2221        let name = self.state.rename_buffer.trim().to_string();
2222        if name.is_empty() {
2223            anyhow::bail!("name cannot be empty");
2224        }
2225        let Some(row) = self.state.inventory_selected_row() else {
2226            anyhow::bail!("inventory empty");
2227        };
2228        let Some(instance_id) = row.stack.item_instance_id else {
2229            anyhow::bail!("item has no instance id");
2230        };
2231        self.seq += 1;
2232        self.session
2233            .submit_intent(Intent::RenameContainer {
2234                entity_id: self.state.entity_id,
2235                item_instance_id: instance_id,
2236                location: row.from.clone(),
2237                name,
2238                seq: self.seq,
2239            })
2240            .await?;
2241        self.state.intents_sent += 1;
2242        self.state.show_rename_prompt = false;
2243        self.state.rename_buffer.clear();
2244        Ok(())
2245    }
2246
2247    pub fn toggle_inventory_menu(&mut self) {
2248        if self.state.show_inventory_menu {
2249            self.close_inventory_menu();
2250        } else {
2251            self.open_inventory_menu();
2252        }
2253    }
2254
2255    /// ↑/↓ in the inventory browser, or within the "move to…" picker when open.
2256    pub fn inventory_menu_move(&mut self, delta: i32) {
2257        if self.state.show_move_picker {
2258            let n = self
2259                .state
2260                .move_picker
2261                .as_ref()
2262                .map(|p| p.options.len())
2263                .unwrap_or(0);
2264            if n == 0 {
2265                return;
2266            }
2267            let idx = self.state.move_picker_index as i32;
2268            self.state.move_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
2269            self.state.clamp_move_picker_quantity();
2270            return;
2271        }
2272        let n = self.state.inventory_selectable_rows().len();
2273        if n == 0 {
2274            return;
2275        }
2276        let idx = self.state.inventory_menu_index as i32;
2277        self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
2278    }
2279
2280    /// Enter: unequip a worn bag, equip a weapon, wear/place a loose bag or
2281    /// chest, drink/eat a loose consumable — or fall back to the "move to…"
2282    /// destination picker for anything else (including items already inside a
2283    /// worn bag or a nearby chest).
2284    pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
2285        if self.state.show_move_picker {
2286            return self.confirm_move_picker().await;
2287        }
2288        let Some(row) = self.state.inventory_selected_row() else {
2289            anyhow::bail!("inventory empty");
2290        };
2291        if row.is_equip_shell {
2292            let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
2293                anyhow::bail!("not a worn item");
2294            };
2295            return self.equip_worn(slot, None).await;
2296        }
2297        if row.is_chest_shell {
2298            let flatland_protocol::InventoryLocation::Placed { container_id } = row.from else {
2299                anyhow::bail!("not a placed chest");
2300            };
2301            return self.toggle_placed_chest_lock(&container_id).await;
2302        }
2303        let template_id = row.stack.template_id.clone();
2304        let instance_id = row.stack.item_instance_id;
2305        let category = self.state.inventory_item_category(&template_id);
2306        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
2307
2308        if category == Some("weapon") {
2309            return self.equip_mainhand(Some(template_id)).await;
2310        }
2311        if (category == Some("container") || category == Some("armor")) && on_person {
2312            if let Some(inst) = instance_id {
2313                if template_id.contains("chest") {
2314                    return self.place_container(inst).await;
2315                }
2316                // Pouches no longer equip directly — they clip onto a worn belt's loops
2317                // instead, so fall through to the move picker (offers "belt loop" when a
2318                // belt is worn). Backpacks/belts/armor equip straight to their body slot.
2319                if let Some(slot) = guess_body_slot(&template_id) {
2320                    return self.equip_worn(slot, Some(inst)).await;
2321                }
2322            }
2323        }
2324        if category == Some("consumable") && on_person {
2325            return self.use_item(&template_id).await;
2326        }
2327        // Anything else (materials, pouches, items nested in a bag/chest, weapons
2328        // you'd rather stash than wield, ...) — offer explicit places to move it
2329        // instead of guessing.
2330        self.open_move_picker()
2331    }
2332
2333    /// `m`: always open the "move to…" picker for the selected item, even for
2334    /// weapons/wearables that Enter would otherwise equip/wear directly.
2335    pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
2336        let Some(row) = self.state.inventory_selected_row() else {
2337            anyhow::bail!("inventory empty");
2338        };
2339        if row.is_equip_shell {
2340            anyhow::bail!("this is a worn bag — press Enter to unequip it");
2341        }
2342        if row.is_chest_shell {
2343            anyhow::bail!("select the chest and press Enter or l to lock/unlock it");
2344        }
2345        let Some(instance_id) = row.stack.item_instance_id else {
2346            anyhow::bail!("item has no instance id");
2347        };
2348        let options = self.state.move_destinations_for(
2349            &row.from,
2350            row.from_parent_instance_id,
2351            row.stack.item_instance_id,
2352            &row.stack.template_id,
2353        );
2354        let item_label = row
2355            .stack
2356            .display_name
2357            .clone()
2358            .unwrap_or_else(|| row.stack.template_id.clone());
2359        self.state.move_picker = Some(MovePicker {
2360            item_instance_id: instance_id,
2361            from: row.from,
2362            item_label,
2363            template_id: row.stack.template_id.clone(),
2364            stack_quantity: row.stack.quantity,
2365            quantity: row.stack.quantity,
2366            options,
2367        });
2368        self.state.move_picker_index = 0;
2369        self.state.show_move_picker = true;
2370        Ok(())
2371    }
2372
2373    pub fn close_move_picker(&mut self) {
2374        self.state.show_move_picker = false;
2375        self.state.move_picker = None;
2376    }
2377
2378    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
2379        self.state.move_picker_adjust_quantity(delta);
2380    }
2381
2382    pub fn move_picker_set_quantity_max(&mut self) {
2383        self.state.move_picker_set_quantity_max();
2384    }
2385
2386    async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
2387        let Some(picker) = self.state.move_picker.clone() else {
2388            self.close_move_picker();
2389            return Ok(());
2390        };
2391        let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
2392            self.close_move_picker();
2393            return Ok(());
2394        };
2395        match option.kind {
2396            MoveOptionKind::Cancel => {
2397                self.close_move_picker();
2398            }
2399            MoveOptionKind::Drop => {
2400                self.close_move_picker();
2401                self.drop_item(picker.item_instance_id, picker.from).await?;
2402                self.state.push_log(format!("Dropped {}", picker.item_label));
2403            }
2404            MoveOptionKind::Move {
2405                location,
2406                parent_instance_id,
2407            } => {
2408                self.close_move_picker();
2409                let qty = if picker.quantity >= picker.stack_quantity {
2410                    None
2411                } else {
2412                    Some(picker.quantity)
2413                };
2414                self.move_item(
2415                    picker.item_instance_id,
2416                    picker.from,
2417                    location,
2418                    parent_instance_id,
2419                    qty,
2420                )
2421                .await?;
2422                let moved = qty.unwrap_or(picker.stack_quantity);
2423                if moved >= picker.stack_quantity {
2424                    self.state.push_log(format!("Moved {}", picker.item_label));
2425                } else {
2426                    self.state.push_log(format!(
2427                        "Moved {} ×{} of {}",
2428                        picker.item_label, moved, picker.stack_quantity
2429                    ));
2430                }
2431            }
2432        }
2433        Ok(())
2434    }
2435
2436    /// `d`: drop the selected item on the ground immediately (no picker).
2437    pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
2438        let Some(row) = self.state.inventory_selected_row() else {
2439            anyhow::bail!("inventory empty");
2440        };
2441        if row.is_equip_shell {
2442            anyhow::bail!("unequip the bag first (Enter), then drop from your person");
2443        }
2444        if row.is_chest_shell {
2445            anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
2446        }
2447        let Some(inst) = row.stack.item_instance_id else {
2448            anyhow::bail!("item has no instance id");
2449        };
2450        let label = row
2451            .stack
2452            .display_name
2453            .clone()
2454            .unwrap_or_else(|| row.stack.template_id.clone());
2455        self.drop_item(inst, row.from).await?;
2456        self.state.push_log(format!("Dropped {label}"));
2457        Ok(())
2458    }
2459
2460    pub async fn drop_item(
2461        &mut self,
2462        item_instance_id: uuid::Uuid,
2463        from: flatland_protocol::InventoryLocation,
2464    ) -> anyhow::Result<()> {
2465        self.seq += 1;
2466        self.session
2467            .submit_intent(Intent::DropItem {
2468                entity_id: self.state.entity_id,
2469                item_instance_id,
2470                from,
2471                seq: self.seq,
2472            })
2473            .await?;
2474        self.state.intents_sent += 1;
2475        Ok(())
2476    }
2477
2478    /// `l`: lock/unlock a placed chest — selected chest in inventory UI, else nearest.
2479    pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
2480        if let Some(row) = self.state.inventory_selected_row() {
2481            if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
2482                return self.toggle_placed_chest_lock(container_id).await;
2483            }
2484        }
2485        self.toggle_nearby_chest_lock().await
2486    }
2487
2488    pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
2489        let chest = self
2490            .state
2491            .placed_containers
2492            .iter()
2493            .find(|c| c.id == container_id)
2494            .cloned()
2495            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
2496        let (px, py) = self.state.player_position();
2497        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
2498            anyhow::bail!("too far from {}", chest.display_name);
2499        }
2500        if !chest.accessible && chest.locked {
2501            anyhow::bail!(
2502                "need the matching key for {} (each crafted chest has its own key)",
2503                chest.display_name
2504            );
2505        }
2506        let lock = !chest.locked;
2507        self.set_container_locked(
2508            flatland_protocol::InventoryLocation::Placed {
2509                container_id: chest.id.clone(),
2510            },
2511            lock,
2512        )
2513        .await?;
2514        self.state.push_log(if lock {
2515            format!("Locked {}", chest.display_name)
2516        } else {
2517            format!("Unlocked {}", chest.display_name)
2518        });
2519        Ok(())
2520    }
2521
2522    /// `l` outside inventory: lock/unlock the nearest placed chest (within `CONTAINER_RANGE_M`).
2523    pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
2524        let chest = self
2525            .state
2526            .nearest_placed_container(CONTAINER_RANGE_M)
2527            .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
2528        self.toggle_placed_chest_lock(&chest.id).await
2529    }
2530
2531    pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
2532        self.equip_mainhand(None).await
2533    }
2534
2535    pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
2536        let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
2537        for slot in slots {
2538            self.equip_worn(slot, None).await?;
2539        }
2540        Ok(())
2541    }
2542
2543    pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
2544        let (px, py) = self.state.player_position();
2545        let nearest = self
2546            .state
2547            .placed_containers
2548            .iter()
2549            .min_by(|a, b| {
2550                let da = (a.x - px).hypot(a.y - py);
2551                let db = (b.x - px).hypot(b.y - py);
2552                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
2553            })
2554            .cloned();
2555        let Some(chest) = nearest else {
2556            anyhow::bail!("no chest nearby");
2557        };
2558        if (chest.x - px).hypot(chest.y - py) > 2.0 {
2559            anyhow::bail!("too far from chest");
2560        }
2561        self.pickup_container(chest.id).await
2562    }
2563
2564    pub async fn equip_worn(
2565        &mut self,
2566        slot: BodySlot,
2567        instance_id: Option<uuid::Uuid>,
2568    ) -> anyhow::Result<()> {
2569        self.seq += 1;
2570        self.session
2571            .submit_intent(Intent::EquipWorn {
2572                entity_id: self.state.entity_id,
2573                slot,
2574                instance_id,
2575                seq: self.seq,
2576            })
2577            .await?;
2578        self.state.intents_sent += 1;
2579        Ok(())
2580    }
2581
2582    pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
2583        self.seq += 1;
2584        self.session
2585            .submit_intent(Intent::PlaceContainer {
2586                entity_id: self.state.entity_id,
2587                item_instance_id,
2588                seq: self.seq,
2589            })
2590            .await?;
2591        self.state.intents_sent += 1;
2592        Ok(())
2593    }
2594
2595    pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
2596        self.seq += 1;
2597        self.session
2598            .submit_intent(Intent::PickupContainer {
2599                entity_id: self.state.entity_id,
2600                container_id,
2601                seq: self.seq,
2602            })
2603            .await?;
2604        self.state.intents_sent += 1;
2605        Ok(())
2606    }
2607
2608    pub async fn move_item(
2609        &mut self,
2610        item_instance_id: uuid::Uuid,
2611        from: flatland_protocol::InventoryLocation,
2612        to: flatland_protocol::InventoryLocation,
2613        to_parent_instance_id: Option<uuid::Uuid>,
2614        quantity: Option<u32>,
2615    ) -> anyhow::Result<()> {
2616        self.seq += 1;
2617        self.session
2618            .submit_intent(Intent::MoveItem {
2619                entity_id: self.state.entity_id,
2620                item_instance_id,
2621                from,
2622                to,
2623                to_parent_instance_id,
2624                quantity,
2625                seq: self.seq,
2626            })
2627            .await?;
2628        self.state.intents_sent += 1;
2629        Ok(())
2630    }
2631
2632    pub async fn set_container_locked(
2633        &mut self,
2634        location: flatland_protocol::InventoryLocation,
2635        locked: bool,
2636    ) -> anyhow::Result<()> {
2637        self.seq += 1;
2638        self.session
2639            .submit_intent(Intent::SetContainerLocked {
2640                entity_id: self.state.entity_id,
2641                location,
2642                locked,
2643                seq: self.seq,
2644            })
2645            .await?;
2646        self.state.intents_sent += 1;
2647        Ok(())
2648    }
2649
2650    pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
2651        if !self.state.is_alive() {
2652            anyhow::bail!("you are dead");
2653        }
2654        self.seq += 1;
2655        self.session
2656            .submit_intent(Intent::Use {
2657                entity_id: self.state.entity_id,
2658                template_id: template_id.to_string(),
2659                seq: self.seq,
2660            })
2661            .await?;
2662        self.state.intents_sent += 1;
2663        Ok(())
2664    }
2665
2666    pub fn open_craft_menu(&mut self) {
2667        self.state.show_craft_menu = true;
2668        self.state.show_stats = false;
2669        self.state.show_inventory_menu = false;
2670        if self.state.blueprints.is_empty() {
2671            self.state.craft_menu_index = 0;
2672            self.state.craft_batch_quantity = 1;
2673            return;
2674        }
2675        self.state.craft_menu_index = self
2676            .state
2677            .craft_menu_index
2678            .min(self.state.blueprints.len() - 1);
2679        if let Some(idx) = self
2680            .state
2681            .blueprints
2682            .iter()
2683            .position(|bp| self.state.can_craft_blueprint(bp))
2684        {
2685            self.state.craft_menu_index = idx;
2686        }
2687        self.state.clamp_craft_batch_quantity();
2688    }
2689
2690    pub fn close_craft_menu(&mut self) {
2691        self.state.show_craft_menu = false;
2692    }
2693
2694    pub fn craft_menu_move(&mut self, delta: i32) {
2695        let n = self.state.blueprints.len();
2696        if n == 0 {
2697            return;
2698        }
2699        let idx = self.state.craft_menu_index as i32;
2700        let next = (idx + delta).rem_euclid(n as i32);
2701        self.state.craft_menu_index = next as usize;
2702        self.state.clamp_craft_batch_quantity();
2703    }
2704
2705    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
2706        self.state.craft_batch_adjust_quantity(delta);
2707    }
2708
2709    pub fn craft_batch_set_max(&mut self) {
2710        self.state.craft_batch_set_max();
2711    }
2712
2713    pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
2714        let Some(blueprint) = self
2715            .state
2716            .blueprints
2717            .get(self.state.craft_menu_index)
2718            .cloned()
2719        else {
2720            anyhow::bail!("no blueprints known");
2721        };
2722        if !self.state.can_craft_blueprint(&blueprint) {
2723            let hint = self
2724                .state
2725                .craft_missing_hint(&blueprint)
2726                .unwrap_or_else(|| "missing materials".into());
2727            anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
2728        }
2729        let count = self.state.craft_batch_quantity;
2730        let max = self.state.max_craft_batches(&blueprint);
2731        if max == 0 {
2732            anyhow::bail!("cannot craft {}", blueprint.label);
2733        }
2734        let batches = count.min(max);
2735        self.craft(&blueprint.id, Some(batches)).await?;
2736        self.state.show_craft_menu = false;
2737        Ok(())
2738    }
2739
2740    pub async fn move_by(
2741        &mut self,
2742        forward: f32,
2743        strafe: f32,
2744        vertical: f32,
2745        sprint: bool,
2746    ) -> anyhow::Result<()> {
2747        if !self.state.is_alive() {
2748            anyhow::bail!("you are dead");
2749        }
2750        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
2751            self.last_move_forward = forward;
2752            self.last_move_strafe = strafe;
2753        }
2754        self.seq += 1;
2755        self.session
2756            .submit_intent(Intent::Move {
2757                entity_id: self.state.entity_id,
2758                forward,
2759                strafe,
2760                vertical,
2761                sprint,
2762                seq: self.seq,
2763            })
2764            .await?;
2765        self.state.intents_sent += 1;
2766        Ok(())
2767    }
2768
2769    pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
2770        if !self.state.connected {
2771            crate::harvest_trace!("harvest_nearest rejected: not connected");
2772            anyhow::bail!("not connected");
2773        }
2774        if !self.state.is_alive() {
2775            crate::harvest_trace!("harvest_nearest rejected: player dead");
2776            anyhow::bail!("you are dead");
2777        }
2778        if self.state.harvest_in_progress {
2779            if self.state.harvest_state_stale() {
2780                self.state.clear_harvest_state();
2781            } else {
2782                anyhow::bail!("already harvesting");
2783            }
2784        }
2785        let (px, py) = self
2786            .state
2787            .player
2788            .as_ref()
2789            .map(|p| (p.transform.position.x, p.transform.position.y))
2790            .unwrap_or((0.0, 0.0));
2791
2792        let available = self
2793            .state
2794            .resource_nodes
2795            .iter()
2796            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
2797            .count();
2798        let node_id = self
2799            .state
2800            .resource_nodes
2801            .iter()
2802            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
2803            .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
2804            .min_by(|a, b| {
2805                let da = distance(px, py, a.x, a.y);
2806                let db = distance(px, py, b.x, b.y);
2807                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
2808            })
2809            .map(|n| n.id.clone());
2810
2811        let Some(node_id) = node_id else {
2812            let has_loot = self.state.ground_drops.iter().any(|d| {
2813                distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M
2814            });
2815            if has_loot {
2816                return self.pickup_nearest().await;
2817            }
2818            anyhow::bail!(
2819                "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press p to pick up"
2820            );
2821        };
2822
2823        self.seq += 1;
2824        let seq = self.seq;
2825        crate::harvest_trace!(
2826            entity_id = self.state.entity_id,
2827            node_id = %node_id,
2828            seq,
2829            px,
2830            py,
2831            available_nodes = available,
2832            "submitting harvest intent"
2833        );
2834        self.session
2835            .submit_intent(Intent::Harvest {
2836                entity_id: self.state.entity_id,
2837                node_id,
2838                seq,
2839            })
2840            .await?;
2841        self.state.intents_sent += 1;
2842        self.state.harvest_in_progress = true;
2843        self.state.harvest_started_at = Some(Instant::now());
2844        self.state.push_log("Harvesting…");
2845        crate::harvest_trace!(entity_id = self.state.entity_id, seq, "harvest intent queued to session");
2846        Ok(())
2847    }
2848
2849    pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
2850        if !self.state.is_alive() {
2851            anyhow::bail!("you are dead");
2852        }
2853        let blueprint_id = self
2854            .state
2855            .blueprints
2856            .iter()
2857            .find(|bp| self.state.can_craft_blueprint(bp))
2858            .map(|bp| bp.id.clone())
2859            .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
2860        self.craft(&blueprint_id, None).await
2861    }
2862
2863    pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
2864        if !self.state.is_alive() {
2865            anyhow::bail!("you are dead");
2866        }
2867        self.seq += 1;
2868        self.session
2869            .submit_intent(Intent::Craft {
2870                entity_id: self.state.entity_id,
2871                blueprint_id: blueprint_id.to_string(),
2872                count,
2873                seq: self.seq,
2874            })
2875            .await?;
2876        self.state.intents_sent += 1;
2877        let (label, batches) = self
2878            .state
2879            .blueprints
2880            .iter()
2881            .find(|b| b.id == blueprint_id)
2882            .map(|b| {
2883                let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
2884                (b.label.as_str(), n)
2885            })
2886            .unwrap_or((blueprint_id, count.unwrap_or(1)));
2887        self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
2888        Ok(())
2889    }
2890
2891    pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
2892        if !self.state.is_alive() {
2893            anyhow::bail!("you are dead");
2894        }
2895        let target_id = match self.state.nearest_interact_target() {
2896            Some(id) => id,
2897            None => {
2898                let (px, py) = self.state.player_position();
2899                let has_loot = self.state.ground_drops.iter().any(|d| {
2900                    distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M
2901                });
2902                if has_loot {
2903                    return self.pickup_nearest().await;
2904                }
2905                anyhow::bail!("nothing to interact with nearby (stand on * loot and press p)");
2906            }
2907        };
2908        self.seq += 1;
2909        self.session
2910            .submit_intent(Intent::Interact {
2911                entity_id: self.state.entity_id,
2912                target_id: target_id.clone(),
2913                seq: self.seq,
2914            })
2915            .await?;
2916        self.state.intents_sent += 1;
2917        Ok(())
2918    }
2919
2920    pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
2921        self.seq += 1;
2922        self.session
2923            .submit_intent(Intent::TestDamage {
2924                entity_id: self.state.entity_id,
2925                amount,
2926                seq: self.seq,
2927            })
2928            .await?;
2929        self.state.intents_sent += 1;
2930        Ok(())
2931    }
2932
2933    pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
2934        self.cycle_combat_target_slot(1, reverse).await
2935    }
2936
2937    pub async fn cycle_combat_target_slot(
2938        &mut self,
2939        slot_index: u8,
2940        reverse: bool,
2941    ) -> anyhow::Result<()> {
2942        if !self.state.is_alive() {
2943            anyhow::bail!("you are dead");
2944        }
2945        let candidates = self.state.candidates_for_slot(slot_index);
2946        if candidates.is_empty() {
2947            anyhow::bail!("no targets nearby");
2948        }
2949        let current = self.state.target_for_slot(slot_index);
2950        let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
2951        let next_idx = match idx {
2952            None => 0,
2953            Some(i) if reverse => {
2954                if i == 0 {
2955                    candidates.len() - 1
2956                } else {
2957                    i - 1
2958                }
2959            }
2960            Some(i) => (i + 1) % candidates.len(),
2961        };
2962        if idx == Some(next_idx) && candidates.len() == 1 {
2963            self.clear_combat_target_slot(slot_index).await?;
2964            return Ok(());
2965        }
2966        let (target_id, label) = candidates[next_idx].clone();
2967        self.set_combat_target_slot(slot_index, target_id, &label)
2968            .await
2969    }
2970
2971    pub async fn set_combat_target_slot(
2972        &mut self,
2973        slot_index: u8,
2974        target_id: EntityId,
2975        label: &str,
2976    ) -> anyhow::Result<()> {
2977        if !self.state.is_alive() {
2978            anyhow::bail!("you are dead");
2979        }
2980        self.seq += 1;
2981        self.session
2982            .submit_intent(Intent::SetTargetSlot {
2983                entity_id: self.state.entity_id,
2984                slot_index,
2985                target_id,
2986                seq: self.seq,
2987            })
2988            .await?;
2989        self.state.intents_sent += 1;
2990        if slot_index == 1 {
2991            self.state.combat_target = Some(target_id);
2992            self.state.combat_target_label = Some(label.to_string());
2993        }
2994        self.state
2995            .push_log(format!("Slot {slot_index} target: {label}"));
2996        Ok(())
2997    }
2998
2999    pub async fn set_combat_target(
3000        &mut self,
3001        target_id: EntityId,
3002        label: &str,
3003    ) -> anyhow::Result<()> {
3004        self.set_combat_target_slot(1, target_id, label).await
3005    }
3006
3007    pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
3008        if slot_index == 1 && self.state.combat_target.is_none() {
3009            return Ok(());
3010        }
3011        self.seq += 1;
3012        self.session
3013            .submit_intent(Intent::ClearTargetSlot {
3014                entity_id: self.state.entity_id,
3015                slot_index,
3016                seq: self.seq,
3017            })
3018            .await?;
3019        if slot_index == 1 {
3020            self.state.combat_target = None;
3021            self.state.combat_target_label = None;
3022        }
3023        self.state.intents_sent += 1;
3024        self.state
3025            .push_log(format!("Slot {slot_index} target cleared"));
3026        Ok(())
3027    }
3028
3029    pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
3030        self.clear_combat_target_slot(1).await
3031    }
3032
3033    pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
3034        if !self.state.is_alive() {
3035            anyhow::bail!("you are dead");
3036        }
3037        self.seq += 1;
3038        self.session
3039            .submit_intent(Intent::AdvanceRotation {
3040                entity_id: self.state.entity_id,
3041                slot_index,
3042                seq: self.seq,
3043            })
3044            .await?;
3045        self.state.intents_sent += 1;
3046        Ok(())
3047    }
3048
3049    pub async fn assign_slot_preset(&mut self, slot_index: u8, preset_id: &str) -> anyhow::Result<()> {
3050        if !self.state.is_alive() {
3051            anyhow::bail!("you are dead");
3052        }
3053        self.seq += 1;
3054        self.session
3055            .submit_intent(Intent::AssignSlotPreset {
3056                entity_id: self.state.entity_id,
3057                slot_index,
3058                preset_id: preset_id.to_string(),
3059                seq: self.seq,
3060            })
3061            .await?;
3062        self.state.intents_sent += 1;
3063        if let Some(slot) = self
3064            .state
3065            .combat_slots
3066            .iter_mut()
3067            .find(|s| s.slot_index == slot_index)
3068        {
3069            slot.preset_id = Some(preset_id.to_string());
3070            if let Some(preset) = self.state.rotation_presets.iter().find(|p| p.id == preset_id) {
3071                slot.preset_label = Some(preset.label.clone());
3072                slot.rotation = preset.abilities.clone();
3073                slot.rotation_index = 0;
3074            }
3075        }
3076        self.state
3077            .push_log(format!("T{slot_index} loadout → {preset_id}"));
3078        Ok(())
3079    }
3080
3081    pub async fn cast_ability(
3082        &mut self,
3083        ability_id: &str,
3084        target_id: Option<EntityId>,
3085    ) -> anyhow::Result<()> {
3086        if !self.state.is_alive() {
3087            anyhow::bail!("you are dead");
3088        }
3089        let target_id = target_id
3090            .or_else(|| self.state.target_for_slot(2))
3091            .or_else(|| self.state.target_for_slot(1))
3092            .unwrap_or(self.state.entity_id);
3093        self.seq += 1;
3094        self.session
3095            .submit_intent(Intent::Cast {
3096                entity_id: self.state.entity_id,
3097                ability_id: ability_id.to_string(),
3098                target_id,
3099                seq: self.seq,
3100            })
3101            .await?;
3102        self.state.intents_sent += 1;
3103        self.state
3104            .push_log(format!("Cast {ability_id} → {target_id}"));
3105        Ok(())
3106    }
3107
3108    pub async fn upsert_rotation_preset(
3109        &mut self,
3110        preset: RotationPreset,
3111    ) -> anyhow::Result<()> {
3112        self.seq += 1;
3113        self.session
3114            .submit_intent(Intent::UpsertRotationPreset {
3115                entity_id: self.state.entity_id,
3116                preset: preset.clone(),
3117                seq: self.seq,
3118            })
3119            .await?;
3120        self.state.intents_sent += 1;
3121        if let Some(existing) = self
3122            .state
3123            .rotation_presets
3124            .iter_mut()
3125            .find(|p| p.id == preset.id)
3126        {
3127            *existing = preset.clone();
3128        } else {
3129            self.state.rotation_presets.push(preset.clone());
3130        }
3131        for slot in &mut self.state.combat_slots {
3132            if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
3133                slot.preset_label = Some(preset.label.clone());
3134                slot.rotation = preset.abilities.clone();
3135            }
3136        }
3137        self.state
3138            .push_log(format!("Saved rotation: {}", preset.label));
3139        Ok(())
3140    }
3141
3142    pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
3143        self.seq += 1;
3144        self.session
3145            .submit_intent(Intent::DeleteRotationPreset {
3146                entity_id: self.state.entity_id,
3147                preset_id: preset_id.to_string(),
3148                seq: self.seq,
3149            })
3150            .await?;
3151        self.state.intents_sent += 1;
3152        self.state
3153            .rotation_presets
3154            .retain(|p| p.id != preset_id);
3155        for slot in &mut self.state.combat_slots {
3156            if slot.preset_id.as_deref() == Some(preset_id) {
3157                slot.preset_id = None;
3158                slot.preset_label = None;
3159                slot.rotation.clear();
3160                slot.rotation_index = 0;
3161            }
3162        }
3163        self.state
3164            .push_log(format!("Deleted rotation: {preset_id}"));
3165        Ok(())
3166    }
3167
3168    pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
3169        if !self.state.is_alive() {
3170            anyhow::bail!("you are dead");
3171        }
3172        let enabled = !self
3173            .state
3174            .combat_slots
3175            .iter()
3176            .find(|s| s.slot_index == slot_index)
3177            .map(|s| s.auto_enabled)
3178            .unwrap_or(false);
3179        self.seq += 1;
3180        self.session
3181            .submit_intent(Intent::SetAutoAttack {
3182                entity_id: self.state.entity_id,
3183                slot_index,
3184                enabled,
3185                seq: self.seq,
3186            })
3187            .await?;
3188        if slot_index == 1 {
3189            self.state.auto_attack = enabled;
3190        }
3191        self.state.intents_sent += 1;
3192        self.state.push_log(format!(
3193            "T{slot_index} auto {}",
3194            if enabled { "ON" } else { "OFF" }
3195        ));
3196        Ok(())
3197    }
3198
3199    pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
3200        if !self.state.connected {
3201            anyhow::bail!("not connected");
3202        }
3203        if !self.state.is_alive() {
3204            anyhow::bail!("you are dead");
3205        }
3206        let (px, py) = self.state.player_position();
3207        if self
3208            .state
3209            .ground_drops
3210            .iter()
3211            .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
3212        {
3213            anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press p");
3214        }
3215        self.seq += 1;
3216        self.session
3217            .submit_intent(Intent::Pickup {
3218                entity_id: self.state.entity_id,
3219                drop_id: None,
3220                seq: self.seq,
3221            })
3222            .await?;
3223        self.state.intents_sent += 1;
3224        Ok(())
3225    }
3226
3227    pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
3228        self.toggle_auto_attack_slot(1).await
3229    }
3230
3231    pub async fn dodge(&mut self) -> anyhow::Result<()> {
3232        if !self.state.is_alive() {
3233            anyhow::bail!("you are dead");
3234        }
3235        self.seq += 1;
3236        self.session
3237            .submit_intent(Intent::Dodge {
3238                entity_id: self.state.entity_id,
3239                seq: self.seq,
3240            })
3241            .await?;
3242        self.state.intents_sent += 1;
3243        self.state.push_log("Dodge!");
3244        Ok(())
3245    }
3246
3247    pub async fn lunge(&mut self) -> anyhow::Result<()> {
3248        if !self.state.is_alive() {
3249            anyhow::bail!("you are dead");
3250        }
3251        let (forward, strafe) = self.last_move_axes();
3252        self.seq += 1;
3253        self.session
3254            .submit_intent(Intent::Lunge {
3255                entity_id: self.state.entity_id,
3256                forward,
3257                strafe,
3258                seq: self.seq,
3259            })
3260            .await?;
3261        self.state.intents_sent += 1;
3262        self.state.push_log("Lunge!");
3263        Ok(())
3264    }
3265
3266    /// Remembered WASD axes for lunge when not currently moving.
3267    pub fn last_move_axes(&self) -> (f32, f32) {
3268        (self.last_move_forward, self.last_move_strafe)
3269    }
3270
3271    pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
3272        if !self.state.is_alive() {
3273            anyhow::bail!("you are dead");
3274        }
3275        self.seq += 1;
3276        self.session
3277            .submit_intent(Intent::Block {
3278                entity_id: self.state.entity_id,
3279                enabled,
3280                seq: self.seq,
3281            })
3282            .await?;
3283        self.state.intents_sent += 1;
3284        if enabled {
3285            self.state.push_log("Blocking");
3286        }
3287        Ok(())
3288    }
3289
3290    pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
3291        if !self.state.is_alive() {
3292            anyhow::bail!("you are dead");
3293        }
3294        self.seq += 1;
3295        self.session
3296            .submit_intent(Intent::EquipMainhand {
3297                entity_id: self.state.entity_id,
3298                template_id,
3299                seq: self.seq,
3300            })
3301            .await?;
3302        self.state.intents_sent += 1;
3303        Ok(())
3304    }
3305
3306    pub async fn say(&mut self, channel: flatland_protocol::ChatChannel, text: &str) -> anyhow::Result<()> {
3307        self.seq += 1;
3308        self.session
3309            .submit_intent(Intent::Say {
3310                entity_id: self.state.entity_id,
3311                channel,
3312                text: text.to_string(),
3313                seq: self.seq,
3314            })
3315            .await?;
3316        self.state.intents_sent += 1;
3317        Ok(())
3318    }
3319
3320    pub async fn stop(&mut self) -> anyhow::Result<()> {
3321        self.seq += 1;
3322        self.session
3323            .submit_intent(Intent::Stop {
3324                entity_id: self.state.entity_id,
3325                seq: self.seq,
3326            })
3327            .await?;
3328        self.state.intents_sent += 1;
3329        Ok(())
3330    }
3331
3332    pub fn disconnect(&self) {
3333        self.session.disconnect();
3334    }
3335}
3336
3337fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
3338    let dx = ax - bx;
3339    let dy = ay - by;
3340    (dx * dx + dy * dy).sqrt()
3341}
3342
3343#[cfg(test)]
3344mod tests {
3345    use super::*;
3346    use flatland_protocol::{
3347        BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
3348    };
3349
3350    fn sample_state() -> GameState {
3351        let mut state = GameState {
3352            session_id: 1,
3353            entity_id: 1,
3354            character_id: None,
3355            tick: 0,
3356            chunk_rev: 0,
3357            content_rev: 0,
3358            entities: vec![EntityState {
3359                id: 1,
3360                label: "You".into(),
3361                transform: Transform {
3362                    position: WorldCoord::surface(128.0, 128.0),
3363                    yaw: 0.0,
3364                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
3365                },
3366                vitals: None,
3367                attributes: None,
3368                skills: None,
3369                inside_building: None,
3370            }],
3371            player: None,
3372            resource_nodes: vec![ResourceNodeView {
3373                id: "oak-1".into(),
3374                label: "Oak".into(),
3375                x: 130.0,
3376                y: 128.0,
3377                z: 0.0,
3378                item_template: "oak_log".into(),
3379                state: ResourceNodeState::Available,
3380                blocking: true,
3381                blocking_radius_m: 0.8,
3382            }],
3383            ground_drops: vec![],
3384            placed_containers: vec![],
3385            buildings: vec![BuildingView {
3386                id: "broker-hut".into(),
3387                label: "Broker".into(),
3388                x: 148.0,
3389                y: 118.0,
3390                width_m: 8.0,
3391                depth_m: 6.0,
3392                interior_origin_x: 404.0,
3393                interior_origin_y: 403.0,
3394                tags: vec![],
3395            }],
3396            doors: vec![flatland_protocol::DoorView {
3397                id: "door-1".into(),
3398                building_id: "broker-hut".into(),
3399                x: 148.0,
3400                y: 118.0,
3401                open: false,
3402                is_exit: false,
3403            }],
3404            npcs: vec![],
3405            blueprints: vec![],
3406            world_width_m: 256.0,
3407            world_height_m: 256.0,
3408            terrain_zones: Vec::new(),
3409            world_clock: flatland_protocol::WorldClock::default(),
3410            inventory: std::collections::HashMap::new(),
3411            inventory_hints: std::collections::HashMap::new(),
3412            logs: VecDeque::new(),
3413            intents_sent: 0,
3414            ticks_received: 0,
3415            connected: true,
3416            disconnect_reason: None,
3417            show_stats: false,
3418            show_craft_menu: false,
3419            craft_menu_index: 0,
3420            craft_batch_quantity: 1,
3421            show_inventory_menu: false,
3422            inventory_menu_index: 0,
3423            show_move_picker: false,
3424            show_rename_prompt: false,
3425            rename_buffer: String::new(),
3426            move_picker_index: 0,
3427            move_picker: None,
3428            combat_target: None,
3429            combat_target_label: None,
3430            in_combat: false,
3431            auto_attack: true,
3432            combat_has_los: false,
3433            attack_cd_ticks: 0,
3434            gcd_ticks: 0,
3435            weapon_ability_id: "unarmed".into(),
3436            mainhand_template_id: None,
3437            mainhand_label: None,
3438            worn: BTreeMap::new(),
3439            carry_mass: 0.0,
3440            carry_mass_max: 0.0,
3441            encumbrance: flatland_protocol::EncumbranceState::Light,
3442            inventory_stacks: Vec::new(),
3443            combat_target_detail: None,
3444            cast_progress: None,
3445            ability_cooldowns: Vec::new(),
3446            blocking_active: false,
3447            max_target_slots: 1,
3448            combat_slots: Vec::new(),
3449            rotation_presets: Vec::new(),
3450            show_loadout_menu: false,
3451            show_rotation_editor: false,
3452            loadout_menu_index: 0,
3453            rotation_editor: RotationEditorState::default(),
3454            harvest_in_progress: false,
3455            harvest_started_at: None,
3456            pending_craft_ack: None,
3457        };
3458        state.player = state.entities.first().cloned();
3459        state
3460    }
3461
3462    #[test]
3463    fn empty_entity_tick_preserves_welcome_snapshot() {
3464        let mut state = sample_state();
3465        state.inventory.insert("carrot".into(), 3);
3466        let delta = TickDelta {
3467            tick: 1,
3468            entities: vec![],
3469            resource_nodes: vec![],
3470            ground_drops: vec![],
3471            placed_containers: vec![],
3472            buildings: vec![],
3473            doors: vec![],
3474            npcs: vec![],
3475            inventory: vec![],
3476            blueprints: vec![],
3477            world_clock: flatland_protocol::WorldClock::default(),
3478            combat: None,
3479        };
3480
3481        state.apply_tick_fields(&delta, 1);
3482
3483        assert_eq!(state.entities.len(), 1);
3484        assert!(state.player.is_some());
3485        assert_eq!(state.inventory.get("carrot"), Some(&3));
3486        assert_eq!(state.resource_nodes.len(), 1);
3487    }
3488
3489    #[test]
3490    fn tick_preserves_world_layers_when_delta_omits_them() {
3491        let mut state = sample_state();
3492        let delta = TickDelta {
3493            tick: 1,
3494            entities: state.entities.clone(),
3495            resource_nodes: vec![],
3496            ground_drops: vec![],
3497            placed_containers: vec![],
3498            buildings: vec![],
3499            doors: vec![],
3500            npcs: vec![],
3501            inventory: vec![],
3502            blueprints: vec![],
3503            world_clock: flatland_protocol::WorldClock::default(),
3504            combat: None,
3505        };
3506
3507        state.apply_tick_fields(&delta, 1);
3508
3509        assert_eq!(state.resource_nodes.len(), 1);
3510        assert_eq!(state.buildings.len(), 1);
3511        assert_eq!(state.doors.len(), 1);
3512    }
3513
3514    #[test]
3515    fn tick_updates_resource_nodes_when_server_sends_them() {
3516        let mut state = sample_state();
3517        let delta = TickDelta {
3518            tick: 1,
3519            entities: state.entities.clone(),
3520            resource_nodes: vec![ResourceNodeView {
3521                id: "oak-1".into(),
3522                label: "Oak".into(),
3523                x: 130.0,
3524                y: 128.0,
3525                z: 0.0,
3526                item_template: "oak_log".into(),
3527                state: ResourceNodeState::Cooldown,
3528                blocking: true,
3529                blocking_radius_m: 0.8,
3530            }],
3531            buildings: vec![],
3532            doors: vec![],
3533            npcs: vec![],
3534            inventory: vec![],
3535            blueprints: vec![],
3536            world_clock: flatland_protocol::WorldClock::default(),
3537            ground_drops: vec![],
3538            placed_containers: vec![],
3539            combat: None,
3540        };
3541
3542        state.apply_tick_fields(&delta, 1);
3543
3544        assert!(matches!(
3545            state.resource_nodes[0].state,
3546            ResourceNodeState::Cooldown
3547        ));
3548    }
3549
3550    #[test]
3551    fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
3552        let mut state = GameState {
3553            session_id: 1,
3554            entity_id: 1,
3555            character_id: None,
3556            tick: 0,
3557            chunk_rev: 0,
3558            content_rev: 0,
3559            entities: vec![EntityState {
3560                id: 1,
3561                label: "You".into(),
3562                transform: Transform {
3563                    position: WorldCoord::surface(406.0, 403.0),
3564                    yaw: 0.0,
3565                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
3566                },
3567                vitals: None,
3568                attributes: None,
3569                skills: None,
3570                inside_building: None,
3571            }],
3572            player: None,
3573            resource_nodes: vec![],
3574            ground_drops: vec![],
3575            placed_containers: vec![],
3576            buildings: vec![BuildingView {
3577                id: "broker_hut".into(),
3578                label: "Broker".into(),
3579                x: 158.0,
3580                y: 124.0,
3581                width_m: 8.0,
3582                depth_m: 6.0,
3583                interior_origin_x: 400.0,
3584                interior_origin_y: 400.0,
3585                tags: vec![],
3586            }],
3587            doors: vec![flatland_protocol::DoorView {
3588                id: "broker_hut_exit".into(),
3589                building_id: "broker_hut".into(),
3590                x: 404.0,
3591                y: 400.0,
3592                open: true,
3593                is_exit: true,
3594            }],
3595            npcs: vec![flatland_protocol::NpcView {
3596                id: "ada_broker".into(),
3597                label: "Ada".into(),
3598                x: 406.0,
3599                y: 403.0,
3600                building_id: Some("broker_hut".into()),
3601                role: "broker".into(),
3602                entity_id: None,
3603                life_state: None,
3604                hp_pct: None,
3605            }],
3606            blueprints: vec![],
3607            world_width_m: 256.0,
3608            world_height_m: 256.0,
3609            terrain_zones: Vec::new(),
3610            world_clock: flatland_protocol::WorldClock::default(),
3611            inventory: std::collections::HashMap::new(),
3612            inventory_hints: std::collections::HashMap::new(),
3613            logs: VecDeque::new(),
3614            intents_sent: 0,
3615            ticks_received: 0,
3616            connected: true,
3617            disconnect_reason: None,
3618            show_stats: false,
3619            show_craft_menu: false,
3620            craft_menu_index: 0,
3621            craft_batch_quantity: 1,
3622            show_inventory_menu: false,
3623            inventory_menu_index: 0,
3624            show_move_picker: false,
3625            show_rename_prompt: false,
3626            rename_buffer: String::new(),
3627            move_picker_index: 0,
3628            move_picker: None,
3629            combat_target: None,
3630            combat_target_label: None,
3631            in_combat: false,
3632            auto_attack: true,
3633            combat_has_los: false,
3634            attack_cd_ticks: 0,
3635            gcd_ticks: 0,
3636            weapon_ability_id: "unarmed".into(),
3637            mainhand_template_id: None,
3638            mainhand_label: None,
3639            worn: BTreeMap::new(),
3640            carry_mass: 0.0,
3641            carry_mass_max: 0.0,
3642            encumbrance: flatland_protocol::EncumbranceState::Light,
3643            inventory_stacks: Vec::new(),
3644            combat_target_detail: None,
3645            cast_progress: None,
3646            ability_cooldowns: Vec::new(),
3647            blocking_active: false,
3648            max_target_slots: 1,
3649            combat_slots: Vec::new(),
3650            rotation_presets: Vec::new(),
3651            show_loadout_menu: false,
3652            show_rotation_editor: false,
3653            loadout_menu_index: 0,
3654            rotation_editor: RotationEditorState::default(),
3655            harvest_in_progress: false,
3656            harvest_started_at: None,
3657            pending_craft_ack: None,
3658        };
3659        state.player = state.entities.first().cloned();
3660        assert_eq!(
3661            state.nearest_interact_target().as_deref(),
3662            Some("ada_broker")
3663        );
3664    }
3665
3666    #[test]
3667    fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
3668        let mut state = sample_state();
3669        // Player is at (128, 128) per sample_state(). One chest just inside
3670        // CONTAINER_RANGE_M, one clearly beyond it.
3671        state.placed_containers = vec![
3672            flatland_protocol::PlacedContainerView {
3673                id: "near".into(),
3674                template_id: "wooden_chest_small".into(),
3675                display_name: "Wooden Chest".into(),
3676                x: 130.0,
3677                y: 128.0,
3678                z: 0.0,
3679                locked: true,
3680                accessible: true,
3681                owner_character_id: None,
3682                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
3683                item_instance_id: Some(uuid::Uuid::from_u128(1)),
3684            },
3685            flatland_protocol::PlacedContainerView {
3686                id: "far".into(),
3687                template_id: "wooden_chest_small".into(),
3688                display_name: "Distant Chest".into(),
3689                x: 128.0 + CONTAINER_RANGE_M + 5.0,
3690                y: 128.0,
3691                z: 0.0,
3692                locked: false,
3693                accessible: true,
3694                owner_character_id: None,
3695                contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
3696                item_instance_id: Some(uuid::Uuid::from_u128(2)),
3697            },
3698        ];
3699
3700        let nearby = state.nearby_containers();
3701        assert_eq!(nearby.len(), 1, "far chest must not appear once out of range");
3702        assert_eq!(nearby[0].view.id, "near");
3703        assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
3704        assert!(nearby[0].rows[0].is_chest_shell);
3705
3706        // The same chest, but locked and inaccessible (no key held), must hide
3707        // contents but still show the selectable chest shell row.
3708        state.placed_containers[0].accessible = false;
3709        let nearby = state.nearby_containers();
3710        assert_eq!(nearby.len(), 1);
3711        assert_eq!(nearby[0].rows.len(), 1);
3712        assert!(nearby[0].rows[0].is_chest_shell);
3713    }
3714
3715    #[test]
3716    fn placed_container_public_label_hides_owner_custom_name() {
3717        let owner = uuid::Uuid::from_u128(99);
3718        let mut state = sample_state();
3719        state.character_id = Some(uuid::Uuid::from_u128(1));
3720        state.inventory_hints.insert(
3721            "wooden_chest_medium".into(),
3722            InventoryHint {
3723                display_name: "Medium Wooden Chest".into(),
3724                category: "container".into(),
3725                base_mass: None,
3726                base_volume: None,
3727                capacity_volume: None,
3728                stackable: false,
3729            },
3730        );
3731        let chest = flatland_protocol::PlacedContainerView {
3732            id: "c1".into(),
3733            template_id: "wooden_chest_medium".into(),
3734            display_name: "Barry's Loot #a3f2".into(),
3735            x: 128.0,
3736            y: 128.0,
3737            z: 0.0,
3738            locked: false,
3739            accessible: true,
3740            owner_character_id: Some(owner),
3741            contents: vec![],
3742            item_instance_id: None,
3743        };
3744        assert_eq!(
3745            state.placed_container_public_label(&chest),
3746            "Medium Wooden Chest"
3747        );
3748        state.character_id = Some(owner);
3749        assert_eq!(
3750            state.placed_container_public_label(&chest),
3751            "Barry's Loot #a3f2"
3752        );
3753    }
3754
3755    #[test]
3756    fn location_context_lists_nearby_resource_node() {
3757        let mut state = sample_state();
3758        state.player = state.entities.first().cloned();
3759        state.resource_nodes[0].x = 128.2;
3760        state.resource_nodes[0].y = 128.0;
3761        let lines = state.location_context_lines();
3762        assert!(
3763            lines.iter().any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
3764            "expected resource node in context: {:?}",
3765            lines
3766        );
3767    }
3768
3769    #[test]
3770    fn inventory_selectable_rows_orders_worn_before_person_before_nearby() {
3771        let mut state = sample_state();
3772        state.worn.insert(
3773            BodySlot::Back,
3774            flatland_protocol::ItemStack {
3775                template_id: "travel_backpack".into(),
3776                quantity: 1,
3777                item_instance_id: Some(uuid::Uuid::from_u128(3)),
3778                props: Default::default(),
3779                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
3780                display_name: None,
3781                category: None,
3782                base_mass: None,
3783                base_volume: None,
3784                capacity_volume: None,
3785                stackable: None,
3786            },
3787        );
3788        state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
3789        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
3790            id: "chest-1".into(),
3791            template_id: "wooden_chest_small".into(),
3792            display_name: "Wooden Chest".into(),
3793            x: 129.0,
3794            y: 128.0,
3795            z: 0.0,
3796            locked: false,
3797            accessible: true,
3798            owner_character_id: None,
3799            contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
3800            item_instance_id: Some(uuid::Uuid::from_u128(4)),
3801        }];
3802
3803        let rows = state.inventory_selectable_rows();
3804        let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
3805        assert_eq!(
3806            sections,
3807            vec![
3808                InventorySection::Worn,   // backpack shell
3809                InventorySection::Worn,   // iron_ore nested in backpack
3810                InventorySection::Person, // lumber
3811                InventorySection::Nearby, // chest shell
3812                InventorySection::Nearby, // wood_axe in chest
3813            ]
3814        );
3815        assert_eq!(rows[0].stack.template_id, "travel_backpack");
3816        assert!(rows[0].is_equip_shell);
3817        assert_eq!(rows[1].stack.template_id, "iron_ore");
3818        assert_eq!(rows[1].depth, 1);
3819        assert_eq!(rows[2].stack.template_id, "lumber");
3820        assert!(rows[3].is_chest_shell);
3821        assert_eq!(rows[4].stack.template_id, "wood_axe");
3822        assert_eq!(rows[4].depth, 1);
3823    }
3824
3825    #[test]
3826    fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
3827        let mut state = sample_state();
3828        let back_id = uuid::Uuid::from_u128(5);
3829        state.worn.insert(
3830            BodySlot::Back,
3831            flatland_protocol::ItemStack {
3832                template_id: "travel_backpack".into(),
3833                quantity: 1,
3834                item_instance_id: Some(back_id),
3835                props: Default::default(),
3836                contents: Vec::new(),
3837                display_name: None,
3838                category: Some("container".into()),
3839                base_mass: None,
3840                base_volume: None,
3841                capacity_volume: Some(80.0),
3842                stackable: None,
3843            },
3844        );
3845        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
3846            id: "chest-1".into(),
3847            template_id: "wooden_chest_small".into(),
3848            display_name: "Wooden Chest".into(),
3849            x: 129.0,
3850            y: 128.0,
3851            z: 0.0,
3852            locked: false,
3853            accessible: true,
3854            owner_character_id: None,
3855            contents: Vec::new(),
3856            item_instance_id: Some(uuid::Uuid::from_u128(6)),
3857        }];
3858
3859        // Item currently sitting loose on the person (Root): backpack + nearby
3860        // chest should both be offered, plus Drop/Cancel, but not "Root" itself.
3861        let opts = state.move_destinations_for(
3862            &flatland_protocol::InventoryLocation::Root,
3863            None,
3864            None,
3865            "lumber",
3866        );
3867        assert!(!opts.iter().any(|o| matches!(
3868            &o.kind,
3869            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
3870        )));
3871        assert!(opts.iter().any(|o| matches!(
3872            &o.kind,
3873            MoveOptionKind::Move { location, parent_instance_id, .. }
3874                if *location == flatland_protocol::InventoryLocation::Worn {
3875                    slot: BodySlot::Back,
3876                } && *parent_instance_id == Some(back_id)
3877        )));
3878        assert!(opts.iter().any(|o| matches!(
3879            &o.kind,
3880            MoveOptionKind::Move { location, .. }
3881                if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
3882        )));
3883        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
3884        assert!(matches!(
3885            opts[opts.len() - 2].kind,
3886            MoveOptionKind::Drop
3887        ));
3888
3889        // Item currently inside the worn backpack: the backpack itself must be
3890        // excluded from its own destination list (can't move an item into the
3891        // container it's already in).
3892        let from_backpack = flatland_protocol::InventoryLocation::Worn {
3893            slot: BodySlot::Back,
3894        };
3895        let opts = state.move_destinations_for(
3896            &from_backpack,
3897            Some(back_id),
3898            None,
3899            "iron_ore",
3900        );
3901        assert!(!opts.iter().any(|o| matches!(
3902            &o.kind,
3903            MoveOptionKind::Move { location, parent_instance_id, .. }
3904                if *location == from_backpack && *parent_instance_id == Some(back_id)
3905        )));
3906        assert!(opts.iter().any(|o| matches!(
3907            &o.kind,
3908            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
3909        )));
3910    }
3911
3912    #[test]
3913    fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
3914        let mut state = sample_state();
3915        // Insert out of display order — BTreeMap iteration must still yield the
3916        // canonical Head/Body/Arms/Legs/Feet/Back/Waist order regardless.
3917        state.worn.insert(
3918            BodySlot::Waist,
3919            flatland_protocol::ItemStack {
3920                template_id: "simple_belt".into(),
3921                quantity: 1,
3922                item_instance_id: Some(uuid::Uuid::from_u128(10)),
3923                props: Default::default(),
3924                contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
3925                display_name: None,
3926                category: Some("container".into()),
3927                base_mass: None,
3928                base_volume: None,
3929                capacity_volume: None,
3930                stackable: None,
3931            },
3932        );
3933        state.worn.insert(
3934            BodySlot::Head,
3935            flatland_protocol::ItemStack {
3936                template_id: "cloth_cap".into(),
3937                quantity: 1,
3938                item_instance_id: Some(uuid::Uuid::from_u128(11)),
3939                props: Default::default(),
3940                contents: Vec::new(),
3941                display_name: None,
3942                category: Some("armor".into()),
3943                base_mass: None,
3944                base_volume: None,
3945                capacity_volume: None,
3946                stackable: None,
3947            },
3948        );
3949        state.worn.insert(
3950            BodySlot::Back,
3951            flatland_protocol::ItemStack {
3952                template_id: "travel_backpack".into(),
3953                quantity: 1,
3954                item_instance_id: Some(uuid::Uuid::from_u128(12)),
3955                props: Default::default(),
3956                contents: Vec::new(),
3957                display_name: None,
3958                category: Some("container".into()),
3959                base_mass: None,
3960                base_volume: None,
3961                capacity_volume: None,
3962                stackable: None,
3963            },
3964        );
3965
3966        let rows = state.worn_rows();
3967        // Head, then Back, then Waist (+ nested pouch) — enum declaration order.
3968        assert_eq!(rows.len(), 4);
3969        assert_eq!(rows[0].stack.template_id, "cloth_cap");
3970        assert!(rows[0].is_equip_shell);
3971        assert_eq!(rows[1].stack.template_id, "travel_backpack");
3972        assert!(rows[1].is_equip_shell);
3973        assert_eq!(rows[2].stack.template_id, "simple_belt");
3974        assert!(rows[2].is_equip_shell);
3975        assert_eq!(rows[3].stack.template_id, "leather_pouch");
3976        assert_eq!(rows[3].depth, 1);
3977        assert!(!rows[3].is_equip_shell);
3978    }
3979
3980    #[test]
3981    fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
3982        let mut state = sample_state();
3983        state.worn.insert(
3984            BodySlot::Waist,
3985            flatland_protocol::ItemStack {
3986                template_id: "simple_belt".into(),
3987                quantity: 1,
3988                item_instance_id: Some(uuid::Uuid::from_u128(20)),
3989                props: Default::default(),
3990                contents: Vec::new(),
3991                display_name: Some("Simple Belt".into()),
3992                category: Some("container".into()),
3993                base_mass: None,
3994                base_volume: None,
3995                capacity_volume: None,
3996                stackable: None,
3997            },
3998        );
3999        state.worn.insert(
4000            BodySlot::Head,
4001            flatland_protocol::ItemStack {
4002                template_id: "cloth_cap".into(),
4003                quantity: 1,
4004                item_instance_id: Some(uuid::Uuid::from_u128(21)),
4005                props: Default::default(),
4006                contents: Vec::new(),
4007                display_name: Some("Cloth Cap".into()),
4008                category: Some("armor".into()),
4009                base_mass: None,
4010                base_volume: None,
4011                capacity_volume: None,
4012                stackable: None,
4013            },
4014        );
4015
4016        let opts = state.move_destinations_for(
4017            &flatland_protocol::InventoryLocation::Root,
4018            None,
4019            None,
4020            "leather_pouch",
4021        );
4022        assert!(
4023            opts.iter().any(|o| matches!(
4024                &o.kind,
4025                MoveOptionKind::Move { location, .. }
4026                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
4027            )),
4028            "belt loop must be offered when moving a pouch"
4029        );
4030        assert!(
4031            !opts.iter().any(|o| matches!(
4032                &o.kind,
4033                MoveOptionKind::Move { location, .. }
4034                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
4035            )),
4036            "armor slots can't hold other items and must not appear as move destinations"
4037        );
4038        let belt_opt = opts
4039            .iter()
4040            .find(|o| matches!(
4041                &o.kind,
4042                MoveOptionKind::Move { location, .. }
4043                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
4044            ))
4045            .unwrap();
4046        assert!(belt_opt.label.contains("belt loop"));
4047
4048        let opts = state.move_destinations_for(
4049            &flatland_protocol::InventoryLocation::Root,
4050            None,
4051            None,
4052            "lumber",
4053        );
4054        assert!(
4055            !opts.iter().any(|o| o.label.contains("belt loop")),
4056            "loose materials must not target the belt shell — only nested pouches"
4057        );
4058    }
4059
4060    #[test]
4061    fn move_destinations_for_offers_dimensional_pouch_on_belt() {
4062        let mut state = sample_state();
4063        let belt_id = uuid::Uuid::from_u128(30);
4064        let pouch_id = uuid::Uuid::from_u128(31);
4065        state.worn.insert(
4066            BodySlot::Waist,
4067            flatland_protocol::ItemStack {
4068                template_id: "simple_belt".into(),
4069                quantity: 1,
4070                item_instance_id: Some(belt_id),
4071                props: Default::default(),
4072                contents: vec![flatland_protocol::ItemStack {
4073                    template_id: "dimensional_pouch".into(),
4074                    quantity: 1,
4075                    item_instance_id: Some(pouch_id),
4076                    props: Default::default(),
4077                    contents: Vec::new(),
4078                    display_name: Some("Dimensional Pouch".into()),
4079                    category: Some("container".into()),
4080                    base_mass: None,
4081                    base_volume: None,
4082                    capacity_volume: Some(200.0),
4083                    stackable: None,
4084                }],
4085                display_name: Some("Simple Belt".into()),
4086                category: Some("container".into()),
4087                base_mass: None,
4088                base_volume: None,
4089                capacity_volume: None,
4090                stackable: None,
4091            },
4092        );
4093
4094        let opts = state.move_destinations_for(
4095            &flatland_protocol::InventoryLocation::Root,
4096            None,
4097            None,
4098            "iron_ore",
4099        );
4100        assert!(
4101            opts.iter().any(|o| matches!(
4102                &o.kind,
4103                MoveOptionKind::Move {
4104                    location,
4105                    parent_instance_id,
4106                    ..
4107                } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
4108                    && *parent_instance_id == Some(pouch_id)
4109            )),
4110            "dimensional pouch clipped on belt must accept loose items"
4111        );
4112        assert!(
4113            opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
4114            "destination label should name the pouch"
4115        );
4116    }
4117}