Skip to main content

flatland_client_lib/
game.rs

1use std::collections::{BTreeMap, VecDeque};
2use std::time::{Duration, Instant};
3
4use flatland_protocol::{
5    AbilityCooldownHud, BlueprintView, BodySlot, BuildingView, CastProgressHud, CombatHud,
6    CombatSlotHud, CombatTargetHud, DoorView, EntityId, EntityState, Intent, InteriorMapView,
7    LifeState, NpcView, RotationPreset, Seq, SessionId, TerrainKindView, TerrainZoneView, Tick,
8    ZPlatformView, ZTransitionView,
9};
10
11use crate::session::{PlayConnection, SessionEvent};
12
13/// Matches `flatland_sim::containers` prop keys (client does not depend on sim).
14const KEY_TEMPLATE: &str = "container_key";
15const PROP_LOCK_ID: &str = "lock_id";
16const PROP_OPENS_LOCK_ID: &str = "opens_lock_id";
17const PROP_OPENS_CONTAINER_NAME: &str = "opens_container_name";
18const PROP_CUSTOM_NAME: &str = "custom_name";
19const PROP_LOCKED: &str = "locked";
20
21fn stack_is_locked(stack: &flatland_protocol::ItemStack) -> bool {
22    stack
23        .props
24        .get(PROP_LOCKED)
25        .is_some_and(|v| v == "true" || v == "1")
26}
27
28const MAX_LOG_LINES: usize = 200;
29const MAX_SHOP_TRADE_LOG_LINES: usize = 40;
30const INTERACTION_RADIUS_M: f32 = 1.5;
31const DOOR_INTERACTION_RADIUS_M: f32 = 3.5;
32const QUEST_BOARD_INTERACTION_RADIUS_M: f32 = 3.0;
33const HARVEST_CLIENT_TIMEOUT: Duration = Duration::from_secs(12);
34/// Matches `assets/config/server-settings.yaml` default for batch-cap UI estimates.
35const CRAFT_STAMINA_COST: f32 = 3.0;
36/// Minimum time the workers-menu `step:` line holds a value before accepting a change.
37const WORKER_STEP_HOLD: Duration = Duration::from_millis(1200);
38
39/// Catalog hints synced from server `ItemStack` wire rows.
40#[derive(Debug, Clone, Default)]
41pub struct InventoryHint {
42    pub display_name: String,
43    pub category: String,
44    pub base_mass: Option<f32>,
45    pub base_volume: Option<f32>,
46    pub capacity_volume: Option<f32>,
47    pub stackable: bool,
48}
49
50/// Rotation editor overlay mode (`plans/26` §C2.5).
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum RotationEditorMode {
53    #[default]
54    List,
55    EditSequence,
56    PickAbility,
57    EditLabel,
58}
59
60/// Local rotation editor UI state (not persisted).
61#[derive(Debug, Clone, Default)]
62pub struct RotationEditorState {
63    pub mode: RotationEditorMode,
64    pub list_index: usize,
65    pub ability_index: usize,
66    pub picker_index: usize,
67    pub draft: Option<RotationPreset>,
68    pub label_buffer: String,
69}
70
71impl RotationEditorState {
72    pub fn reset(&mut self) {
73        *self = Self::default();
74    }
75}
76
77/// Max distance (m) a placed chest can be browsed/moved-into from the inventory
78/// UI. Mirrors `flatland_sim::interaction::CONTAINER_INTERACTION_RADIUS_M` so the
79/// client only ever shows chests the server will actually let you use — this is
80/// what makes a chest disappear from the menu as soon as you walk away.
81pub const CONTAINER_RANGE_M: f32 = 3.0;
82
83/// Broad section of the inventory browser a row belongs to (drives the grouped
84/// "Worn" / "On you" / "Nearby chest" headers in the UI).
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum InventorySection {
87    /// Inside a worn body-slot item (backpack, belt w/ clipped pouches, armor).
88    Worn,
89    /// Loose on your person — not worn, not inside a placed chest.
90    Person,
91    /// Inside a placed chest within reach.
92    Nearby,
93}
94
95/// Short display label for a body slot (`plans/08` §4.1) — shared by the inventory
96/// browser section headers and the move-destination picker.
97pub fn body_slot_label(slot: BodySlot) -> &'static str {
98    match slot {
99        BodySlot::Head => "Head",
100        BodySlot::Body => "Body",
101        BodySlot::Arms => "Arms",
102        BodySlot::Legs => "Legs",
103        BodySlot::Feet => "Feet",
104        BodySlot::Back => "Back",
105        BodySlot::Waist => "Waist",
106    }
107}
108
109/// Client-side naming heuristic for "Enter equips this" — the client doesn't sync the
110/// item catalog's `equip_slot`, so this guesses from `template_id` the same way the
111/// pre-generalization code already guessed backpack vs. pouch. Pouches deliberately
112/// aren't covered — they attach to belt loops via the move picker instead of equipping
113/// directly (`plans/08` §4.1).
114fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
115    if template_id.contains("backpack") {
116        Some(BodySlot::Back)
117    } else if template_id.contains("belt") {
118        Some(BodySlot::Waist)
119    } else if template_id.contains("cap")
120        || template_id.contains("hat")
121        || template_id.contains("helm")
122    {
123        Some(BodySlot::Head)
124    } else if template_id.contains("shirt")
125        || template_id.contains("robe")
126        || template_id.contains("vest")
127    {
128        Some(BodySlot::Body)
129    } else if template_id.contains("sleeves")
130        || template_id.contains("gloves")
131        || template_id.contains("gauntlets")
132    {
133        Some(BodySlot::Arms)
134    } else if template_id.contains("pants") || template_id.contains("leggings") {
135        Some(BodySlot::Legs)
136    } else if template_id.contains("boots") || template_id.contains("shoes") {
137        Some(BodySlot::Feet)
138    } else {
139        None
140    }
141}
142
143/// One row in the inventory browser tree.
144#[derive(Debug, Clone)]
145pub struct InventoryRow {
146    pub depth: usize,
147    pub stack: flatland_protocol::ItemStack,
148    /// `MoveItem` source location for this stack.
149    pub from: flatland_protocol::InventoryLocation,
150    /// Parent container instance when nested (belt shell, backpack, chest, pouch).
151    pub from_parent_instance_id: Option<uuid::Uuid>,
152    /// Equipped bag/chest shell — unequip via Enter instead of the move picker.
153    pub is_equip_shell: bool,
154    /// Placed world chest shell — lock/unlock via Enter or `l`.
155    pub is_chest_shell: bool,
156    pub section: InventorySection,
157}
158
159/// Formatted inventory row text shared by TUI and gfx browsers.
160#[derive(Debug, Clone)]
161pub struct InventoryRowView {
162    pub depth: usize,
163    /// Dense single-line label (legacy / TUI).
164    pub text: String,
165    /// Primary label for redesigned gfx rows (name, qty, optional #id / slot).
166    pub title: String,
167    pub mass_kg: Option<f32>,
168    pub volume: Option<(f32, f32)>,
169}
170
171/// One line in the sectioned inventory browser (headers are non-selectable).
172#[derive(Debug, Clone)]
173pub enum InventoryBrowserLine {
174    Section(String),
175    SlotLabel(String),
176    Hint(String),
177    Blank,
178    Item {
179        selectable_index: usize,
180        selected: bool,
181        depth: usize,
182        text: String,
183        title: String,
184        mass_kg: Option<f32>,
185        volume: Option<(f32, f32)>,
186    },
187}
188
189/// A placed chest within `CONTAINER_RANGE_M`, with its contents pre-flattened for
190/// the browser (empty when locked without the matching key).
191#[derive(Debug, Clone)]
192pub struct NearbyContainer {
193    pub view: flatland_protocol::PlacedContainerView,
194    pub distance_m: f32,
195    pub rows: Vec<InventoryRow>,
196}
197
198/// One key row in the keychain overlay (carried vs stowed).
199#[derive(Debug, Clone)]
200pub struct KeychainEntry {
201    pub stack: flatland_protocol::ItemStack,
202    pub stowed: bool,
203}
204
205/// A destination the currently-picked item could be moved to.
206#[derive(Debug, Clone)]
207pub struct MoveOption {
208    pub label: String,
209    pub kind: MoveOptionKind,
210}
211
212#[derive(Debug, Clone, PartialEq)]
213pub enum MoveOptionKind {
214    Move {
215        location: flatland_protocol::InventoryLocation,
216        parent_instance_id: Option<uuid::Uuid>,
217    },
218    /// Pick up a placed chest/crate; optionally nest into a worn bag afterward.
219    PickupPlaced {
220        container_id: String,
221        nest_location: flatland_protocol::InventoryLocation,
222        nest_parent_instance_id: Option<uuid::Uuid>,
223    },
224    /// Eat/drink a loose consumable (one unit per use).
225    Use,
226    Drop,
227    Cancel,
228}
229
230/// Active "move to…" destination picker state for the selected inventory item.
231#[derive(Debug, Clone)]
232pub struct MovePicker {
233    pub item_instance_id: uuid::Uuid,
234    pub from: flatland_protocol::InventoryLocation,
235    pub item_label: String,
236    pub template_id: String,
237    pub stack_quantity: u32,
238    pub quantity: u32,
239    pub options: Vec<MoveOption>,
240}
241
242/// Active permanent-delete picker for the selected inventory item.
243#[derive(Debug, Clone)]
244pub struct DestroyPicker {
245    pub item_instance_id: uuid::Uuid,
246    pub from: flatland_protocol::InventoryLocation,
247    pub item_label: String,
248    pub stack_quantity: u32,
249    pub quantity: u32,
250}
251
252/// One giveable stack in the workers-menu give picker.
253#[derive(Debug, Clone)]
254pub struct WorkerGiveOption {
255    pub item_instance_id: uuid::Uuid,
256    pub label: String,
257    pub quantity: u32,
258    pub template_id: String,
259}
260
261/// Give an on-person inventory stack to the selected hired worker.
262#[derive(Debug, Clone)]
263pub struct WorkerGivePicker {
264    pub worker_instance_id: String,
265    pub worker_label: String,
266    pub options: Vec<WorkerGiveOption>,
267}
268
269/// Nearby hired worker choice when giving a selected inventory stack (`g`).
270#[derive(Debug, Clone)]
271pub struct WorkerGiveTargetOption {
272    pub instance_id: String,
273    pub label: String,
274    pub distance_m: f32,
275}
276
277/// Pick which nearby worker receives the selected inventory item.
278#[derive(Debug, Clone)]
279pub struct WorkerGiveTargetPicker {
280    pub item_instance_id: uuid::Uuid,
281    pub item_label: String,
282    pub quantity: Option<u32>,
283    pub options: Vec<WorkerGiveTargetOption>,
284}
285
286/// Take an item from a hired worker back into the employer's inventory.
287#[derive(Debug, Clone)]
288pub struct WorkerTakePicker {
289    pub worker_instance_id: String,
290    pub worker_label: String,
291    pub options: Vec<WorkerGiveOption>,
292}
293
294/// Max distance (m) to hand an item to a hired worker.
295pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
296
297/// One teachable blueprint in the workers-menu teach picker.
298#[derive(Debug, Clone)]
299pub struct WorkerTeachOption {
300    pub blueprint_id: String,
301    pub label: String,
302    pub cost_copper: u64,
303    pub min_level: u32,
304    pub worker_level: u32,
305    pub can_afford: bool,
306    pub level_ok: bool,
307}
308
309/// Teach a known blueprint to the selected hired worker.
310#[derive(Debug, Clone)]
311pub struct WorkerTeachPicker {
312    pub worker_instance_id: String,
313    pub worker_label: String,
314    pub worker_level: u32,
315    pub options: Vec<WorkerTeachOption>,
316}
317
318/// Sticky workers-menu step line — holds a coarse label so travel/harvest ticks
319/// do not thrash the UI.
320#[derive(Debug, Clone, Default)]
321pub struct StickyWorkerStep {
322    shown: String,
323    pending: String,
324    pending_since: Option<Instant>,
325}
326
327impl StickyWorkerStep {
328    fn from_label(label: String) -> Self {
329        Self {
330            shown: label.clone(),
331            pending: label,
332            pending_since: Some(Instant::now()),
333        }
334    }
335
336    fn observe(&mut self, label: &str, now: Instant) {
337        let pending_since = self.pending_since.unwrap_or(now);
338        if label == self.pending {
339            if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD
340            {
341                self.shown = self.pending.clone();
342            }
343            return;
344        }
345        self.pending = label.to_string();
346        self.pending_since = Some(now);
347        // Empty → first value, or first observation: show immediately.
348        if self.shown.is_empty() {
349            self.shown = self.pending.clone();
350        }
351    }
352}
353
354/// True when a worker `last_error` is transient noise (path retries / soft-skip).
355pub fn worker_error_is_transient(err: &str) -> bool {
356    let e = err.to_ascii_lowercase();
357    e.contains("continuing route")
358        || e.contains("no path")
359        || e.contains("storage full")
360        || e.starts_with("nothing to withdraw")
361}
362
363/// In-flight `SetWorkerJob` waiting for IntentAck (or a reject Interaction).
364#[derive(Debug, Clone)]
365pub struct PendingWorkerJobAck {
366    pub seq: u32,
367    pub worker_instance_id: String,
368    pub worker_label: String,
369    pub idle: bool,
370    pub stop_count: usize,
371    pub prev_route: Option<flatland_protocol::WorkerRouteView>,
372    pub prev_mode: flatland_protocol::WorkerModeView,
373    pub prev_step_label: String,
374    pub prev_last_error: Option<String>,
375}
376
377fn push_inventory_rows(
378    rows: &mut Vec<InventoryRow>,
379    depth: usize,
380    stack: &flatland_protocol::ItemStack,
381    from: &flatland_protocol::InventoryLocation,
382    from_parent_instance_id: Option<uuid::Uuid>,
383    section: InventorySection,
384) {
385    rows.push(InventoryRow {
386        depth,
387        stack: stack.clone(),
388        from: from.clone(),
389        from_parent_instance_id,
390        is_equip_shell: false,
391        is_chest_shell: false,
392        section,
393    });
394    for child in &stack.contents {
395        push_inventory_rows(
396            rows,
397            depth + 1,
398            child,
399            from,
400            stack.item_instance_id,
401            section,
402        );
403    }
404}
405
406#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
407pub enum ShopTab {
408    #[default]
409    Buy,
410    Sell,
411}
412
413#[derive(Debug, Clone)]
414pub struct NpcChatState {
415    pub npc_id: String,
416    pub npc_label: String,
417    pub lines: Vec<String>,
418    pub input: String,
419    pub pending: bool,
420    pub talk_depth: flatland_protocol::NpcTalkDepth,
421    pub trade_allowed: bool,
422    pub banner: Option<String>,
423}
424
425impl Default for NpcChatState {
426    fn default() -> Self {
427        Self {
428            npc_id: String::new(),
429            npc_label: String::new(),
430            lines: Vec::new(),
431            input: String::new(),
432            pending: false,
433            talk_depth: flatland_protocol::NpcTalkDepth::Full,
434            trade_allowed: true,
435            banner: None,
436        }
437    }
438}
439
440#[derive(Debug, Clone)]
441pub struct GameState {
442    pub session_id: SessionId,
443    pub entity_id: EntityId,
444    /// Logged-in character — used to show owner-only container labels.
445    pub character_id: Option<uuid::Uuid>,
446    pub tick: Tick,
447    pub chunk_rev: u64,
448    pub content_rev: u64,
449    pub publish_rev: u64,
450    pub entities: Vec<EntityState>,
451    pub player: Option<EntityState>,
452    pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
453    pub ground_drops: Vec<flatland_protocol::GroundDropView>,
454    pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
455    pub buildings: Vec<BuildingView>,
456    pub doors: Vec<DoorView>,
457    pub interior_map: Option<InteriorMapView>,
458    pub npcs: Vec<NpcView>,
459    pub blueprints: Vec<BlueprintView>,
460    pub world_width_m: f32,
461    pub world_height_m: f32,
462    pub terrain_zones: Vec<TerrainZoneView>,
463    pub z_platforms: Vec<ZPlatformView>,
464    pub z_transitions: Vec<ZTransitionView>,
465    pub world_clock: flatland_protocol::WorldClock,
466    pub inventory: std::collections::HashMap<String, u32>,
467    pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
468    pub logs: VecDeque<String>,
469    pub intents_sent: u64,
470    pub ticks_received: u64,
471    pub connected: bool,
472    pub disconnect_reason: Option<String>,
473    pub show_stats: bool,
474    pub show_craft_menu: bool,
475    pub craft_menu_index: usize,
476    /// How many timed crafts to queue when confirming the craft menu.
477    pub craft_batch_quantity: u32,
478    pub show_shop_menu: bool,
479    pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
480    pub shop_tab: ShopTab,
481    pub shop_menu_index: usize,
482    pub shop_quantity: u32,
483    /// Recent buy/sell lines while the shop panel is open (gfx dock).
484    pub shop_trade_log: VecDeque<String>,
485    pub show_npc_verb_menu: bool,
486    pub npc_verb_target: Option<String>,
487    pub npc_verb_index: usize,
488    pub show_npc_chat: bool,
489    pub npc_chat: Option<NpcChatState>,
490    pub show_inventory_menu: bool,
491    pub inventory_menu_index: usize,
492    pub show_move_picker: bool,
493    pub move_picker_index: usize,
494    pub move_picker: Option<MovePicker>,
495    pub show_destroy_picker: bool,
496    pub destroy_confirm_pending: bool,
497    pub destroy_picker: Option<DestroyPicker>,
498    /// Rename prompt for a selected container (`n` in inventory).
499    pub show_rename_prompt: bool,
500    /// Rename prompt for a hired worker (`n` in workers menu).
501    pub show_worker_rename: bool,
502    pub rename_buffer: String,
503    /// Slot-1 combat target (mirrors server after SetTarget).
504    pub combat_target: Option<EntityId>,
505    pub combat_target_label: Option<String>,
506    pub in_combat: bool,
507    pub auto_attack: bool,
508    pub combat_has_los: bool,
509    pub attack_cd_ticks: u64,
510    pub gcd_ticks: u64,
511    pub weapon_ability_id: String,
512    pub mainhand_template_id: Option<String>,
513    pub mainhand_label: Option<String>,
514    /// Worn body-slot items — backpack (`Back`), belt w/ clipped pouches (`Waist`), and
515    /// future armor. At most one item per slot (`plans/08` §4.1).
516    pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
517    pub carry_mass: f32,
518    pub carry_mass_max: f32,
519    pub encumbrance: flatland_protocol::EncumbranceState,
520    /// Full nested inventory stacks from the server (root only; worn are separate).
521    pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
522    /// Keys stowed on the virtual keychain (zero carry mass).
523    pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
524    pub combat_target_detail: Option<CombatTargetHud>,
525    pub cast_progress: Option<CastProgressHud>,
526    pub ability_cooldowns: Vec<AbilityCooldownHud>,
527    pub blocking_active: bool,
528    pub max_target_slots: u8,
529    pub combat_slots: Vec<CombatSlotHud>,
530    pub rotation_presets: Vec<RotationPreset>,
531    pub show_loadout_menu: bool,
532    pub show_keychain_menu: bool,
533    pub keychain_menu_index: usize,
534    pub show_rotation_editor: bool,
535    pub loadout_menu_index: usize,
536    pub rotation_editor: RotationEditorState,
537    /// True after a harvest intent is accepted until result/reject/disconnect.
538    pub harvest_in_progress: bool,
539    /// Wall-clock start of the current harvest; clears stale client state on timeout.
540    pub harvest_started_at: Option<Instant>,
541    /// Craft log deferred until the server acks the craft intent.
542    pub pending_craft_ack: Option<(u32, String, u32)>,
543    pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
544    pub interactables: Vec<flatland_protocol::InteractableView>,
545    pub show_quest_offer: bool,
546    pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
547    pub show_quest_menu: bool,
548    pub quest_menu_index: usize,
549    pub quest_withdraw_confirm: bool,
550    pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
551    pub show_workers_menu: bool,
552    pub workers_menu_index: usize,
553    /// Workers panel: one-line rows instead of full cards (more on screen).
554    pub workers_menu_compact: bool,
555    /// Coarse `step:` line held per worker so AOI ticks cannot thrash the menu.
556    /// Public so out-of-crate tests can construct [`GameState`].
557    pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
558    /// Give-item sheet opened from the workers menu (`g`) — pick which item to give.
559    pub show_worker_give_picker: bool,
560    pub worker_give_picker_index: usize,
561    pub worker_give_picker: Option<WorkerGivePicker>,
562    /// Inventory `g` — pick which nearby worker receives the selected stack.
563    pub show_worker_give_target_picker: bool,
564    pub worker_give_target_picker_index: usize,
565    pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
566    /// Take-item sheet opened from the workers menu (`i`) — pick which worker stack to take.
567    pub show_worker_take_picker: bool,
568    pub worker_take_picker_index: usize,
569    pub worker_take_picker: Option<WorkerTakePicker>,
570    /// Teach-blueprint sheet opened from the workers menu (`t`).
571    pub show_worker_teach_picker: bool,
572    pub worker_teach_picker_index: usize,
573    pub worker_teach_picker: Option<WorkerTeachPicker>,
574    /// Active harvest-route editor (`h` → `e` on a worker).
575    pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
576    /// Awaiting IntentAck for the last route save (`SetWorkerJob`).
577    pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
578    /// Server progression curve from the latest combat HUD (matches server-settings.yaml).
579    pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
580}
581
582impl GameState {
583    pub fn push_log(&mut self, line: impl Into<String>) {
584        self.logs.push_back(line.into());
585        while self.logs.len() > MAX_LOG_LINES {
586            self.logs.pop_front();
587        }
588    }
589
590    pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
591        self.shop_trade_log.push_back(line.into());
592        while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
593            self.shop_trade_log.pop_front();
594        }
595    }
596
597    pub fn clear_shop_trade_log(&mut self) {
598        self.shop_trade_log.clear();
599    }
600
601    fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
602        if !self.show_shop_menu {
603            return;
604        }
605        let msg = notice.message.trim();
606        if msg.is_empty() {
607            return;
608        }
609        if notice.coins_delta != 0
610            || msg.starts_with("Bought ")
611            || msg.starts_with("Sold ")
612            || msg.contains("taught you how to craft")
613            || msg.starts_with("need ")
614        {
615            self.push_shop_trade_log(msg);
616        }
617    }
618
619    pub fn is_alive(&self) -> bool {
620        self.player
621            .as_ref()
622            .and_then(|p| p.vitals)
623            .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
624            .unwrap_or(true)
625    }
626
627    /// Verb menu entries for the current `npc_verb_target` (Talk, and Trade when applicable).
628    pub fn npc_verb_options(&self) -> Vec<&'static str> {
629        let Some(ref id) = self.npc_verb_target else {
630            return vec![];
631        };
632        let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
633            return vec!["Talk"];
634        };
635        if npc.can_trade || Self::npc_role_can_trade(npc.role.as_str()) {
636            vec!["Talk", "Trade"]
637        } else {
638            vec!["Talk"]
639        }
640    }
641
642    fn npc_role_can_trade(role: &str) -> bool {
643        matches!(role, "broker" | "cook" | "farmer" | "merchant")
644    }
645
646    pub fn clear_harvest_state(&mut self) {
647        self.harvest_in_progress = false;
648        self.harvest_started_at = None;
649    }
650
651    fn harvest_state_stale(&self) -> bool {
652        match self.harvest_started_at {
653            Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
654            None => self.harvest_in_progress,
655        }
656    }
657
658    pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
659        self.player.as_ref().and_then(|p| p.vitals)
660    }
661
662    pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
663        let materials_ok = blueprint.inputs.iter().all(|input| {
664            self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
665        });
666        let tools_ok = blueprint
667            .required_tools
668            .iter()
669            .all(|tool| self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1);
670        let station_ok = match blueprint.station.as_deref() {
671            None | Some("hand") => true,
672            Some(tag) => self.player_at_station_tag(tag),
673        };
674        materials_ok && tools_ok && station_ok
675    }
676
677    pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
678        if !self.can_craft_blueprint(blueprint) {
679            return 0;
680        }
681        let mut limit = u32::MAX;
682        for input in &blueprint.inputs {
683            if input.quantity == 0 {
684                continue;
685            }
686            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
687            limit = limit.min(have / input.quantity);
688        }
689        for tool in &blueprint.required_tools {
690            if tool.consumed {
691                let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
692                limit = limit.min(have);
693            }
694        }
695        let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
696        if CRAFT_STAMINA_COST > 0.0 {
697            limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
698        }
699        limit
700    }
701
702    pub fn clamp_craft_batch_quantity(&mut self) {
703        let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
704            self.craft_batch_quantity = 1;
705            return;
706        };
707        let max = self.max_craft_batches(bp).max(1);
708        self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
709    }
710
711    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
712        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
713            return;
714        };
715        let max = self.max_craft_batches(&bp).max(1);
716        let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
717        self.craft_batch_quantity = next as u32;
718    }
719
720    pub fn craft_batch_set_max(&mut self) {
721        let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
722            return;
723        };
724        let max = self.max_craft_batches(&bp);
725        self.craft_batch_quantity = if max == 0 { 1 } else { max };
726    }
727
728    pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
729        let preserve_ui = self.show_shop_menu;
730        let tab = self.shop_tab;
731        let index = self.shop_menu_index;
732        let qty = self.shop_quantity;
733
734        self.show_shop_menu = true;
735        self.show_craft_menu = false;
736        self.show_inventory_menu = false;
737        self.show_stats = false;
738        if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
739            self.npc_verb_target = Some(catalog.npc_id.clone());
740        }
741        self.shop_catalog = Some(catalog);
742
743        if preserve_ui {
744            self.shop_tab = tab;
745            self.shop_menu_index = index;
746            self.shop_quantity = qty;
747        } else {
748            self.shop_tab = ShopTab::Buy;
749            self.shop_menu_index = 0;
750            self.shop_quantity = 1;
751            self.clear_shop_trade_log();
752        }
753        self.show_npc_verb_menu = false;
754        self.clamp_shop_selection();
755    }
756
757    pub fn shop_list_len(&self) -> usize {
758        let Some(catalog) = &self.shop_catalog else {
759            return 0;
760        };
761        match self.shop_tab {
762            ShopTab::Buy => catalog.sells.len(),
763            ShopTab::Sell => catalog.buys.len(),
764        }
765    }
766
767    pub fn shop_menu_move(&mut self, delta: i32) {
768        let n = self.shop_list_len();
769        if n == 0 {
770            return;
771        }
772        let idx = self.shop_menu_index as i32;
773        let next = (idx + delta).rem_euclid(n as i32);
774        self.shop_menu_index = next as usize;
775        self.clamp_shop_quantity();
776    }
777
778    pub fn shop_quantity_adjust(&mut self, delta: i32) {
779        let max = self.shop_quantity_max();
780        if max == 0 {
781            self.shop_quantity = 0;
782            return;
783        }
784        let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
785        self.shop_quantity = next as u32;
786    }
787
788    pub(crate) fn clamp_shop_selection(&mut self) {
789        let n = self.shop_list_len();
790        if n == 0 {
791            self.shop_menu_index = 0;
792        } else {
793            self.shop_menu_index = self.shop_menu_index.min(n - 1);
794        }
795        self.clamp_shop_quantity();
796    }
797
798    fn shop_quantity_max(&self) -> u32 {
799        let Some(catalog) = &self.shop_catalog else {
800            return 1;
801        };
802        match self.shop_tab {
803            ShopTab::Buy => {
804                if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
805                    if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
806                        return 1;
807                    }
808                }
809                99
810            }
811            ShopTab::Sell => catalog
812                .buys
813                .get(self.shop_menu_index)
814                .map(|l| l.quantity)
815                .unwrap_or(0),
816        }
817    }
818
819    pub fn shop_quantity_set_max(&mut self) {
820        self.shop_quantity = self.shop_quantity_max();
821    }
822
823    fn clamp_shop_quantity(&mut self) {
824        let max = self.shop_quantity_max();
825        if max == 0 {
826            self.shop_quantity = 0;
827        } else {
828            self.shop_quantity = self.shop_quantity.max(1).min(max);
829        }
830    }
831
832    pub fn player_at_station_tag(&self, tag: &str) -> bool {
833        let Some(id) = self.effective_inside_building() else {
834            return false;
835        };
836        self.buildings
837            .iter()
838            .find(|b| b.id == id)
839            .is_some_and(|b| b.tags.iter().any(|t| t == tag))
840    }
841
842    /// Short hint for UI when a recipe cannot be started.
843    pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
844        if self.can_craft_blueprint(blueprint) {
845            return None;
846        }
847        let mut missing = Vec::new();
848        for input in &blueprint.inputs {
849            let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
850            if have < input.quantity {
851                missing.push(format!(
852                    "{}×{} (have {have})",
853                    input.quantity, input.template_id
854                ));
855            }
856        }
857        for tool in &blueprint.required_tools {
858            let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
859            if have < 1 {
860                missing.push(format!("tool: {}", tool.item));
861            }
862        }
863        if let Some(station) = blueprint.station.as_deref() {
864            if station != "hand" && !self.player_at_station_tag(station) {
865                missing.push(format!("station: {station} (enter building)"));
866            }
867        }
868        if missing.is_empty() {
869            None
870        } else {
871            Some(missing.join(", "))
872        }
873    }
874
875    pub fn player_entity(&self) -> Option<&EntityState> {
876        self.player
877            .as_ref()
878            .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
879    }
880
881    pub fn player_position(&self) -> (f32, f32) {
882        let (x, y, _) = self.player_position_with_z();
883        (x, y)
884    }
885
886    pub fn player_position_with_z(&self) -> (f32, f32, f32) {
887        if let Some(p) = self.player_entity() {
888            (
889                p.transform.position.x,
890                p.transform.position.y,
891                p.transform.position.z,
892            )
893        } else {
894            (0.0, 0.0, 0.0)
895        }
896    }
897
898    pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
899        let mut rows: Vec<(String, u32, String)> = self
900            .inventory
901            .iter()
902            .filter(|(_, q)| **q > 0)
903            .map(|(id, qty)| {
904                let label = self
905                    .inventory_hints
906                    .get(id)
907                    .map(|h| h.display_name.clone())
908                    .unwrap_or_else(|| id.clone());
909                (id.clone(), *qty, label)
910            })
911            .collect();
912        rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
913        rows
914    }
915
916    pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
917        self.inventory_hints
918            .get(template_id)
919            .map(|h| h.category.as_str())
920            .filter(|c| !c.is_empty())
921    }
922
923    pub fn item_base_mass(&self, template_id: &str) -> f32 {
924        self.inventory_hints
925            .get(template_id)
926            .and_then(|h| h.base_mass)
927            .unwrap_or(0.5)
928    }
929
930    pub fn item_base_volume(&self, template_id: &str) -> f32 {
931        self.inventory_hints
932            .get(template_id)
933            .and_then(|h| h.base_volume)
934            .unwrap_or(1.0)
935    }
936
937    pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
938        let unit = stack
939            .base_mass
940            .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
941        unit * stack.quantity as f32
942    }
943
944    fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
945        let unit = stack.base_volume.unwrap_or(1.0);
946        unit * stack.quantity as f32
947            + stack
948                .contents
949                .iter()
950                .map(Self::stack_tree_volume)
951                .sum::<f32>()
952    }
953
954    fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
955        contents.iter().map(Self::stack_tree_volume).sum()
956    }
957
958    fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
959        self.inventory_hints
960            .get(template_id)
961            .and_then(|h| h.capacity_volume)
962            .filter(|c| *c > 0.0)
963    }
964
965    fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
966        stack
967            .capacity_volume
968            .filter(|c| *c > 0.0)
969            .or_else(|| self.template_capacity_volume(&stack.template_id))
970    }
971
972    /// Volume used / capacity / free space label for storage containers in the inventory UI.
973    pub fn container_volume_label(&self, row: &InventoryRow) -> String {
974        let Some((used, cap)) = self.container_volume_stats(row) else {
975            return String::new();
976        };
977        let free = (cap - used).max(0.0);
978        format!("  vol {used:.0}/{cap:.0} ({free:.0} free)")
979    }
980
981    fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
982        if row.is_chest_shell {
983            let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
984                return None;
985            };
986            let chest = self
987                .placed_containers
988                .iter()
989                .find(|c| c.id == *container_id)?;
990            let cap = self
991                .stack_capacity_volume(&row.stack)
992                .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
993            let used = if chest.accessible {
994                Self::contents_used_volume(&chest.contents)
995            } else {
996                0.0
997            };
998            return Some((used, cap));
999        }
1000
1001        let cap = self.stack_capacity_volume(&row.stack)?;
1002        let used = Self::contents_used_volume(&row.stack.contents);
1003        Some((used, cap))
1004    }
1005
1006    pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
1007        if row.is_chest_shell {
1008            return true;
1009        }
1010        if row.is_equip_shell {
1011            return self.inventory_item_category(&row.stack.template_id) == Some("container");
1012        }
1013        self.inventory_item_category(&row.stack.template_id) == Some("container")
1014            || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
1015    }
1016
1017    fn container_stack_for(
1018        &self,
1019        location: &flatland_protocol::InventoryLocation,
1020        parent_instance_id: Option<uuid::Uuid>,
1021    ) -> Option<flatland_protocol::ItemStack> {
1022        match location {
1023            flatland_protocol::InventoryLocation::Root => {
1024                let pid = parent_instance_id?;
1025                self.find_stack_by_instance(&self.inventory_stacks, pid)
1026            }
1027            flatland_protocol::InventoryLocation::Worn { slot } => {
1028                let worn = self.worn.get(slot)?;
1029                if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
1030                    Some(worn.clone())
1031                } else {
1032                    self.find_stack_by_instance(&worn.contents, parent_instance_id?)
1033                }
1034            }
1035            flatland_protocol::InventoryLocation::Placed { container_id } => {
1036                let chest = self
1037                    .placed_containers
1038                    .iter()
1039                    .find(|c| c.id == *container_id)?;
1040                if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
1041                    Some(flatland_protocol::ItemStack {
1042                        template_id: chest.template_id.clone(),
1043                        quantity: 1,
1044                        item_instance_id: chest.item_instance_id,
1045                        props: Default::default(),
1046                        contents: chest.contents.clone(),
1047                        display_name: Some(chest.display_name.clone()),
1048                        category: Some("container".into()),
1049                        base_mass: None,
1050                        base_volume: None,
1051                        capacity_volume: self
1052                            .inventory_hints
1053                            .get(&chest.template_id)
1054                            .and_then(|h| h.capacity_volume),
1055                        stackable: None,
1056                        world_placeable: None,
1057                        worker_lodging_capacity: chest.worker_lodging_capacity,
1058                    })
1059                } else {
1060                    self.find_stack_by_instance(&chest.contents, parent_instance_id?)
1061                }
1062            }
1063            flatland_protocol::InventoryLocation::Keychain => None,
1064        }
1065    }
1066
1067    fn find_stack_by_instance(
1068        &self,
1069        stacks: &[flatland_protocol::ItemStack],
1070        instance_id: uuid::Uuid,
1071    ) -> Option<flatland_protocol::ItemStack> {
1072        for stack in stacks {
1073            if stack.item_instance_id == Some(instance_id) {
1074                return Some(stack.clone());
1075            }
1076            if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
1077                return Some(found);
1078            }
1079        }
1080        None
1081    }
1082
1083    /// Client-side estimate of how many units can move to `to` (server clamps authoritatively).
1084    pub fn max_movable_to(
1085        &self,
1086        template_id: &str,
1087        stack_qty: u32,
1088        from: &flatland_protocol::InventoryLocation,
1089        to: &flatland_protocol::InventoryLocation,
1090        parent_instance_id: Option<uuid::Uuid>,
1091    ) -> u32 {
1092        let unit_vol = self.item_base_volume(template_id);
1093        let unit_mass = self.item_base_mass(template_id);
1094        let mut limit = stack_qty;
1095
1096        if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
1097            let cap = parent
1098                .capacity_volume
1099                .or_else(|| {
1100                    self.inventory_hints
1101                        .get(&parent.template_id)
1102                        .and_then(|h| h.capacity_volume)
1103                })
1104                .unwrap_or(0.0);
1105            if cap > 0.0 && unit_vol > 0.0 {
1106                let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
1107                limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
1108            }
1109        }
1110
1111        let to_person = matches!(
1112            to,
1113            flatland_protocol::InventoryLocation::Root
1114                | flatland_protocol::InventoryLocation::Worn { .. }
1115        );
1116        let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
1117        if to_person && from_placed && unit_mass > 0.0 {
1118            let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
1119            if self.encumbrance == flatland_protocol::EncumbranceState::Over {
1120                limit = 0;
1121            } else {
1122                limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
1123            }
1124        }
1125
1126        limit.max(0).min(stack_qty)
1127    }
1128
1129    pub fn move_picker_max_at_selection(&self) -> u32 {
1130        let Some(picker) = &self.move_picker else {
1131            return 1;
1132        };
1133        let Some(opt) = picker.options.get(self.move_picker_index) else {
1134            return picker.stack_quantity;
1135        };
1136        match &opt.kind {
1137            MoveOptionKind::Cancel
1138            | MoveOptionKind::Drop
1139            | MoveOptionKind::Use
1140            | MoveOptionKind::PickupPlaced { .. } => picker.stack_quantity,
1141            MoveOptionKind::Move {
1142                location,
1143                parent_instance_id,
1144            } => self.max_movable_to(
1145                &picker.template_id,
1146                picker.stack_quantity,
1147                &picker.from,
1148                location,
1149                *parent_instance_id,
1150            ),
1151        }
1152    }
1153
1154    pub fn clamp_move_picker_quantity(&mut self) {
1155        let max = self.move_picker_max_at_selection();
1156        if let Some(picker) = &mut self.move_picker {
1157            if max == 0 {
1158                picker.quantity = 1;
1159            } else {
1160                picker.quantity = picker.quantity.clamp(1, max);
1161            }
1162        }
1163    }
1164
1165    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
1166        let max = self.move_picker_max_at_selection().max(1);
1167        if let Some(picker) = &mut self.move_picker {
1168            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
1169            picker.quantity = next as u32;
1170        }
1171    }
1172
1173    pub fn move_picker_set_quantity_max(&mut self) {
1174        let max = self.move_picker_max_at_selection();
1175        if let Some(picker) = &mut self.move_picker {
1176            picker.quantity = if max == 0 {
1177                1
1178            } else {
1179                max.min(picker.stack_quantity)
1180            };
1181        }
1182    }
1183
1184    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
1185        if let Some(picker) = &mut self.destroy_picker {
1186            let max = picker.stack_quantity.max(1);
1187            let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
1188            picker.quantity = next as u32;
1189        }
1190    }
1191
1192    pub fn destroy_picker_set_quantity_max(&mut self) {
1193        if let Some(picker) = &mut self.destroy_picker {
1194            picker.quantity = picker.stack_quantity.max(1);
1195        }
1196    }
1197
1198    pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
1199        let have = self.inventory.get(template_id).copied().unwrap_or(0);
1200        (have, have >= need)
1201    }
1202
1203    pub fn currency_display(&self) -> String {
1204        crate::currency::currency_line(&self.inventory)
1205    }
1206
1207    /// True when standing in a shallow-water terrain zone from the segment snapshot.
1208    pub fn in_shallow_water(&self) -> bool {
1209        let (px, py) = self.player_position();
1210        self.terrain_at(px, py)
1211            .is_some_and(|k| k == TerrainKindView::ShallowWater)
1212    }
1213
1214    pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
1215        self.terrain_zone_at(x, y).map(|z| z.kind)
1216    }
1217
1218    /// First terrain zone containing `(x, y)` — highest `z_order` wins.
1219    pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
1220        self.terrain_zones
1221            .iter()
1222            .enumerate()
1223            .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
1224            .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
1225            .map(|(_, z)| z)
1226    }
1227
1228    /// Ground elevation from terrain zones (m).
1229    pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
1230        self.terrain_zone_at(x, y)
1231            .map(|z| z.elevation)
1232            .unwrap_or(0.0)
1233    }
1234
1235    /// Walkable z levels at a map column (terrain + platforms).
1236    pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
1237        const TOL: f32 = 0.35;
1238        let mut levels = vec![self.elevation_at(x, y)];
1239        for p in &self.z_platforms {
1240            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
1241                levels.push(p.z);
1242            }
1243        }
1244        levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
1245        levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
1246        levels
1247    }
1248
1249    pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
1250        const TOL: f32 = 0.35;
1251        self.walkable_levels_at(x, y)
1252            .iter()
1253            .any(|&l| (l - z).abs() <= TOL)
1254    }
1255
1256    pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
1257        let mut top = self.elevation_at(x, y);
1258        for p in &self.z_platforms {
1259            if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
1260                top = top.max(p.z);
1261            }
1262        }
1263        top
1264    }
1265
1266    /// Authoritative interior context from the server (`inside_building` flag).
1267    pub fn effective_inside_building(&self) -> Option<String> {
1268        self.player_entity().and_then(|p| p.inside_building.clone())
1269    }
1270
1271    pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
1272        self.inventory_stacks = stacks.to_vec();
1273        self.inventory.clear();
1274        self.inventory_hints.clear();
1275        fn walk(
1276            stacks: &[flatland_protocol::ItemStack],
1277            inventory: &mut std::collections::HashMap<String, u32>,
1278            hints: &mut std::collections::HashMap<String, InventoryHint>,
1279        ) {
1280            for stack in stacks {
1281                *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
1282                if stack.display_name.is_some()
1283                    || stack.category.is_some()
1284                    || stack.base_mass.is_some()
1285                    || stack.base_volume.is_some()
1286                {
1287                    hints.insert(
1288                        stack.template_id.clone(),
1289                        InventoryHint {
1290                            display_name: stack
1291                                .display_name
1292                                .clone()
1293                                .unwrap_or_else(|| stack.template_id.clone()),
1294                            category: stack.category.clone().unwrap_or_default(),
1295                            base_mass: stack.base_mass,
1296                            base_volume: stack.base_volume,
1297                            capacity_volume: stack.capacity_volume,
1298                            stackable: stack.stackable.unwrap_or(true),
1299                        },
1300                    );
1301                }
1302                walk(&stack.contents, inventory, hints);
1303            }
1304        }
1305        walk(stacks, &mut self.inventory, &mut self.inventory_hints);
1306        // Include worn items (and nested contents, e.g. belt-clipped pouches) in craft counts.
1307        for item in self.worn.values() {
1308            walk(
1309                std::slice::from_ref(item),
1310                &mut self.inventory,
1311                &mut self.inventory_hints,
1312            );
1313        }
1314    }
1315
1316    /// Apply server interaction deltas immediately (quest rewards, shop, etc.) so the
1317    /// inventory UI updates before the next tick snapshot arrives.
1318    pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1319        let subtract_items =
1320            notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
1321        for stack in &notice.inventory_delta {
1322            if stack.quantity == 0 {
1323                continue;
1324            }
1325            if subtract_items {
1326                crate::currency::drain_template_stacks(
1327                    &mut self.inventory_stacks,
1328                    &stack.template_id,
1329                    stack.quantity,
1330                );
1331                continue;
1332            }
1333            let stackable = self
1334                .inventory_hints
1335                .get(&stack.template_id)
1336                .map(|h| h.stackable)
1337                .or(stack.stackable)
1338                .unwrap_or(true);
1339            if stackable {
1340                if let Some(existing) = self
1341                    .inventory_stacks
1342                    .iter_mut()
1343                    .find(|s| s.template_id == stack.template_id)
1344                {
1345                    existing.quantity = existing.quantity.saturating_add(stack.quantity);
1346                    if stack.display_name.is_some() {
1347                        existing.display_name = stack.display_name.clone();
1348                    }
1349                    if stack.category.is_some() {
1350                        existing.category = stack.category.clone();
1351                    }
1352                    continue;
1353                }
1354            }
1355            self.inventory_stacks.push(stack.clone());
1356        }
1357        if notice.coins_delta != 0 {
1358            crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
1359        }
1360        if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
1361            let stacks = self.inventory_stacks.clone();
1362            self.sync_inventory_from_stacks(&stacks);
1363        }
1364        self.record_shop_trade_notice(notice);
1365    }
1366
1367    /// Worn body-slot items — each shown as a shell row (unequip via Enter) followed by
1368    /// its nested contents (e.g. pouches clipped onto a worn belt). `BodySlot` derives
1369    /// `Ord` in display order (Head/Body/Arms/Legs/Feet/Back/Waist), so `BTreeMap`
1370    /// iteration alone gives a stable row order.
1371    pub fn worn_rows(&self) -> Vec<InventoryRow> {
1372        let mut rows = Vec::new();
1373        for (slot, item) in &self.worn {
1374            let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
1375            rows.push(InventoryRow {
1376                depth: 0,
1377                stack: item.clone(),
1378                from: from.clone(),
1379                from_parent_instance_id: None,
1380                is_equip_shell: true,
1381                is_chest_shell: false,
1382                section: InventorySection::Worn,
1383            });
1384            for child in &item.contents {
1385                push_inventory_rows(
1386                    &mut rows,
1387                    1,
1388                    child,
1389                    &from,
1390                    item.item_instance_id,
1391                    InventorySection::Worn,
1392                );
1393            }
1394        }
1395        rows
1396    }
1397
1398    /// Root on-person stacks that can be handed to a hired worker.
1399    pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
1400        self.inventory_stacks
1401            .iter()
1402            .filter_map(|stack| {
1403                let item_instance_id = stack.item_instance_id?;
1404                let label = stack
1405                    .display_name
1406                    .clone()
1407                    .unwrap_or_else(|| stack.template_id.clone());
1408                let label = if stack.quantity > 1 {
1409                    format!("{label} ×{}", stack.quantity)
1410                } else {
1411                    label
1412                };
1413                Some(WorkerGiveOption {
1414                    item_instance_id,
1415                    label,
1416                    quantity: stack.quantity,
1417                    template_id: stack.template_id.clone(),
1418                })
1419            })
1420            .collect()
1421    }
1422
1423    /// Employer-known blueprints the selected worker does not yet know.
1424    pub fn teachable_blueprint_options(
1425        &self,
1426        worker: &flatland_protocol::HiredWorkerView,
1427    ) -> Vec<WorkerTeachOption> {
1428        let copper = crate::currency::copper_from_counts(&self.inventory);
1429        let mut options: Vec<WorkerTeachOption> = self
1430            .blueprints
1431            .iter()
1432            .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
1433            .map(|bp| {
1434                let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
1435                let cost = bp.worker_train_copper;
1436                WorkerTeachOption {
1437                    blueprint_id: bp.id.clone(),
1438                    label: if bp.label.is_empty() {
1439                        bp.id.clone()
1440                    } else {
1441                        bp.label.clone()
1442                    },
1443                    cost_copper: cost,
1444                    min_level,
1445                    worker_level: worker.level,
1446                    can_afford: copper >= cost,
1447                    level_ok: worker.level >= min_level,
1448                }
1449            })
1450            .collect();
1451        options.sort_by(|a, b| a.label.cmp(&b.label));
1452        options
1453    }
1454
1455    /// Loose on-person inventory (not worn, not inside a placed chest).
1456    pub fn person_rows(&self) -> Vec<InventoryRow> {
1457        let mut rows = Vec::new();
1458        for stack in &self.inventory_stacks {
1459            push_inventory_rows(
1460                &mut rows,
1461                0,
1462                stack,
1463                &flatland_protocol::InventoryLocation::Root,
1464                None,
1465                InventorySection::Person,
1466            );
1467        }
1468        rows
1469    }
1470
1471    /// Legacy alias used by the HUD sidebar summary (worn + on-person, unchanged).
1472    pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
1473        let mut rows = self.worn_rows();
1474        rows.extend(self.person_rows());
1475        rows.into_iter().map(|r| (r.depth, r.stack)).collect()
1476    }
1477
1478    /// Placed chests within `CONTAINER_RANGE_M`, nearest first. Contents are only
1479    /// populated when `accessible` — this is what makes a chest's contents
1480    /// disappear the moment you walk away or it's locked without your key.
1481    pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
1482        let (px, py) = self.player_position();
1483        let mut list: Vec<NearbyContainer> = self
1484            .placed_containers
1485            .iter()
1486            .filter_map(|c| {
1487                let distance_m = (c.x - px).hypot(c.y - py);
1488                if distance_m > CONTAINER_RANGE_M {
1489                    return None;
1490                }
1491                let mut rows = Vec::new();
1492                let from = flatland_protocol::InventoryLocation::Placed {
1493                    container_id: c.id.clone(),
1494                };
1495                rows.push(InventoryRow {
1496                    depth: 0,
1497                    stack: flatland_protocol::ItemStack {
1498                        template_id: c.template_id.clone(),
1499                        quantity: 1,
1500                        item_instance_id: c.item_instance_id,
1501                        props: Default::default(),
1502                        contents: Vec::new(),
1503                        display_name: Some(c.display_name.clone()),
1504                        category: Some("container".into()),
1505                        base_mass: None,
1506                        base_volume: None,
1507                        capacity_volume: c.capacity_volume,
1508                        stackable: None,
1509                        world_placeable: None,
1510                        worker_lodging_capacity: c.worker_lodging_capacity,
1511                    },
1512                    from: from.clone(),
1513                    from_parent_instance_id: None,
1514                    is_equip_shell: false,
1515                    is_chest_shell: true,
1516                    section: InventorySection::Nearby,
1517                });
1518                if c.accessible {
1519                    for child in &c.contents {
1520                        push_inventory_rows(
1521                            &mut rows,
1522                            1,
1523                            child,
1524                            &from,
1525                            c.item_instance_id,
1526                            InventorySection::Nearby,
1527                        );
1528                    }
1529                }
1530                Some(NearbyContainer {
1531                    view: c.clone(),
1532                    distance_m,
1533                    rows,
1534                })
1535            })
1536            .collect();
1537        list.sort_by(|a, b| {
1538            a.distance_m
1539                .partial_cmp(&b.distance_m)
1540                .unwrap_or(std::cmp::Ordering::Equal)
1541        });
1542        list
1543    }
1544
1545    /// Nearest placed chest within `max_dist`, regardless of accessibility.
1546    pub fn nearest_placed_container(
1547        &self,
1548        max_dist: f32,
1549    ) -> Option<flatland_protocol::PlacedContainerView> {
1550        let (px, py) = self.player_position();
1551        self.placed_containers
1552            .iter()
1553            .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
1554            .min_by(|a, b| {
1555                let da = (a.x - px).hypot(a.y - py);
1556                let db = (b.x - px).hypot(b.y - py);
1557                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
1558            })
1559            .cloned()
1560    }
1561
1562    /// Full ordered list of *selectable* rows: worn ++ on-person ++ each nearby
1563    /// accessible chest's contents (nearest chest first). This single order drives
1564    /// `inventory_menu_index`; the renderer must build its grouped headers by
1565    /// walking `worn_rows()` / `person_rows()` / `nearby_containers()` in the same
1566    /// sequence so the highlighted row always matches.
1567    pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
1568        let mut rows = self.worn_rows();
1569        rows.extend(self.person_rows());
1570        for nc in self.nearby_containers() {
1571            rows.extend(nc.rows);
1572        }
1573        rows
1574    }
1575
1576    pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
1577        self.inventory_selectable_rows()
1578            .into_iter()
1579            .nth(self.inventory_menu_index)
1580    }
1581
1582    /// Format one selectable inventory row for TUI/gfx (label + hints + mass/volume).
1583    pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
1584        let cat = self
1585            .inventory_item_category(&row.stack.template_id)
1586            .unwrap_or("");
1587        let label = if cat == "key" {
1588            self.key_inventory_label(&row.stack)
1589        } else {
1590            row.stack
1591                .display_name
1592                .clone()
1593                .unwrap_or_else(|| row.stack.template_id.clone())
1594        };
1595        let hint: String = if row.is_equip_shell {
1596            " [worn — Enter to unequip]".into()
1597        } else if row.is_chest_shell {
1598            let (locked, lodging_note) = match &row.from {
1599                flatland_protocol::InventoryLocation::Placed { container_id } => {
1600                    let locked = self
1601                        .placed_containers
1602                        .iter()
1603                        .find(|c| c.id == *container_id)
1604                        .map(|c| c.locked)
1605                        .unwrap_or(false);
1606                    let lodging_note = self
1607                        .lodging_occupancy_label(container_id)
1608                        .map(|who| format!(" [lodging: {who}]"))
1609                        .unwrap_or_default();
1610                    (locked, lodging_note)
1611                }
1612                _ => (false, String::new()),
1613            };
1614            if locked {
1615                format!(" [locked — Enter pick up · l unlock]{lodging_note}")
1616            } else {
1617                format!(" [Enter pick up · l lock]{lodging_note}")
1618            }
1619        } else if cat == "key" {
1620            self.key_inventory_hint(&row.stack)
1621        } else {
1622            match cat {
1623                "weapon" => " [weapon]".into(),
1624                "container" => " [bag/chest/belt]".into(),
1625                "lodging" => " [worker lodging]".into(),
1626                "armor" => " [armor]".into(),
1627                _ => String::new(),
1628            }
1629        };
1630        let qty = if row.stack.quantity > 1 {
1631            format!(" ×{}", row.stack.quantity)
1632        } else {
1633            String::new()
1634        };
1635        let mass = self.stack_mass(&row.stack);
1636        let mass_kg = (mass >= 0.05).then_some(mass);
1637        let mass_str = mass_kg
1638            .map(|m| format!("  {m:.1} kg"))
1639            .unwrap_or_default();
1640        let volume = self.container_volume_stats(row);
1641        let vol_str = self.container_volume_label(row);
1642
1643        let mut title = label.clone();
1644        if let Some(id) = row.stack.item_instance_id {
1645            let hex: String = id
1646                .as_simple()
1647                .to_string()
1648                .chars()
1649                .filter(|c| c.is_ascii_hexdigit())
1650                .collect();
1651            if hex.len() >= 4 {
1652                title.push_str(&format!(" #{}", &hex[hex.len() - 4..]));
1653            }
1654        }
1655        title.push_str(&qty);
1656        if row.is_equip_shell {
1657            if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
1658                title.push_str(&format!(" ({})", body_slot_label(slot)));
1659            }
1660        }
1661
1662        InventoryRowView {
1663            depth: row.depth,
1664            text: format!("{label}{hint}{qty}{mass_str}{vol_str}"),
1665            title,
1666            mass_kg,
1667            volume,
1668        }
1669    }
1670
1671    /// Sectioned inventory browser lines for gfx/TUI. Selectable rows carry
1672    /// `selectable_index` matching `inventory_menu_index`.
1673    pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
1674        let mut lines = Vec::new();
1675        let target = self.inventory_menu_index;
1676        let highlight = !self.show_move_picker;
1677        let mut global_idx = 0usize;
1678
1679        lines.push(InventoryBrowserLine::Section("— Worn —".into()));
1680        let worn = self.worn_rows();
1681        if worn.is_empty() {
1682            lines.push(InventoryBrowserLine::Hint(
1683                "  (nothing equipped — wear a backpack/belt from \"On you\" below)".into(),
1684            ));
1685        } else {
1686            for row in &worn {
1687                if row.is_equip_shell {
1688                    if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
1689                        lines.push(InventoryBrowserLine::SlotLabel(format!(
1690                            "  {}:",
1691                            body_slot_label(slot)
1692                        )));
1693                    }
1694                }
1695                let view = self.format_inventory_row(row);
1696                lines.push(InventoryBrowserLine::Item {
1697                    selectable_index: global_idx,
1698                    selected: highlight && global_idx == target,
1699                    depth: view.depth,
1700                    text: view.text,
1701                    title: view.title,
1702                    mass_kg: view.mass_kg,
1703                    volume: view.volume,
1704                });
1705                global_idx += 1;
1706            }
1707        }
1708
1709        lines.push(InventoryBrowserLine::Blank);
1710        lines.push(InventoryBrowserLine::Section(
1711            "— On you (loose, not worn) —".into(),
1712        ));
1713        let person = self.person_rows();
1714        if person.is_empty() {
1715            lines.push(InventoryBrowserLine::Hint("  (empty)".into()));
1716        } else {
1717            for row in &person {
1718                let view = self.format_inventory_row(row);
1719                lines.push(InventoryBrowserLine::Item {
1720                    selectable_index: global_idx,
1721                    selected: highlight && global_idx == target,
1722                    depth: view.depth,
1723                    text: view.text,
1724                    title: view.title,
1725                    mass_kg: view.mass_kg,
1726                    volume: view.volume,
1727                });
1728                global_idx += 1;
1729            }
1730        }
1731
1732        let nearby = self.nearby_containers();
1733        if nearby.is_empty() {
1734            lines.push(InventoryBrowserLine::Blank);
1735            lines.push(InventoryBrowserLine::Section("— Nearby chests —".into()));
1736            lines.push(InventoryBrowserLine::Hint(
1737                "  (none within reach — walk up to a chest)".into(),
1738            ));
1739        } else {
1740            for nc in &nearby {
1741                lines.push(InventoryBrowserLine::Blank);
1742                let lock_note = if nc.view.locked && nc.view.accessible {
1743                    "  unlocked with your key"
1744                } else if nc.view.locked {
1745                    "  locked"
1746                } else {
1747                    ""
1748                };
1749                lines.push(InventoryBrowserLine::Section(format!(
1750                    "— {} ({:.0}m away){lock_note} —",
1751                    nc.view.display_name, nc.distance_m
1752                )));
1753                if !nc.view.accessible {
1754                    lines.push(InventoryBrowserLine::Hint(
1755                        "  locked — need the matching key (l to try)".into(),
1756                    ));
1757                } else if nc.rows.is_empty() {
1758                    lines.push(InventoryBrowserLine::Hint(
1759                        "  (empty — select chest row above, m to move items in)".into(),
1760                    ));
1761                } else {
1762                    for row in &nc.rows {
1763                        let view = self.format_inventory_row(row);
1764                        lines.push(InventoryBrowserLine::Item {
1765                            selectable_index: global_idx,
1766                            selected: highlight && global_idx == target,
1767                            depth: view.depth,
1768                            text: view.text,
1769                            title: view.title,
1770                            mass_kg: view.mass_kg,
1771                            volume: view.volume,
1772                        });
1773                        global_idx += 1;
1774                    }
1775                }
1776            }
1777        }
1778        lines
1779    }
1780
1781    /// Destinations for picking up a placed chest/crate into inventory.
1782    pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
1783        let mut opts = Vec::new();
1784        opts.push(MoveOption {
1785            label: "On your person (loose)".into(),
1786            kind: MoveOptionKind::PickupPlaced {
1787                container_id: container_id.to_string(),
1788                nest_location: flatland_protocol::InventoryLocation::Root,
1789                nest_parent_instance_id: None,
1790            },
1791        });
1792        for (slot, item) in &self.worn {
1793            if item.category.as_deref() != Some("container") {
1794                continue;
1795            }
1796            if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
1797                continue;
1798            }
1799            let Some(parent_id) = item.item_instance_id else {
1800                continue;
1801            };
1802            let shell_name = item
1803                .display_name
1804                .clone()
1805                .unwrap_or_else(|| item.template_id.clone());
1806            opts.push(MoveOption {
1807                label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
1808                kind: MoveOptionKind::PickupPlaced {
1809                    container_id: container_id.to_string(),
1810                    nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
1811                    nest_parent_instance_id: Some(parent_id),
1812                },
1813            });
1814            // Nested pouches inside the worn bag.
1815            Self::append_chest_pickup_nested(
1816                &mut opts,
1817                container_id,
1818                flatland_protocol::InventoryLocation::Worn { slot: *slot },
1819                item,
1820                &format!("in {shell_name}"),
1821            );
1822        }
1823        opts.push(MoveOption {
1824            label: "Cancel".into(),
1825            kind: MoveOptionKind::Cancel,
1826        });
1827        opts
1828    }
1829
1830    fn append_chest_pickup_nested(
1831        opts: &mut Vec<MoveOption>,
1832        container_id: &str,
1833        location: flatland_protocol::InventoryLocation,
1834        parent: &flatland_protocol::ItemStack,
1835        context: &str,
1836    ) {
1837        for child in &parent.contents {
1838            if child.category.as_deref() != Some("container") {
1839                continue;
1840            }
1841            if !Self::is_volume_container_stack(child) {
1842                continue;
1843            }
1844            // Skip placeable nested chests — relocating into another chest is not useful.
1845            if child.world_placeable == Some(true) {
1846                continue;
1847            }
1848            let Some(child_id) = child.item_instance_id else {
1849                continue;
1850            };
1851            let name = child
1852                .display_name
1853                .clone()
1854                .unwrap_or_else(|| child.template_id.clone());
1855            opts.push(MoveOption {
1856                label: format!("{name} ({context})"),
1857                kind: MoveOptionKind::PickupPlaced {
1858                    container_id: container_id.to_string(),
1859                    nest_location: location.clone(),
1860                    nest_parent_instance_id: Some(child_id),
1861                },
1862            });
1863            Self::append_chest_pickup_nested(
1864                opts,
1865                container_id,
1866                location.clone(),
1867                child,
1868                &format!("in {name}"),
1869            );
1870        }
1871    }
1872
1873    /// Build the "move to…" destination list for an item currently at `from`.
1874    pub fn move_destinations_for(
1875        &self,
1876        from: &flatland_protocol::InventoryLocation,
1877        from_parent_instance_id: Option<uuid::Uuid>,
1878        moving_instance_id: Option<uuid::Uuid>,
1879        moving_template_id: &str,
1880    ) -> Vec<MoveOption> {
1881        let mut opts = Vec::new();
1882        if *from != flatland_protocol::InventoryLocation::Root {
1883            opts.push(MoveOption {
1884                label: "On your person (loose)".into(),
1885                kind: MoveOptionKind::Move {
1886                    location: flatland_protocol::InventoryLocation::Root,
1887                    parent_instance_id: None,
1888                },
1889            });
1890        }
1891        for (slot, item) in &self.worn {
1892            if item.category.as_deref() != Some("container") {
1893                continue;
1894            }
1895            let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
1896            let shell_name = item
1897                .display_name
1898                .clone()
1899                .unwrap_or_else(|| item.template_id.clone());
1900
1901            // Backpack and other worn volume containers — store directly inside the shell.
1902            if *slot != BodySlot::Waist
1903                && item.item_instance_id != moving_instance_id
1904                && Self::is_volume_container_stack(item)
1905            {
1906                Self::push_move_destination(
1907                    &mut opts,
1908                    format!("{shell_name} (worn {})", body_slot_label(*slot)),
1909                    location.clone(),
1910                    item.item_instance_id,
1911                    from,
1912                    from_parent_instance_id,
1913                );
1914            }
1915
1916            // Belt loops only accept pouch attachments — not loose materials.
1917            if *slot == BodySlot::Waist
1918                && Self::attaches_to_belt_loop(moving_template_id)
1919                && item.item_instance_id != moving_instance_id
1920            {
1921                Self::push_move_destination(
1922                    &mut opts,
1923                    format!("{shell_name} (belt loop)"),
1924                    location.clone(),
1925                    item.item_instance_id,
1926                    from,
1927                    from_parent_instance_id,
1928                );
1929            }
1930
1931            let context = if *slot == BodySlot::Waist {
1932                format!("on {shell_name}")
1933            } else {
1934                format!("in {shell_name}")
1935            };
1936            Self::append_nested_container_destinations(
1937                &mut opts,
1938                location,
1939                item,
1940                &context,
1941                from,
1942                from_parent_instance_id,
1943                moving_instance_id,
1944            );
1945        }
1946        for nc in self.nearby_containers() {
1947            if !nc.view.accessible {
1948                continue;
1949            }
1950            let location = flatland_protocol::InventoryLocation::Placed {
1951                container_id: nc.view.id.clone(),
1952            };
1953            Self::push_move_destination(
1954                &mut opts,
1955                format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
1956                location,
1957                nc.view.item_instance_id,
1958                from,
1959                from_parent_instance_id,
1960            );
1961        }
1962        let allow_drop = moving_instance_id
1963            .and_then(|id| self.stack_for_instance(id))
1964            .map(|stack| !self.key_drop_blocked(&stack))
1965            .unwrap_or(moving_template_id != KEY_TEMPLATE);
1966        if allow_drop {
1967            opts.push(MoveOption {
1968                label: "Drop on the ground".into(),
1969                kind: MoveOptionKind::Drop,
1970            });
1971        }
1972        opts.push(MoveOption {
1973            label: "Cancel".into(),
1974            kind: MoveOptionKind::Cancel,
1975        });
1976        opts
1977    }
1978
1979    fn is_same_container_dest(
1980        dest_location: &flatland_protocol::InventoryLocation,
1981        dest_parent: Option<uuid::Uuid>,
1982        from: &flatland_protocol::InventoryLocation,
1983        from_parent: Option<uuid::Uuid>,
1984    ) -> bool {
1985        dest_location == from && dest_parent == from_parent
1986    }
1987
1988    fn push_move_destination(
1989        opts: &mut Vec<MoveOption>,
1990        label: String,
1991        location: flatland_protocol::InventoryLocation,
1992        parent_instance_id: Option<uuid::Uuid>,
1993        from: &flatland_protocol::InventoryLocation,
1994        from_parent_instance_id: Option<uuid::Uuid>,
1995    ) {
1996        if Self::is_same_container_dest(
1997            &location,
1998            parent_instance_id,
1999            from,
2000            from_parent_instance_id,
2001        ) {
2002            return;
2003        }
2004        opts.push(MoveOption {
2005            label,
2006            kind: MoveOptionKind::Move {
2007                location,
2008                parent_instance_id,
2009            },
2010        });
2011    }
2012
2013    fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
2014        stack.capacity_volume.is_some_and(|c| c > 0.0)
2015    }
2016
2017    fn attaches_to_belt_loop(template_id: &str) -> bool {
2018        matches!(template_id, "leather_pouch" | "dimensional_pouch")
2019    }
2020
2021    fn append_nested_container_destinations(
2022        opts: &mut Vec<MoveOption>,
2023        location: flatland_protocol::InventoryLocation,
2024        container: &flatland_protocol::ItemStack,
2025        context: &str,
2026        from: &flatland_protocol::InventoryLocation,
2027        from_parent_instance_id: Option<uuid::Uuid>,
2028        moving_instance_id: Option<uuid::Uuid>,
2029    ) {
2030        for child in &container.contents {
2031            if Self::is_volume_container_stack(child)
2032                && child.item_instance_id != moving_instance_id
2033            {
2034                let name = child
2035                    .display_name
2036                    .clone()
2037                    .unwrap_or_else(|| child.template_id.clone());
2038                Self::push_move_destination(
2039                    opts,
2040                    format!("{name} ({context})"),
2041                    location.clone(),
2042                    child.item_instance_id,
2043                    from,
2044                    from_parent_instance_id,
2045                );
2046            }
2047            let nested_context = format!(
2048                "in {}",
2049                child.display_name.as_deref().unwrap_or(&child.template_id)
2050            );
2051            Self::append_nested_container_destinations(
2052                opts,
2053                location.clone(),
2054                child,
2055                &nested_context,
2056                from,
2057                from_parent_instance_id,
2058                moving_instance_id,
2059            );
2060        }
2061    }
2062
2063    fn clamp_inventory_indices(&mut self) {
2064        let n = self.inventory_selectable_rows().len();
2065        self.inventory_menu_index = if n == 0 {
2066            0
2067        } else {
2068            self.inventory_menu_index.min(n - 1)
2069        };
2070        if let Some(picker) = &self.move_picker {
2071            let pn = picker.options.len();
2072            self.move_picker_index = if pn == 0 {
2073                0
2074            } else {
2075                self.move_picker_index.min(pn - 1)
2076            };
2077        }
2078    }
2079
2080    /// Drop interior map layers whenever the player is outdoors.
2081    fn sync_interior_map_context(&mut self) {
2082        if self.effective_inside_building().is_none() {
2083            self.interior_map = None;
2084        }
2085    }
2086
2087    fn apply_snapshot_fields(
2088        &mut self,
2089        snapshot: &flatland_protocol::Snapshot,
2090        entity_id: EntityId,
2091    ) {
2092        self.tick = snapshot.tick;
2093        self.chunk_rev = snapshot.chunk_rev;
2094        self.content_rev = snapshot.content_rev;
2095        self.publish_rev = snapshot.publish_rev;
2096        self.resource_nodes = snapshot.resource_nodes.clone();
2097        self.ground_drops = snapshot.ground_drops.clone();
2098        self.placed_containers = snapshot.placed_containers.clone();
2099        self.world_width_m = snapshot.world_width_m;
2100        self.world_height_m = snapshot.world_height_m;
2101        self.world_clock = snapshot.world_clock;
2102        self.terrain_zones = snapshot.terrain_zones.clone();
2103        self.z_platforms = snapshot.z_platforms.clone();
2104        self.z_transitions = snapshot.z_transitions.clone();
2105        self.buildings = snapshot.buildings.clone();
2106        self.doors = snapshot.doors.clone();
2107        self.interior_map = snapshot.interior_map.clone();
2108        self.npcs = snapshot.npcs.clone();
2109        self.blueprints = snapshot.blueprints.clone();
2110        self.sync_inventory_from_stacks(&snapshot.inventory);
2111        self.player = snapshot
2112            .entities
2113            .iter()
2114            .find(|e| e.id == entity_id)
2115            .cloned();
2116        self.entities = snapshot.entities.clone();
2117        self.quest_log = snapshot.quest_log.clone();
2118        self.apply_hired_workers(snapshot.hired_workers.clone());
2119        self.interactables = snapshot.interactables.clone();
2120        self.sync_interior_map_context();
2121    }
2122
2123    /// Re-derive inventory selection state after a snapshot/tick — chests that
2124    /// went out of range or got locked simply vanish from the row list, and the
2125    /// move picker (if any) closes once its item is no longer reachable.
2126    fn refresh_inventory_ui(&mut self) {
2127        if let Some(picker) = &self.move_picker {
2128            let instance_id = picker.item_instance_id;
2129            let still_exists = self
2130                .inventory_selectable_rows()
2131                .iter()
2132                .any(|r| r.stack.item_instance_id == Some(instance_id));
2133            if !still_exists {
2134                self.move_picker = None;
2135                self.show_move_picker = false;
2136            }
2137        }
2138        if let Some(picker) = &self.destroy_picker {
2139            let instance_id = picker.item_instance_id;
2140            let still_exists = self
2141                .inventory_selectable_rows()
2142                .iter()
2143                .any(|r| r.stack.item_instance_id == Some(instance_id));
2144            if !still_exists {
2145                self.destroy_picker = None;
2146                self.show_destroy_picker = false;
2147                self.destroy_confirm_pending = false;
2148            }
2149        }
2150        self.clamp_inventory_indices();
2151    }
2152
2153    /// Replace the hired-worker list from a snapshot/delta.
2154    ///
2155    /// Keeps a stable sort and remaps `workers_menu_index` by instance id so the
2156    /// selected worker (and its step line) does not jump when the server rebuilds
2157    /// the list.
2158    fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
2159        let selected_id = self
2160            .hired_workers
2161            .get(self.workers_menu_index)
2162            .map(|w| w.instance_id.clone());
2163        workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
2164        let now = Instant::now();
2165        let mut next_display = BTreeMap::new();
2166        for w in &workers {
2167            let mut sticky = self
2168                .worker_step_display
2169                .remove(&w.instance_id)
2170                .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
2171            sticky.observe(&w.step_label, now);
2172            next_display.insert(w.instance_id.clone(), sticky);
2173        }
2174        self.worker_step_display = next_display;
2175        self.hired_workers = workers;
2176        if let Some(id) = selected_id {
2177            if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
2178                self.workers_menu_index = idx;
2179                return;
2180            }
2181        }
2182        if self.workers_menu_index >= self.hired_workers.len() {
2183            self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
2184        }
2185    }
2186
2187    /// Held coarse step label for the workers menu (`step:` line).
2188    pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
2189        self.worker_step_display
2190            .get(worker_instance_id)
2191            .map(|s| s.shown.as_str())
2192            .or_else(|| {
2193                self.hired_workers
2194                    .iter()
2195                    .find(|w| w.instance_id == worker_instance_id)
2196                    .map(|w| w.step_label.as_str())
2197            })
2198            .unwrap_or("")
2199    }
2200
2201    fn apply_combat_hud(&mut self, combat: &CombatHud) {
2202        self.in_combat = combat.in_combat;
2203        self.auto_attack = combat.auto_attack;
2204        self.combat_has_los = combat.has_los;
2205        self.attack_cd_ticks = combat.attack_cd_ticks;
2206        self.gcd_ticks = combat.gcd_ticks;
2207        self.weapon_ability_id = combat.ability_id.clone();
2208        self.mainhand_template_id = combat.mainhand_template_id.clone();
2209        self.mainhand_label = combat.mainhand_label.clone();
2210        self.worn = combat.worn.iter().cloned().collect();
2211        self.carry_mass = combat.carry_mass;
2212        self.carry_mass_max = combat.carry_mass_max;
2213        self.encumbrance = combat.encumbrance;
2214        self.cast_progress = combat.cast.clone();
2215        self.ability_cooldowns = combat.ability_cooldowns.clone();
2216        self.blocking_active = combat.blocking_active;
2217        self.max_target_slots = combat.max_target_slots.max(1);
2218        self.combat_slots = combat.slots.clone();
2219        self.rotation_presets = combat.rotation_presets.clone();
2220        self.keychain_stacks = combat.keychain.clone();
2221        self.combat_target_detail = combat.target.clone();
2222        self.combat_target = combat.target_entity_id;
2223        if combat.progression_xp_base > 0.0 {
2224            self.progression_curve = Some(flatland_protocol::ProgressionCurve {
2225                baseline_display: combat.progression_baseline,
2226                xp_base: combat.progression_xp_base,
2227                xp_growth: combat.progression_xp_growth,
2228            });
2229        }
2230        if let Some(xp) = &combat.progression_xp {
2231            if let Some(player) = &mut self.player {
2232                player.progression_xp = Some(xp.clone());
2233                if let Some(attrs) = combat.attributes {
2234                    player.attributes = Some(attrs);
2235                }
2236                if let Some(skills) = &combat.skills {
2237                    player.skills = Some(skills.clone());
2238                }
2239            }
2240        }
2241        if let Some(label) = &combat.target_label {
2242            self.combat_target_label = Some(label.clone());
2243        } else if let Some(id) = combat.target_entity_id {
2244            self.combat_target_label = self
2245                .entities
2246                .iter()
2247                .find(|e| e.id == id)
2248                .map(|e| e.label.clone())
2249                .or_else(|| self.combat_target_label.clone());
2250        }
2251        self.refresh_inventory_ui();
2252    }
2253
2254    /// Entity id assigned to a combat slot (from server HUD).
2255    pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
2256        self.combat_slots
2257            .iter()
2258            .find(|s| s.slot_index == slot)
2259            .and_then(|s| s.target_entity_id)
2260            .or_else(|| if slot == 1 { self.combat_target } else { None })
2261    }
2262
2263    /// Hostile wildlife / monsters for T1 (`Tab`).
2264    pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
2265        self.combat_candidates()
2266    }
2267
2268    /// Allies first, then monsters, for T2 (`Shift+Tab`). Includes self for heals.
2269    pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
2270        let (px, py) = self.player_position();
2271        let dist = |id: EntityId| {
2272            self.entities
2273                .iter()
2274                .find(|e| e.id == id)
2275                .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
2276                .unwrap_or(f32::MAX)
2277        };
2278
2279        let mut allies = Vec::new();
2280        // Self first — heal_touch on T2.
2281        if let Some(me) = self.player.as_ref() {
2282            let alive = me
2283                .vitals
2284                .as_ref()
2285                .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
2286                .unwrap_or(true);
2287            if alive {
2288                allies.push((self.entity_id, "Yourself".into()));
2289            }
2290        }
2291        for entity in &self.entities {
2292            if entity.id == self.entity_id {
2293                continue;
2294            }
2295            if entity.vitals.is_some() {
2296                let alive = entity
2297                    .vitals
2298                    .as_ref()
2299                    .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
2300                    .unwrap_or(true);
2301                if alive {
2302                    allies.push((entity.id, entity.label.clone()));
2303                }
2304            }
2305        }
2306        allies.sort_by(|(a, _), (b, _)| {
2307            if *a == self.entity_id {
2308                return std::cmp::Ordering::Less;
2309            }
2310            if *b == self.entity_id {
2311                return std::cmp::Ordering::Greater;
2312            }
2313            dist(*a)
2314                .partial_cmp(&dist(*b))
2315                .unwrap_or(std::cmp::Ordering::Equal)
2316        });
2317
2318        let mut monsters = self.combat_candidates();
2319        monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
2320        allies.into_iter().chain(monsters).collect()
2321    }
2322
2323    fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
2324        match slot_index {
2325            2 => self.t2_candidates(),
2326            _ => self.t1_candidates(),
2327        }
2328    }
2329
2330    /// Nearest combat candidate within `radius_m` of world click (gfx click-to-target).
2331    pub fn pick_combat_target_at(
2332        &self,
2333        wx: f32,
2334        wy: f32,
2335        slot_index: u8,
2336        radius_m: f32,
2337    ) -> Option<(EntityId, String)> {
2338        let mut best: Option<(f32, EntityId, String)> = None;
2339        for (id, label) in self.candidates_for_slot(slot_index) {
2340            let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
2341                // NPCs may only appear in NpcView — fall back to npc list coords.
2342                if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
2343                    let d = distance(wx, wy, npc.x, npc.y);
2344                    if d <= radius_m {
2345                        best = match best {
2346                            Some((bd, _, _)) if bd <= d => best,
2347                            _ => Some((d, id, label)),
2348                        };
2349                    }
2350                }
2351                continue;
2352            };
2353            let d = distance(
2354                wx,
2355                wy,
2356                entity.transform.position.x,
2357                entity.transform.position.y,
2358            );
2359            if d <= radius_m {
2360                best = match best {
2361                    Some((bd, _, _)) if bd <= d => best,
2362                    _ => Some((d, id, label)),
2363                };
2364            }
2365        }
2366        best.map(|(_, id, label)| (id, label))
2367    }
2368
2369    /// Full client reset after Welcome (initial connect or reconnect).
2370    pub(crate) fn restore_from_welcome(
2371        &mut self,
2372        session_id: SessionId,
2373        entity_id: EntityId,
2374        snapshot: &flatland_protocol::Snapshot,
2375    ) {
2376        self.clear_harvest_state();
2377        self.disconnect_reason = None;
2378        self.show_stats = false;
2379        self.show_craft_menu = false;
2380        self.show_shop_menu = false;
2381        self.shop_catalog = None;
2382        self.show_inventory_menu = false;
2383        self.session_id = session_id;
2384        self.entity_id = entity_id;
2385        self.connected = true;
2386        self.apply_snapshot_fields(snapshot, entity_id);
2387        if let Some(combat) = &snapshot.combat {
2388            self.apply_combat_hud(combat);
2389            let stacks = self.inventory_stacks.clone();
2390            self.sync_inventory_from_stacks(&stacks);
2391        }
2392    }
2393
2394    fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
2395        self.tick = delta.tick;
2396        self.world_clock = delta.world_clock;
2397
2398        // Degenerate AOI tick (observer missing server-side): keep welcome snapshot layers.
2399        if delta.entities.is_empty() {
2400            if let Some(combat) = &delta.combat {
2401                self.apply_combat_hud(combat);
2402                let stacks = self.inventory_stacks.clone();
2403                self.sync_inventory_from_stacks(&stacks);
2404            }
2405            return;
2406        }
2407        if !delta.buildings.is_empty() {
2408            self.buildings = delta.buildings.clone();
2409        }
2410        if !delta.blueprints.is_empty() {
2411            self.blueprints = delta.blueprints.clone();
2412        }
2413        self.sync_inventory_from_stacks(&delta.inventory);
2414
2415        if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
2416            self.player = Some(updated.clone());
2417        }
2418        self.entities = delta.entities.clone();
2419        if self.player.is_none() {
2420            self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
2421        }
2422
2423        self.sync_interior_map_context();
2424
2425        // Static world layers: ticks often omit these (indoors, or unchanged).
2426        // Never wipe the welcome snapshot with an empty vec.
2427        if !delta.resource_nodes.is_empty() {
2428            self.resource_nodes = delta.resource_nodes.clone();
2429        }
2430        if self
2431            .player
2432            .as_ref()
2433            .is_none_or(|p| p.inside_building.is_none())
2434        {
2435            self.ground_drops = delta.ground_drops.clone();
2436            self.placed_containers = delta.placed_containers.clone();
2437        }
2438        if !delta.doors.is_empty() {
2439            self.doors = delta.doors.clone();
2440        }
2441        if self.effective_inside_building().is_some() {
2442            if let Some(map) = &delta.interior_map {
2443                self.interior_map = Some(map.clone());
2444            }
2445        } else {
2446            self.interior_map = None;
2447        }
2448        if !delta.npcs.is_empty() {
2449            self.npcs = delta.npcs.clone();
2450        }
2451        if !delta.quest_log.is_empty() {
2452            self.quest_log = delta.quest_log.clone();
2453        }
2454        self.apply_hired_workers(delta.hired_workers.clone());
2455        if !delta.interactables.is_empty() {
2456            self.interactables = delta.interactables.clone();
2457        }
2458        if let Some(combat) = &delta.combat {
2459            self.apply_combat_hud(combat);
2460            let stacks = self.inventory_stacks.clone();
2461            self.sync_inventory_from_stacks(&stacks);
2462        } else {
2463            self.refresh_inventory_ui();
2464        }
2465    }
2466
2467    /// Wildlife and other combat targets visible in AOI (`NpcView.entity_id`).
2468    pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
2469        let (px, py) = self.player_position();
2470        let mut out = Vec::new();
2471        for npc in &self.npcs {
2472            let Some(eid) = npc.entity_id else {
2473                continue;
2474            };
2475            let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
2476            let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
2477            if alive && has_hp {
2478                out.push((eid, npc.label.clone()));
2479            }
2480        }
2481        out.sort_by(|(a_id, a_label), (b_id, b_label)| {
2482            let dist = |id: EntityId| {
2483                self.entities
2484                    .iter()
2485                    .find(|e| e.id == id)
2486                    .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
2487                    .unwrap_or(f32::MAX)
2488            };
2489            dist(*a_id)
2490                .partial_cmp(&dist(*b_id))
2491                .unwrap_or(std::cmp::Ordering::Equal)
2492                .then_with(|| a_label.cmp(b_label))
2493                .then_with(|| a_id.cmp(b_id))
2494        });
2495        out
2496    }
2497
2498    pub fn refresh_combat_target_label(&mut self) {
2499        let Some(id) = self.combat_target else {
2500            return;
2501        };
2502        if let Some((_, label)) = self
2503            .combat_candidates()
2504            .into_iter()
2505            .find(|(eid, _)| *eid == id)
2506        {
2507            self.combat_target_label = Some(label);
2508        } else if let Some(label) = self
2509            .entities
2510            .iter()
2511            .find(|e| e.id == id)
2512            .map(|e| e.label.clone())
2513        {
2514            self.combat_target_label = Some(label);
2515        }
2516    }
2517
2518    pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
2519        self.quest_log
2520            .iter()
2521            .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
2522            .collect()
2523    }
2524
2525    /// True when the observer has at least one free lodging slot (max workers = beds).
2526    pub fn has_worker_lodging(&self) -> bool {
2527        self.free_worker_lodging_slots() > 0
2528    }
2529
2530    /// Owned lodging capacity minus currently hired workers.
2531    pub fn free_worker_lodging_slots(&self) -> i64 {
2532        let slots: u32 = self
2533            .placed_containers
2534            .iter()
2535            .filter(|c| match (self.character_id, c.owner_character_id) {
2536                (Some(me), Some(owner)) => me == owner,
2537                (Some(_), None) => false,
2538                (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
2539            })
2540            .map(|c| c.worker_lodging_capacity.unwrap_or(0))
2541            .sum();
2542        let used = self.hired_workers.len() as u32;
2543        slots as i64 - used as i64
2544    }
2545
2546    /// Display names of hired workers assigned to this lodging container.
2547    pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
2548        let mut names: Vec<String> = self
2549            .hired_workers
2550            .iter()
2551            .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
2552            .map(|w| w.label.clone())
2553            .collect();
2554        names.sort();
2555        names
2556    }
2557
2558    /// Compact lodging occupancy for labels: `"Elda, Ana"`, `"vacant"`, or `""` if not lodging.
2559    pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
2560        let is_lodging = self
2561            .placed_containers
2562            .iter()
2563            .find(|c| c.id == container_id)
2564            .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
2565        if !is_lodging {
2566            return None;
2567        }
2568        let names = self.lodging_occupant_labels(container_id);
2569        Some(if names.is_empty() {
2570            "vacant".into()
2571        } else {
2572            names.join(", ")
2573        })
2574    }
2575
2576    pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
2577        self.quest_log
2578            .iter()
2579            .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
2580            .or_else(|| {
2581                self.quest_log
2582                    .iter()
2583                    .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
2584            })
2585    }
2586
2587    /// Nearest interactable target for `f` (doors, NPCs, well, shallow water).
2588    pub fn nearest_interact_target(&self) -> Option<String> {
2589        let (px, py) = self.player_position();
2590        let inside = self.effective_inside_building();
2591
2592        #[derive(Clone, Copy, PartialEq, Eq)]
2593        enum Kind {
2594            Npc,
2595            QuestBoard,
2596            ExitDoor,
2597            EnterDoor,
2598            Well,
2599            Water,
2600        }
2601
2602        fn kind_priority(kind: Kind) -> u8 {
2603            match kind {
2604                Kind::Npc => 0,
2605                Kind::QuestBoard => 1,
2606                Kind::ExitDoor => 2,
2607                Kind::EnterDoor => 3,
2608                Kind::Well => 4,
2609                Kind::Water => 5,
2610            }
2611        }
2612
2613        let mut best: Option<(f32, Kind, String)> = None;
2614
2615        let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
2616            if dist > max {
2617                return;
2618            }
2619            let replace = match best {
2620                None => true,
2621                Some((bd, _bk, _)) if dist < bd - 0.05 => true,
2622                Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
2623                    kind_priority(kind) < kind_priority(bk)
2624                }
2625                _ => false,
2626            };
2627            if replace {
2628                best = Some((dist, kind, id));
2629            }
2630        };
2631
2632        for npc in &self.npcs {
2633            consider(
2634                distance(px, py, npc.x, npc.y),
2635                INTERACTION_RADIUS_M,
2636                Kind::Npc,
2637                npc.id.clone(),
2638            );
2639        }
2640
2641        for door in &self.doors {
2642            if let Some(ref bid) = inside {
2643                if door.building_id != *bid {
2644                    continue;
2645                }
2646                let is_exit = door.portal.is_some();
2647                let max = if is_exit {
2648                    INTERACTION_RADIUS_M
2649                } else {
2650                    DOOR_INTERACTION_RADIUS_M
2651                };
2652                let kind = if is_exit {
2653                    Kind::ExitDoor
2654                } else {
2655                    Kind::EnterDoor
2656                };
2657                consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
2658                continue;
2659            }
2660            consider(
2661                distance(px, py, door.x, door.y),
2662                DOOR_INTERACTION_RADIUS_M,
2663                Kind::EnterDoor,
2664                door.id.clone(),
2665            );
2666        }
2667
2668        if inside.is_none() {
2669            for inter in &self.interactables {
2670                if inter.kind == "quest_board" {
2671                    consider(
2672                        distance(px, py, inter.x, inter.y),
2673                        QUEST_BOARD_INTERACTION_RADIUS_M,
2674                        Kind::QuestBoard,
2675                        inter.id.clone(),
2676                    );
2677                }
2678            }
2679            for building in &self.buildings {
2680                if !building.tags.iter().any(|t| t == "well") {
2681                    continue;
2682                }
2683                consider(
2684                    distance(px, py, building.x, building.y),
2685                    INTERACTION_RADIUS_M,
2686                    Kind::Well,
2687                    building.id.clone(),
2688                );
2689            }
2690            if self.in_shallow_water() {
2691                consider(
2692                    0.0,
2693                    INTERACTION_RADIUS_M,
2694                    Kind::Water,
2695                    "water_source".into(),
2696                );
2697            }
2698        }
2699
2700        best.map(|(_, _, id)| id)
2701    }
2702
2703    /// Nearest quest board and distance (any distance), for out-of-range feedback.
2704    pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
2705        if self.effective_inside_building().is_some() {
2706            return None;
2707        }
2708        let (px, py) = self.player_position();
2709        self.interactables
2710            .iter()
2711            .filter(|i| i.kind == "quest_board")
2712            .map(|i| {
2713                let label = if i.label.is_empty() {
2714                    "Quest board".to_string()
2715                } else {
2716                    i.label.clone()
2717                };
2718                (label, distance(px, py, i.x, i.y))
2719            })
2720            .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
2721    }
2722
2723    /// Human-readable template name from synced catalog hints.
2724    pub fn template_display_name(&self, template_id: &str) -> String {
2725        self.inventory_hints
2726            .get(template_id)
2727            .map(|h| h.display_name.clone())
2728            .filter(|n| !n.is_empty())
2729            .unwrap_or_else(|| humanize_template_id(template_id))
2730    }
2731
2732    /// Chest label for the location panel — generic type unless this player owns it.
2733    pub fn placed_container_public_label(
2734        &self,
2735        c: &flatland_protocol::PlacedContainerView,
2736    ) -> String {
2737        let is_owner = match (self.character_id, c.owner_character_id) {
2738            (Some(me), Some(owner)) => me == owner,
2739            _ => false,
2740        };
2741        if is_owner {
2742            c.display_name.clone()
2743        } else {
2744            self.template_display_name(&c.template_id)
2745        }
2746    }
2747
2748    /// Keys on person and stowed on the keychain for the keychain overlay.
2749    pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
2750        let mut out = Vec::new();
2751        for stack in &self.inventory_stacks {
2752            if stack.template_id == KEY_TEMPLATE {
2753                out.push(KeychainEntry {
2754                    stack: stack.clone(),
2755                    stowed: false,
2756                });
2757            }
2758        }
2759        for stack in &self.keychain_stacks {
2760            if stack.template_id == KEY_TEMPLATE {
2761                out.push(KeychainEntry {
2762                    stack: stack.clone(),
2763                    stowed: true,
2764                });
2765            }
2766        }
2767        out
2768    }
2769
2770    /// Display name of the chest a `container_key` opens, when known on the client.
2771    pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
2772        if stack.template_id != KEY_TEMPLATE {
2773            return None;
2774        }
2775        if let Some(name) = stack
2776            .props
2777            .get(PROP_OPENS_CONTAINER_NAME)
2778            .filter(|n| !n.is_empty())
2779        {
2780            return Some(name.clone());
2781        }
2782        let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
2783        self.container_name_for_lock_id(opens)
2784    }
2785
2786    /// Keys always show the catalog name — never a chest rename or stray `custom_name`.
2787    pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
2788        if stack.template_id == KEY_TEMPLATE {
2789            self.template_display_name(KEY_TEMPLATE)
2790        } else {
2791            stack
2792                .display_name
2793                .clone()
2794                .unwrap_or_else(|| stack.template_id.clone())
2795        }
2796    }
2797
2798    /// Hint suffix for a key row (`[key for …]` or `[key — unpaired]`).
2799    pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
2800        if stack.template_id != KEY_TEMPLATE {
2801            return String::new();
2802        }
2803        match self.key_pair_chest_label(stack) {
2804            Some(chest) if self.key_drop_blocked(stack) => {
2805                format!(" [key for {chest} — can't drop while locked]")
2806            }
2807            Some(chest) => format!(" [key for {chest}]"),
2808            None => " [key — unpaired]".into(),
2809        }
2810    }
2811
2812    /// Resolve a lock id to a container label (placed chest or one still on your person).
2813    pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
2814        for c in &self.placed_containers {
2815            if c.lock_id.as_deref() == Some(lock) {
2816                return Some(c.display_name.clone());
2817            }
2818        }
2819        Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
2820            self.worn
2821                .values()
2822                .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
2823        })
2824    }
2825
2826    /// Keys cannot be dropped while their paired chest is locked.
2827    pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
2828        if stack.template_id != KEY_TEMPLATE {
2829            return false;
2830        }
2831        let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
2832            return false;
2833        };
2834        for c in &self.placed_containers {
2835            if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
2836                return true;
2837            }
2838        }
2839        if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
2840            return true;
2841        }
2842        self.worn
2843            .values()
2844            .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
2845    }
2846
2847    fn container_name_in_stacks(
2848        stacks: &[flatland_protocol::ItemStack],
2849        lock: &str,
2850    ) -> Option<String> {
2851        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
2852            for s in stacks {
2853                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
2854                    return Some(GameState::stack_container_label(s));
2855                }
2856                if let Some(name) = walk(&s.contents, lock) {
2857                    return Some(name);
2858                }
2859            }
2860            None
2861        }
2862        walk(stacks, lock)
2863    }
2864
2865    fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
2866        stack
2867            .props
2868            .get(PROP_CUSTOM_NAME)
2869            .cloned()
2870            .or_else(|| stack.display_name.clone())
2871            .unwrap_or_else(|| stack.template_id.clone())
2872    }
2873
2874    fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
2875        fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
2876            for s in stacks {
2877                if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
2878                    return true;
2879                }
2880                if walk(&s.contents, lock) {
2881                    return true;
2882                }
2883            }
2884            false
2885        }
2886        walk(stacks, lock)
2887    }
2888
2889    fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
2890        if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
2891            return Some(stack.clone());
2892        }
2893        for worn in self.worn.values() {
2894            if worn.item_instance_id == Some(instance_id) {
2895                return Some(worn.clone());
2896            }
2897            if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
2898                return Some(stack.clone());
2899            }
2900        }
2901        None
2902    }
2903
2904    /// Terrain, interactables, and map objects near the player for the HUD location panel.
2905    pub fn location_context_lines(&self) -> Vec<ContextLine> {
2906        let (px, py) = self.player_position();
2907        let inside = self.effective_inside_building();
2908        let mut lines = Vec::new();
2909
2910        if let Some(kind) = self.terrain_at(px, py) {
2911            lines.push(ContextLine {
2912                on_top: true,
2913                text: format!("Terrain: {}", terrain_kind_label(kind)),
2914            });
2915        }
2916
2917        if let Some(id) = inside.as_ref() {
2918            if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
2919                lines.push(ContextLine {
2920                    on_top: true,
2921                    text: format!("Inside: {}", b.label),
2922                });
2923            }
2924        }
2925
2926        let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
2927
2928        for node in &self.resource_nodes {
2929            let dist = distance(px, py, node.x, node.y);
2930            if dist > NEARBY_SCAN_M {
2931                continue;
2932            }
2933            let on_top = dist <= ON_TOP_RADIUS_M;
2934            let state = match node.state {
2935                flatland_protocol::ResourceNodeState::Available => " — f harvest",
2936                flatland_protocol::ResourceNodeState::Harvesting => " (being harvested)",
2937                flatland_protocol::ResourceNodeState::Cooldown => " (depleted)",
2938            };
2939            let prefix = if on_top { "On" } else { "Near" };
2940            nearby.push((
2941                dist,
2942                ContextLine {
2943                    on_top,
2944                    text: format!("{prefix}: {} ({dist:.1}m){state}", node.label),
2945                },
2946            ));
2947        }
2948
2949        for drop in &self.ground_drops {
2950            let dist = distance(px, py, drop.x, drop.y);
2951            if dist > INTERACTION_RADIUS_M {
2952                continue;
2953            }
2954            let on_top = dist <= ON_TOP_RADIUS_M;
2955            let name = self.template_display_name(&drop.template_id);
2956            let prefix = if on_top { "On" } else { "Near" };
2957            let qty = if drop.quantity > 1 {
2958                format!(" ×{}", drop.quantity)
2959            } else {
2960                String::new()
2961            };
2962            nearby.push((
2963                dist,
2964                ContextLine {
2965                    on_top,
2966                    text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
2967                },
2968            ));
2969        }
2970
2971        for c in &self.placed_containers {
2972            let dist = distance(px, py, c.x, c.y);
2973            if dist > CONTAINER_RANGE_M {
2974                continue;
2975            }
2976            let on_top = dist <= ON_TOP_RADIUS_M;
2977            let name = self.placed_container_public_label(c);
2978            let lock = if c.locked { " [locked]" } else { "" };
2979            let prefix = if on_top { "On" } else { "Near" };
2980            nearby.push((
2981                dist,
2982                ContextLine {
2983                    on_top,
2984                    text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
2985                },
2986            ));
2987        }
2988
2989        for npc in &self.npcs {
2990            let dist = distance(px, py, npc.x, npc.y);
2991            if dist > NEARBY_SCAN_M {
2992                continue;
2993            }
2994            let on_top = dist <= ON_TOP_RADIUS_M;
2995            let prefix = if on_top { "On" } else { "Near" };
2996            nearby.push((
2997                dist,
2998                ContextLine {
2999                    on_top,
3000                    text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
3001                },
3002            ));
3003        }
3004
3005        for door in &self.doors {
3006            let dist = distance(px, py, door.x, door.y);
3007            if dist > DOOR_INTERACTION_RADIUS_M {
3008                continue;
3009            }
3010            let building = self
3011                .buildings
3012                .iter()
3013                .find(|b| b.id == door.building_id)
3014                .map(|b| b.label.as_str())
3015                .unwrap_or(door.building_id.as_str());
3016            let action = if inside.is_some() && door.portal.is_some() {
3017                "exit"
3018            } else {
3019                "enter"
3020            };
3021            nearby.push((
3022                dist,
3023                ContextLine {
3024                    on_top: dist <= ON_TOP_RADIUS_M,
3025                    text: format!("{building} door ({dist:.1}m) — f {action}"),
3026                },
3027            ));
3028        }
3029
3030        if inside.is_none() {
3031            for inter in &self.interactables {
3032                if inter.kind != "quest_board" {
3033                    continue;
3034                }
3035                let dist = distance(px, py, inter.x, inter.y);
3036                if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
3037                    continue;
3038                }
3039                let on_top = dist <= ON_TOP_RADIUS_M;
3040                let prefix = if on_top { "On" } else { "Near" };
3041                let label = if inter.label.is_empty() {
3042                    "Quest board".to_string()
3043                } else {
3044                    inter.label.clone()
3045                };
3046                nearby.push((
3047                    dist,
3048                    ContextLine {
3049                        on_top,
3050                        text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
3051                    },
3052                ));
3053            }
3054        }
3055
3056        if self.in_shallow_water() {
3057            let already = self
3058                .terrain_at(px, py)
3059                .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
3060            if !already {
3061                nearby.push((
3062                    0.0,
3063                    ContextLine {
3064                        on_top: true,
3065                        text: "Shallow water — f fill bottle".into(),
3066                    },
3067                ));
3068            } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
3069                line.text.push_str(" — f fill bottle");
3070            }
3071        }
3072
3073        for entity in &self.entities {
3074            if entity.id == self.entity_id {
3075                continue;
3076            }
3077            let dist = distance(
3078                px,
3079                py,
3080                entity.transform.position.x,
3081                entity.transform.position.y,
3082            );
3083            if dist > NEARBY_SCAN_M {
3084                continue;
3085            }
3086            let label = if entity.label.is_empty() {
3087                format!("entity {}", entity.id)
3088            } else {
3089                entity.label.clone()
3090            };
3091            nearby.push((
3092                dist,
3093                ContextLine {
3094                    on_top: dist <= ON_TOP_RADIUS_M,
3095                    text: format!("Near: {label} ({dist:.1}m)"),
3096                },
3097            ));
3098        }
3099
3100        nearby.sort_by(|a, b| {
3101            a.0.partial_cmp(&b.0)
3102                .unwrap_or(std::cmp::Ordering::Equal)
3103                .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
3104        });
3105        lines.extend(nearby.into_iter().map(|(_, l)| l));
3106
3107        if lines.is_empty() {
3108            lines.push(ContextLine {
3109                on_top: false,
3110                text: "(nothing notable nearby)".into(),
3111            });
3112        }
3113
3114        lines
3115    }
3116}
3117
3118/// HUD line for the location / nearby panel.
3119#[derive(Debug, Clone)]
3120pub struct ContextLine {
3121    pub on_top: bool,
3122    pub text: String,
3123}
3124
3125const ON_TOP_RADIUS_M: f32 = 0.65;
3126const NEARBY_SCAN_M: f32 = 5.0;
3127
3128fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
3129    use flatland_protocol::TerrainKindView;
3130    match kind {
3131        TerrainKindView::Grass => "Grass",
3132        TerrainKindView::Hill => "Hills",
3133        TerrainKindView::Bog => "Bog",
3134        TerrainKindView::ShallowWater => "Shallow water",
3135        TerrainKindView::DeepWater => "Deep water",
3136        TerrainKindView::Trail => "Trail",
3137        TerrainKindView::Rock => "Rock",
3138    }
3139}
3140
3141fn humanize_template_id(template_id: &str) -> String {
3142    template_id
3143        .split('_')
3144        .map(|word| {
3145            let mut chars = word.chars();
3146            match chars.next() {
3147                None => String::new(),
3148                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
3149            }
3150        })
3151        .collect::<Vec<_>>()
3152        .join(" ")
3153}
3154
3155/// Keep in sync with `flatland_sim::INTERACTION_RADIUS_M`.
3156const HARVEST_RANGE_M: f32 = 1.5;
3157
3158pub struct GameClient<S: PlayConnection> {
3159    session: S,
3160    seq: Seq,
3161    pub state: GameState,
3162    last_move_forward: f32,
3163    last_move_strafe: f32,
3164}
3165
3166impl<S: PlayConnection> GameClient<S> {
3167    pub fn new(session: S) -> Self {
3168        let session_id = session.session_id();
3169        let entity_id = session.entity_id();
3170        Self {
3171            session,
3172            seq: 0,
3173            last_move_forward: 0.0,
3174            last_move_strafe: 0.0,
3175            state: GameState {
3176                session_id,
3177                entity_id,
3178                character_id: None,
3179                tick: 0,
3180                chunk_rev: 0,
3181                content_rev: 0,
3182                publish_rev: 0,
3183                entities: Vec::new(),
3184                player: None,
3185                resource_nodes: Vec::new(),
3186                ground_drops: Vec::new(),
3187                placed_containers: Vec::new(),
3188                buildings: Vec::new(),
3189                doors: Vec::new(),
3190                interior_map: None,
3191                npcs: Vec::new(),
3192                blueprints: Vec::new(),
3193                world_width_m: 0.0,
3194                world_height_m: 0.0,
3195                terrain_zones: Vec::new(),
3196                z_platforms: Vec::new(),
3197                z_transitions: Vec::new(),
3198                world_clock: flatland_protocol::WorldClock::default(),
3199                inventory: std::collections::HashMap::new(),
3200                inventory_hints: std::collections::HashMap::new(),
3201                logs: VecDeque::new(),
3202                intents_sent: 0,
3203                ticks_received: 0,
3204                connected: false,
3205                disconnect_reason: None,
3206                show_stats: false,
3207                show_craft_menu: false,
3208                craft_menu_index: 0,
3209                craft_batch_quantity: 1,
3210                show_shop_menu: false,
3211                shop_catalog: None,
3212                shop_tab: ShopTab::default(),
3213                shop_menu_index: 0,
3214                shop_quantity: 1,
3215                shop_trade_log: VecDeque::new(),
3216                show_npc_verb_menu: false,
3217                npc_verb_target: None,
3218                npc_verb_index: 0,
3219                show_npc_chat: false,
3220                npc_chat: None,
3221                show_inventory_menu: false,
3222                inventory_menu_index: 0,
3223                show_move_picker: false,
3224                show_rename_prompt: false,
3225                show_worker_rename: false,
3226                rename_buffer: String::new(),
3227                move_picker_index: 0,
3228                move_picker: None,
3229                show_destroy_picker: false,
3230                destroy_confirm_pending: false,
3231                destroy_picker: None,
3232                combat_target: None,
3233                combat_target_label: None,
3234                in_combat: false,
3235                auto_attack: true,
3236                combat_has_los: false,
3237                attack_cd_ticks: 0,
3238                gcd_ticks: 0,
3239                weapon_ability_id: "unarmed".into(),
3240                mainhand_template_id: None,
3241                mainhand_label: None,
3242                worn: BTreeMap::new(),
3243                carry_mass: 0.0,
3244                carry_mass_max: 0.0,
3245                encumbrance: flatland_protocol::EncumbranceState::Light,
3246                inventory_stacks: Vec::new(),
3247                keychain_stacks: Vec::new(),
3248                combat_target_detail: None,
3249                cast_progress: None,
3250                ability_cooldowns: Vec::new(),
3251                blocking_active: false,
3252                max_target_slots: 1,
3253                combat_slots: Vec::new(),
3254                rotation_presets: Vec::new(),
3255                show_loadout_menu: false,
3256                show_keychain_menu: false,
3257                keychain_menu_index: 0,
3258                show_rotation_editor: false,
3259                loadout_menu_index: 0,
3260                rotation_editor: RotationEditorState::default(),
3261                harvest_in_progress: false,
3262                harvest_started_at: None,
3263                pending_craft_ack: None,
3264                pending_worker_job_ack: None,
3265                quest_log: Vec::new(),
3266                interactables: Vec::new(),
3267                show_quest_offer: false,
3268                pending_quest_offer: None,
3269                show_quest_menu: false,
3270                quest_menu_index: 0,
3271                quest_withdraw_confirm: false,
3272                hired_workers: Vec::new(),
3273                show_workers_menu: false,
3274                workers_menu_index: 0,
3275                workers_menu_compact: false,
3276                worker_step_display: BTreeMap::new(),
3277                show_worker_give_picker: false,
3278                worker_give_picker_index: 0,
3279                worker_give_picker: None,
3280                show_worker_give_target_picker: false,
3281                worker_give_target_picker_index: 0,
3282                worker_give_target_picker: None,
3283                show_worker_take_picker: false,
3284                worker_take_picker_index: 0,
3285                worker_take_picker: None,
3286                show_worker_teach_picker: false,
3287                worker_teach_picker_index: 0,
3288                worker_teach_picker: None,
3289                worker_route_editor: None,
3290                progression_curve: None,
3291            },
3292        }
3293    }
3294
3295    pub fn entity_id(&self) -> EntityId {
3296        self.state.entity_id
3297    }
3298
3299    pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
3300        if self.state.connected {
3301            return Ok(());
3302        }
3303
3304        loop {
3305            match self.session.next_event().await {
3306                Some(SessionEvent::Welcome {
3307                    session_id,
3308                    entity_id,
3309                    snapshot,
3310                }) => {
3311                    self.state
3312                        .restore_from_welcome(session_id, entity_id, &snapshot);
3313                    self.state.push_log(format!(
3314                        "Connected — session {session_id}, entity {entity_id}"
3315                    ));
3316                    return Ok(());
3317                }
3318                Some(SessionEvent::Disconnected { .. }) => {
3319                    anyhow::bail!("disconnected before welcome");
3320                }
3321                Some(_) => continue,
3322                None => anyhow::bail!("session closed before welcome"),
3323            }
3324        }
3325    }
3326
3327    /// Drain all pending server events (non-blocking).
3328    pub fn drain_events(&mut self) {
3329        while let Some(event) = self.session.try_next_event() {
3330            if self.handle_event_sync(event).is_err() {
3331                break;
3332            }
3333        }
3334    }
3335
3336    /// Wait for the next server event.
3337    pub async fn next_event(&mut self) -> Option<SessionEvent> {
3338        self.session.next_event().await
3339    }
3340
3341    pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
3342        self.handle_event_sync(event)
3343    }
3344
3345    fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
3346        match event {
3347            SessionEvent::Welcome {
3348                session_id,
3349                entity_id,
3350                snapshot,
3351            } => {
3352                let resumed = self.state.connected;
3353                self.state
3354                    .restore_from_welcome(session_id, entity_id, &snapshot);
3355                if resumed {
3356                    self.state.push_log(format!(
3357                        "Session restored — session {session_id}, entity {entity_id}"
3358                    ));
3359                }
3360            }
3361            SessionEvent::ContentUpdated { snapshot } => {
3362                self.state
3363                    .apply_snapshot_fields(&snapshot, self.state.entity_id);
3364                self.state.push_log(format!(
3365                    "World updated (content rev {})",
3366                    snapshot.content_rev
3367                ));
3368            }
3369            SessionEvent::Tick(delta) => {
3370                self.state.apply_tick_fields(&delta, self.state.entity_id);
3371                self.state.ticks_received += 1;
3372            }
3373            SessionEvent::IntentAck {
3374                entity_id,
3375                seq,
3376                tick,
3377            } => {
3378                crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
3379                if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
3380                    if *craft_seq == seq {
3381                        let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
3382                        if batches > 1 {
3383                            self.state.push_log(format!("Crafting {label} ×{batches}…"));
3384                        } else {
3385                            self.state.push_log(format!("Crafting {label}…"));
3386                        }
3387                    }
3388                }
3389                if self
3390                    .state
3391                    .pending_worker_job_ack
3392                    .as_ref()
3393                    .is_some_and(|p| p.seq == seq)
3394                {
3395                    let pending = self.state.pending_worker_job_ack.take().unwrap();
3396                    if pending.idle {
3397                        self.state.push_log(format!(
3398                            "Route cleared for {} — worker idle",
3399                            pending.worker_label
3400                        ));
3401                    } else {
3402                        self.state.push_log(format!(
3403                            "Route saved for {} — {} stop(s), job loop active",
3404                            pending.worker_label, pending.stop_count
3405                        ));
3406                    }
3407                    if self
3408                        .state
3409                        .worker_route_editor
3410                        .as_ref()
3411                        .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
3412                    {
3413                        self.close_worker_route_editor();
3414                    }
3415                }
3416            }
3417            SessionEvent::Chat(msg) => {
3418                let label = match msg.channel {
3419                    flatland_protocol::ChatChannel::Nearby => "nearby",
3420                    flatland_protocol::ChatChannel::WhisperStone => "whisper",
3421                };
3422                self.state
3423                    .push_log(format!("[{label}] {}: {}", msg.from_name, msg.text));
3424            }
3425            SessionEvent::HarvestResult(result) => {
3426                self.state.clear_harvest_state();
3427                crate::harvest_trace!(
3428                    entity_id = self.state.entity_id,
3429                    node_id = %result.node_id,
3430                    template = %result.item_template,
3431                    quantity = result.quantity,
3432                    client_tick = self.state.tick,
3433                    "client applied harvest result"
3434                );
3435                self.state.push_log(format!(
3436                    "Harvested {} x{} (on the ground — press P to pick up)",
3437                    result.item_template, result.quantity
3438                ));
3439            }
3440            SessionEvent::CraftResult(result) => {
3441                for stack in &result.consumed {
3442                    if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
3443                        *qty = qty.saturating_sub(stack.quantity);
3444                        if *qty == 0 {
3445                            self.state.inventory.remove(&stack.template_id);
3446                        }
3447                    }
3448                }
3449                for stack in &result.outputs {
3450                    *self
3451                        .state
3452                        .inventory
3453                        .entry(stack.template_id.clone())
3454                        .or_insert(0) += stack.quantity;
3455                }
3456                if let Some(output) = result.outputs.first() {
3457                    if result.batch_total > 1 {
3458                        self.state.push_log(format!(
3459                            "Crafted {} x{} ({}/{})",
3460                            output.template_id,
3461                            output.quantity,
3462                            result.batch_index,
3463                            result.batch_total
3464                        ));
3465                    } else {
3466                        self.state.push_log(format!(
3467                            "Crafted {} x{}",
3468                            output.template_id, output.quantity
3469                        ));
3470                    }
3471                } else {
3472                    self.state
3473                        .push_log(format!("Craft finished: {}", result.blueprint_id));
3474                }
3475            }
3476            SessionEvent::Death(notice) => {
3477                self.state.clear_harvest_state();
3478                self.state.push_log(notice.message.clone());
3479                self.state.push_log(format!(
3480                    "Respawned at ({:.1}, {:.1})",
3481                    notice.respawn_x, notice.respawn_y
3482                ));
3483            }
3484            SessionEvent::Interaction(notice) => {
3485                if notice.message.starts_with("Harvest failed:") {
3486                    self.state.clear_harvest_state();
3487                }
3488                if notice.message.starts_with("Can't do that:") {
3489                    self.state.pending_craft_ack = None;
3490                    if let Some(pending) = self.state.pending_worker_job_ack.take() {
3491                        if let Some(w) = self
3492                            .state
3493                            .hired_workers
3494                            .iter_mut()
3495                            .find(|w| w.instance_id == pending.worker_instance_id)
3496                        {
3497                            w.route = pending.prev_route;
3498                            w.mode = pending.prev_mode;
3499                            w.step_label = pending.prev_step_label;
3500                            w.last_error = pending.prev_last_error;
3501                        }
3502                        let reason = notice
3503                            .message
3504                            .strip_prefix("Can't do that:")
3505                            .unwrap_or(&notice.message)
3506                            .trim();
3507                        self.state.push_log(format!(
3508                            "Route save failed for {}: {reason}",
3509                            pending.worker_label
3510                        ));
3511                    }
3512                }
3513                if notice.message.starts_with("Cast failed:") {
3514                    self.state.cast_progress = None;
3515                }
3516                if notice.message.contains("slain the") {
3517                    self.state.combat_target = None;
3518                    self.state.combat_target_label = None;
3519                }
3520                self.state.apply_interaction_notice(&notice);
3521                self.state.push_log(notice.message.clone());
3522            }
3523            SessionEvent::ShopOpened(catalog) => {
3524                self.state.apply_shop_catalog(catalog);
3525            }
3526            SessionEvent::NpcTalkOpened(opened) => {
3527                self.state.show_npc_verb_menu = false;
3528                if self.state.npc_verb_target.is_none() {
3529                    self.state.npc_verb_target = Some(opened.npc_id.clone());
3530                }
3531                let label = opened.npc_label.clone();
3532                let banner = if !opened.trade_allowed {
3533                    Some("Trade is unavailable right now.".to_string())
3534                } else {
3535                    None
3536                };
3537                self.state.show_npc_chat = true;
3538                self.state.npc_chat = Some(NpcChatState {
3539                    npc_id: opened.npc_id,
3540                    npc_label: opened.npc_label,
3541                    lines: if opened.greeting.is_empty() {
3542                        vec![]
3543                    } else {
3544                        vec![format!("{label}: {}", opened.greeting)]
3545                    },
3546                    input: String::new(),
3547                    pending: opened.greeting.is_empty(),
3548                    talk_depth: opened.talk_depth,
3549                    trade_allowed: opened.trade_allowed,
3550                    banner,
3551                });
3552            }
3553            SessionEvent::NpcTalkPending(_) => {
3554                if let Some(chat) = self.state.npc_chat.as_mut() {
3555                    chat.pending = true;
3556                }
3557            }
3558            SessionEvent::NpcTalkReply(reply) => {
3559                if let Some(chat) = self.state.npc_chat.as_mut() {
3560                    if chat.npc_id == reply.npc_id {
3561                        chat.pending = false;
3562                        if reply.trade_disabled {
3563                            chat.trade_allowed = false;
3564                            chat.banner = Some("Trade is unavailable right now.".to_string());
3565                        }
3566                        if reply.wind_down {
3567                            chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
3568                            if chat.banner.is_none() {
3569                                chat.banner =
3570                                    Some("They're wrapping up — keep it brief.".to_string());
3571                            }
3572                        }
3573                        chat.lines
3574                            .push(format!("{}: {}", chat.npc_label, reply.line));
3575                    }
3576                }
3577            }
3578            SessionEvent::NpcTalkClosed(closed) => {
3579                if self
3580                    .state
3581                    .npc_chat
3582                    .as_ref()
3583                    .is_some_and(|c| c.npc_id == closed.npc_id)
3584                {
3585                    self.state.show_npc_chat = false;
3586                    self.state.npc_chat = None;
3587                }
3588            }
3589            SessionEvent::NpcTalkError(err) => {
3590                self.state.push_log(format!("Talk failed: {}", err.reason));
3591                if let Some(chat) = self.state.npc_chat.as_mut() {
3592                    chat.pending = false;
3593                }
3594            }
3595            SessionEvent::UseResult(result) => {
3596                // Inventory count hint only — the Interaction notice already logs
3597                // "Consumed …" (and drives the gfx toast). Logging again here doubled toasts.
3598                if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
3599                    *qty = qty.saturating_sub(1);
3600                    if *qty == 0 {
3601                        self.state.inventory.remove(&result.template_id);
3602                    }
3603                }
3604            }
3605            SessionEvent::QuestOffer(offer) => {
3606                self.state.pending_quest_offer = Some(offer.clone());
3607                self.state.show_quest_offer = true;
3608                self.state
3609                    .push_log(format!("Quest offered: {}", offer.title));
3610            }
3611            SessionEvent::QuestAccepted(notice) => {
3612                self.state.show_quest_offer = false;
3613                self.state.pending_quest_offer = None;
3614                self.state.push_log(notice.message);
3615            }
3616            SessionEvent::QuestWithdrawn(notice) => {
3617                self.state.show_quest_menu = false;
3618                self.state.quest_withdraw_confirm = false;
3619                self.state.push_log(notice.message);
3620            }
3621            SessionEvent::QuestStepCompleted(notice) => {
3622                self.state.push_log(notice.message);
3623            }
3624            SessionEvent::QuestCompleted(notice) => {
3625                self.state.push_log(notice.message);
3626            }
3627            SessionEvent::Disconnected { reason } => {
3628                self.state.clear_harvest_state();
3629                self.state.connected = false;
3630                self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
3631                if let Some(r) = &self.state.disconnect_reason {
3632                    self.state.push_log(format!("Disconnected: {r}"));
3633                } else {
3634                    self.state.push_log("Disconnected from server");
3635                }
3636            }
3637        }
3638        Ok(())
3639    }
3640
3641    pub fn is_connected(&self) -> bool {
3642        self.state.connected
3643    }
3644
3645    pub fn close_overlays(&mut self) {
3646        self.state.show_stats = false;
3647        self.state.show_craft_menu = false;
3648        self.state.show_shop_menu = false;
3649        self.state.shop_catalog = None;
3650        self.state.show_npc_verb_menu = false;
3651        self.state.npc_verb_target = None;
3652        self.state.show_npc_chat = false;
3653        self.state.npc_chat = None;
3654        self.state.show_inventory_menu = false;
3655        self.state.show_loadout_menu = false;
3656        self.state.show_rotation_editor = false;
3657        self.state.rotation_editor.reset();
3658        self.state.show_rename_prompt = false;
3659        self.state.show_worker_rename = false;
3660        self.state.rename_buffer.clear();
3661        self.state.show_move_picker = false;
3662        self.state.move_picker = None;
3663        self.state.show_destroy_picker = false;
3664        self.state.destroy_confirm_pending = false;
3665        self.state.destroy_picker = None;
3666        self.state.show_quest_offer = false;
3667        self.state.pending_quest_offer = None;
3668        self.state.show_quest_menu = false;
3669        self.state.quest_withdraw_confirm = false;
3670        self.state.show_workers_menu = false;
3671        self.close_worker_give_picker();
3672        self.close_worker_give_target_picker();
3673        self.close_worker_take_picker();
3674        self.close_worker_teach_picker();
3675        self.state.worker_route_editor = None;
3676    }
3677
3678    /// Esc / back — pop one UI layer instead of closing every overlay at once.
3679    pub fn back_on_esc(&mut self) -> bool {
3680        if self.state.show_rename_prompt {
3681            self.cancel_rename_prompt();
3682            return true;
3683        }
3684        if self.state.show_worker_rename {
3685            self.cancel_worker_rename();
3686            return true;
3687        }
3688        if self.state.show_destroy_picker {
3689            if self.state.destroy_confirm_pending {
3690                self.cancel_destroy_confirm();
3691            } else {
3692                self.close_destroy_picker();
3693            }
3694            return true;
3695        }
3696        if self.state.show_move_picker {
3697            self.close_move_picker();
3698            return true;
3699        }
3700        if self.state.show_rotation_editor {
3701            match self.state.rotation_editor.mode {
3702                RotationEditorMode::List => {
3703                    self.state.show_rotation_editor = false;
3704                    self.state.rotation_editor.reset();
3705                }
3706                RotationEditorMode::EditLabel => {
3707                    self.state.rotation_editor.label_buffer.clear();
3708                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
3709                }
3710                RotationEditorMode::PickAbility => {
3711                    self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
3712                }
3713                RotationEditorMode::EditSequence => {
3714                    self.state.rotation_editor.draft = None;
3715                    self.state.rotation_editor.mode = RotationEditorMode::List;
3716                }
3717            }
3718            return true;
3719        }
3720        if self.state.show_inventory_menu {
3721            self.close_inventory_menu();
3722            return true;
3723        }
3724        if self.state.show_craft_menu {
3725            self.close_craft_menu();
3726            return true;
3727        }
3728        if self.state.show_quest_offer {
3729            self.quest_offer_decline();
3730            return true;
3731        }
3732        if self.state.show_shop_menu {
3733            self.back_from_shop_menu();
3734            return true;
3735        }
3736        if self.state.show_npc_chat {
3737            // play.rs calls npc_interaction_back().await on Esc
3738            return false;
3739        }
3740        if self.state.show_npc_verb_menu {
3741            self.state.show_npc_verb_menu = false;
3742            self.state.npc_verb_target = None;
3743            return true;
3744        }
3745        if self.state.show_quest_menu {
3746            if self.state.quest_withdraw_confirm {
3747                self.state.quest_withdraw_confirm = false;
3748            } else {
3749                self.state.show_quest_menu = false;
3750            }
3751            return true;
3752        }
3753        if self.state.worker_route_editor.is_some() {
3754            // Pop one sheet level; only close the editor at the root stop list.
3755            if self.re_at_root_sheet() {
3756                self.close_worker_route_editor();
3757            } else {
3758                self.re_sheet_back();
3759            }
3760            return true;
3761        }
3762        if self.state.show_worker_give_picker {
3763            self.close_worker_give_picker();
3764            return true;
3765        }
3766        if self.state.show_worker_give_target_picker {
3767            self.close_worker_give_target_picker();
3768            return true;
3769        }
3770        if self.state.show_worker_take_picker {
3771            self.close_worker_take_picker();
3772            return true;
3773        }
3774        if self.state.show_worker_teach_picker {
3775            self.close_worker_teach_picker();
3776            return true;
3777        }
3778        if self.state.show_workers_menu {
3779            self.state.show_workers_menu = false;
3780            return true;
3781        }
3782        if self.state.show_loadout_menu {
3783            self.state.show_loadout_menu = false;
3784            return true;
3785        }
3786        if self.state.show_stats {
3787            self.state.show_stats = false;
3788            return true;
3789        }
3790        false
3791    }
3792
3793    pub fn toggle_stats(&mut self) {
3794        self.state.show_stats = !self.state.show_stats;
3795        if self.state.show_stats {
3796            self.state.show_craft_menu = false;
3797            self.state.show_shop_menu = false;
3798            self.state.shop_catalog = None;
3799            self.state.show_inventory_menu = false;
3800        }
3801    }
3802
3803    pub fn open_inventory_menu(&mut self) {
3804        self.state.show_inventory_menu = true;
3805        self.state.show_craft_menu = false;
3806        self.state.show_shop_menu = false;
3807        self.state.shop_catalog = None;
3808        self.state.show_stats = false;
3809        self.state.show_move_picker = false;
3810        self.state.move_picker = None;
3811        self.state.show_destroy_picker = false;
3812        self.state.destroy_confirm_pending = false;
3813        self.state.destroy_picker = None;
3814        self.state.show_rename_prompt = false;
3815        self.state.rename_buffer.clear();
3816        self.state.clamp_inventory_indices();
3817    }
3818
3819    pub fn close_inventory_menu(&mut self) {
3820        self.state.show_inventory_menu = false;
3821        self.state.show_move_picker = false;
3822        self.state.move_picker = None;
3823        self.state.show_destroy_picker = false;
3824        self.state.destroy_confirm_pending = false;
3825        self.state.destroy_picker = None;
3826        self.state.show_rename_prompt = false;
3827        self.state.rename_buffer.clear();
3828    }
3829
3830    pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
3831        let Some(row) = self.state.inventory_selected_row() else {
3832            anyhow::bail!("inventory empty");
3833        };
3834        if !self.state.row_is_renameable_container(&row) {
3835            anyhow::bail!("only storage containers can be renamed");
3836        }
3837        let current = row
3838            .stack
3839            .display_name
3840            .clone()
3841            .unwrap_or_else(|| row.stack.template_id.clone());
3842        self.state.rename_buffer = current;
3843        self.state.show_rename_prompt = true;
3844        self.state.show_worker_rename = false;
3845        self.state.show_move_picker = false;
3846        self.state.show_destroy_picker = false;
3847        self.state.destroy_confirm_pending = false;
3848        Ok(())
3849    }
3850
3851    pub fn cancel_rename_prompt(&mut self) {
3852        self.state.show_rename_prompt = false;
3853        self.state.rename_buffer.clear();
3854    }
3855
3856    pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
3857        let name = self.state.rename_buffer.trim().to_string();
3858        if name.is_empty() {
3859            anyhow::bail!("name cannot be empty");
3860        }
3861        let Some(row) = self.state.inventory_selected_row() else {
3862            anyhow::bail!("inventory empty");
3863        };
3864        let Some(instance_id) = row.stack.item_instance_id else {
3865            anyhow::bail!("item has no instance id");
3866        };
3867        self.seq += 1;
3868        self.session
3869            .submit_intent(Intent::RenameContainer {
3870                entity_id: self.state.entity_id,
3871                item_instance_id: instance_id,
3872                location: row.from.clone(),
3873                name,
3874                seq: self.seq,
3875            })
3876            .await?;
3877        self.state.intents_sent += 1;
3878        self.state.show_rename_prompt = false;
3879        self.state.rename_buffer.clear();
3880        Ok(())
3881    }
3882
3883    pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
3884        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
3885            anyhow::bail!("no worker selected");
3886        };
3887        self.state.rename_buffer = worker.label.clone();
3888        self.state.show_worker_rename = true;
3889        self.state.show_rename_prompt = false;
3890        Ok(())
3891    }
3892
3893    pub fn cancel_worker_rename(&mut self) {
3894        self.state.show_worker_rename = false;
3895        self.state.rename_buffer.clear();
3896    }
3897
3898    pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
3899        let name = self.state.rename_buffer.trim().to_string();
3900        if name.is_empty() {
3901            anyhow::bail!("name cannot be empty");
3902        }
3903        if name.chars().count() > 32 {
3904            anyhow::bail!("name must be 1–32 characters");
3905        }
3906        let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
3907            anyhow::bail!("no worker selected");
3908        };
3909        let worker_instance_id = worker.instance_id.clone();
3910        self.seq += 1;
3911        self.session
3912            .submit_intent(Intent::RenameHiredWorker {
3913                entity_id: self.state.entity_id,
3914                worker_instance_id: worker_instance_id.clone(),
3915                name: name.clone(),
3916                seq: self.seq,
3917            })
3918            .await?;
3919        self.state.intents_sent += 1;
3920        if let Some(w) = self
3921            .state
3922            .hired_workers
3923            .iter_mut()
3924            .find(|w| w.instance_id == worker_instance_id)
3925        {
3926            w.label = name.clone();
3927        }
3928        if let Some(ed) = self.state.worker_route_editor.as_mut() {
3929            if ed.worker_instance_id == worker_instance_id {
3930                ed.worker_label = name.clone();
3931            }
3932        }
3933        self.state.show_worker_rename = false;
3934        self.state.rename_buffer.clear();
3935        self.state.push_log(format!("Renamed worker to \"{name}\""));
3936        Ok(())
3937    }
3938
3939    pub fn toggle_inventory_menu(&mut self) {
3940        if self.state.show_inventory_menu {
3941            self.close_inventory_menu();
3942        } else {
3943            self.open_inventory_menu();
3944        }
3945    }
3946
3947    /// ↑/↓ in the inventory browser, or within the "move to…" picker when open.
3948    pub fn inventory_menu_move(&mut self, delta: i32) {
3949        if self.state.show_move_picker {
3950            let n = self
3951                .state
3952                .move_picker
3953                .as_ref()
3954                .map(|p| p.options.len())
3955                .unwrap_or(0);
3956            if n == 0 {
3957                return;
3958            }
3959            let idx = self.state.move_picker_index as i32;
3960            self.state.move_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
3961            self.state.clamp_move_picker_quantity();
3962            return;
3963        }
3964        let n = self.state.inventory_selectable_rows().len();
3965        if n == 0 {
3966            return;
3967        }
3968        let idx = self.state.inventory_menu_index as i32;
3969        self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
3970    }
3971
3972    /// Enter: unequip a worn bag, equip a weapon, wear/place a loose bag or
3973    /// chest — or fall back to the "move to…" destination picker (including
3974    /// loose consumables; pick "Use" in that list or press `e` to eat/drink).
3975    /// Placed chest shells open a pick-up destination picker (`l` still locks).
3976    pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
3977        if self.state.show_destroy_picker {
3978            if self.state.destroy_confirm_pending {
3979                return self.confirm_destroy_item().await;
3980            }
3981            return self.request_destroy_confirm();
3982        }
3983        if self.state.show_move_picker {
3984            return self.confirm_move_picker().await;
3985        }
3986        let Some(row) = self.state.inventory_selected_row() else {
3987            anyhow::bail!("inventory empty");
3988        };
3989        if row.is_equip_shell {
3990            let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
3991                anyhow::bail!("not a worn item");
3992            };
3993            return self.equip_worn(slot, None).await;
3994        }
3995        if row.is_chest_shell {
3996            return self.open_chest_pickup_picker();
3997        }
3998        let template_id = row.stack.template_id.clone();
3999        let instance_id = row.stack.item_instance_id;
4000        let category = self.state.inventory_item_category(&template_id);
4001        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
4002
4003        if category == Some("weapon") {
4004            return self.equip_mainhand(Some(template_id)).await;
4005        }
4006        if category == Some("lodging") && on_person {
4007            if let Some(inst) = instance_id {
4008                return self.place_container(inst).await;
4009            }
4010        }
4011        if (category == Some("container") || category == Some("armor")) && on_person {
4012            if let Some(inst) = instance_id {
4013                let world_placeable = row.stack.world_placeable == Some(true)
4014                    || template_id.contains("chest");
4015                if world_placeable {
4016                    return self.place_container(inst).await;
4017                }
4018                // Pouches no longer equip directly — they clip onto a worn belt's loops
4019                // instead, so fall through to the move picker (offers "belt loop" when a
4020                // belt is worn). Backpacks/belts/armor equip straight to their body slot.
4021                if let Some(slot) = guess_body_slot(&template_id) {
4022                    return self.equip_worn(slot, Some(inst)).await;
4023                }
4024            }
4025        }
4026        // Anything else (materials, consumables, pouches, items nested in a bag/chest,
4027        // weapons you'd rather stash than wield, ...) — offer explicit places to move it
4028        // instead of guessing.
4029        self.open_move_picker()
4030    }
4031
4032    /// `e`: eat/drink the selected loose consumable on your person.
4033    pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
4034        let Some(row) = self.state.inventory_selected_row() else {
4035            anyhow::bail!("inventory empty");
4036        };
4037        if row.from != flatland_protocol::InventoryLocation::Root {
4038            anyhow::bail!("select a consumable on your person");
4039        }
4040        let category = self
4041            .state
4042            .inventory_item_category(&row.stack.template_id);
4043        if category != Some("consumable") {
4044            anyhow::bail!("selected item is not consumable");
4045        }
4046        self.use_item(&row.stack.template_id).await
4047    }
4048
4049    /// `m`: always open the "move to…" picker for the selected item, even for
4050    /// weapons/wearables that Enter would otherwise equip/wear directly.
4051    /// Placed chests open the pick-up destination picker instead.
4052    pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
4053        let Some(row) = self.state.inventory_selected_row() else {
4054            anyhow::bail!("inventory empty");
4055        };
4056        if row.is_equip_shell {
4057            anyhow::bail!("this is a worn bag — press Enter to unequip it");
4058        }
4059        if row.is_chest_shell {
4060            return self.open_chest_pickup_picker();
4061        }
4062        let Some(instance_id) = row.stack.item_instance_id else {
4063            anyhow::bail!("item has no instance id");
4064        };
4065        let mut options = self.state.move_destinations_for(
4066            &row.from,
4067            row.from_parent_instance_id,
4068            row.stack.item_instance_id,
4069            &row.stack.template_id,
4070        );
4071        let on_person = row.from == flatland_protocol::InventoryLocation::Root;
4072        let category = self.state.inventory_item_category(&row.stack.template_id);
4073        if on_person && category == Some("consumable") {
4074            options.insert(
4075                0,
4076                MoveOption {
4077                    label: "Use (eat / drink)".into(),
4078                    kind: MoveOptionKind::Use,
4079                },
4080            );
4081        }
4082        let item_label = row
4083            .stack
4084            .display_name
4085            .clone()
4086            .unwrap_or_else(|| row.stack.template_id.clone());
4087        // Default to 1 so withdrawing from storage is partial unless the player
4088        // presses `a` for max fit (full stack when the destination allows it).
4089        let initial_qty = if row.stack.quantity > 1 { 1 } else { row.stack.quantity };
4090        self.state.move_picker = Some(MovePicker {
4091            item_instance_id: instance_id,
4092            from: row.from,
4093            item_label,
4094            template_id: row.stack.template_id.clone(),
4095            stack_quantity: row.stack.quantity,
4096            quantity: initial_qty.max(1),
4097            options,
4098        });
4099        self.state.move_picker_index = 0;
4100        self.state.show_move_picker = true;
4101        self.state.show_destroy_picker = false;
4102        self.state.destroy_confirm_pending = false;
4103        self.state.destroy_picker = None;
4104        self.state.clamp_move_picker_quantity();
4105        Ok(())
4106    }
4107
4108    /// Enter/`m` on a placed chest shell: pick destinations to take it into inventory.
4109    pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
4110        let Some(row) = self.state.inventory_selected_row() else {
4111            anyhow::bail!("inventory empty");
4112        };
4113        if !row.is_chest_shell {
4114            anyhow::bail!("not a placed chest");
4115        }
4116        let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
4117            anyhow::bail!("not a placed chest");
4118        };
4119        let Some(instance_id) = row.stack.item_instance_id else {
4120            anyhow::bail!("chest has no instance id");
4121        };
4122        let chest = self
4123            .state
4124            .placed_containers
4125            .iter()
4126            .find(|c| c.id == *container_id)
4127            .cloned()
4128            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
4129        let (px, py) = self.state.player_position();
4130        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
4131            anyhow::bail!("too far from {}", chest.display_name);
4132        }
4133        if chest.locked && !chest.accessible {
4134            anyhow::bail!(
4135                "need the matching key for {} before picking it up",
4136                chest.display_name
4137            );
4138        }
4139        let options = self.state.chest_pickup_destinations(container_id);
4140        let item_label = row
4141            .stack
4142            .display_name
4143            .clone()
4144            .unwrap_or_else(|| row.stack.template_id.clone());
4145        self.state.move_picker = Some(MovePicker {
4146            item_instance_id: instance_id,
4147            from: row.from.clone(),
4148            item_label,
4149            template_id: row.stack.template_id.clone(),
4150            stack_quantity: 1,
4151            quantity: 1,
4152            options,
4153        });
4154        self.state.move_picker_index = 0;
4155        self.state.show_move_picker = true;
4156        self.state.show_destroy_picker = false;
4157        self.state.destroy_confirm_pending = false;
4158        self.state.destroy_picker = None;
4159        Ok(())
4160    }
4161
4162    pub fn close_move_picker(&mut self) {
4163        self.state.show_move_picker = false;
4164        self.state.move_picker = None;
4165    }
4166
4167    pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
4168        self.state.move_picker_adjust_quantity(delta);
4169    }
4170
4171    pub fn move_picker_set_quantity_max(&mut self) {
4172        self.state.move_picker_set_quantity_max();
4173    }
4174
4175    pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
4176        self.state.destroy_picker_adjust_quantity(delta);
4177    }
4178
4179    pub fn destroy_picker_set_quantity_max(&mut self) {
4180        self.state.destroy_picker_set_quantity_max();
4181    }
4182
4183    async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
4184        let Some(picker) = self.state.move_picker.clone() else {
4185            self.close_move_picker();
4186            return Ok(());
4187        };
4188        let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
4189            self.close_move_picker();
4190            return Ok(());
4191        };
4192        match option.kind {
4193            MoveOptionKind::Cancel => {
4194                self.close_move_picker();
4195            }
4196            MoveOptionKind::Use => {
4197                self.close_move_picker();
4198                self.use_item(&picker.template_id).await?;
4199            }
4200            MoveOptionKind::Drop => {
4201                self.close_move_picker();
4202                if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
4203                    if self.state.key_drop_blocked(&stack) {
4204                        anyhow::bail!("cannot drop the key while its chest is locked");
4205                    }
4206                }
4207                self.drop_item(picker.item_instance_id, picker.from).await?;
4208                self.state
4209                    .push_log(format!("Dropped {}", picker.item_label));
4210            }
4211            MoveOptionKind::PickupPlaced {
4212                container_id,
4213                nest_location,
4214                nest_parent_instance_id,
4215            } => {
4216                self.close_move_picker();
4217                self.pickup_container(container_id.clone()).await?;
4218                let nest_into_bag = nest_parent_instance_id.is_some()
4219                    || !matches!(
4220                        nest_location,
4221                        flatland_protocol::InventoryLocation::Root
4222                    );
4223                if nest_into_bag {
4224                    self.move_item(
4225                        picker.item_instance_id,
4226                        flatland_protocol::InventoryLocation::Root,
4227                        nest_location,
4228                        nest_parent_instance_id,
4229                        None,
4230                    )
4231                    .await?;
4232                    self.state
4233                        .push_log(format!("Picked up {} into bag", picker.item_label));
4234                } else {
4235                    self.state
4236                        .push_log(format!("Picked up {}", picker.item_label));
4237                }
4238            }
4239            MoveOptionKind::Move {
4240                location,
4241                parent_instance_id,
4242            } => {
4243                self.close_move_picker();
4244                let qty = if picker.quantity >= picker.stack_quantity {
4245                    None
4246                } else {
4247                    Some(picker.quantity)
4248                };
4249                self.move_item(
4250                    picker.item_instance_id,
4251                    picker.from,
4252                    location,
4253                    parent_instance_id,
4254                    qty,
4255                )
4256                .await?;
4257                let moved = qty.unwrap_or(picker.stack_quantity);
4258                if moved >= picker.stack_quantity {
4259                    self.state.push_log(format!("Moved {}", picker.item_label));
4260                } else {
4261                    self.state.push_log(format!(
4262                        "Moved {} ×{} of {}",
4263                        picker.item_label, moved, picker.stack_quantity
4264                    ));
4265                }
4266            }
4267        }
4268        Ok(())
4269    }
4270
4271    /// `d`: drop the selected item on the ground immediately (no picker).
4272    pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
4273        let Some(row) = self.state.inventory_selected_row() else {
4274            anyhow::bail!("inventory empty");
4275        };
4276        if row.is_equip_shell {
4277            anyhow::bail!("unequip the bag first (Enter), then drop from your person");
4278        }
4279        if row.is_chest_shell {
4280            anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
4281        }
4282        let Some(inst) = row.stack.item_instance_id else {
4283            anyhow::bail!("item has no instance id");
4284        };
4285        if self.state.key_drop_blocked(&row.stack) {
4286            anyhow::bail!("cannot drop the key while its chest is locked");
4287        }
4288        let label = row
4289            .stack
4290            .display_name
4291            .clone()
4292            .unwrap_or_else(|| row.stack.template_id.clone());
4293        self.drop_item(inst, row.from).await?;
4294        self.state.push_log(format!("Dropped {label}"));
4295        Ok(())
4296    }
4297
4298    pub async fn drop_item(
4299        &mut self,
4300        item_instance_id: uuid::Uuid,
4301        from: flatland_protocol::InventoryLocation,
4302    ) -> anyhow::Result<()> {
4303        self.seq += 1;
4304        self.session
4305            .submit_intent(Intent::DropItem {
4306                entity_id: self.state.entity_id,
4307                item_instance_id,
4308                from,
4309                seq: self.seq,
4310            })
4311            .await?;
4312        self.state.intents_sent += 1;
4313        Ok(())
4314    }
4315
4316    /// `x`: open permanent-delete picker for the selected item (quantity + confirm).
4317    pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
4318        let Some(row) = self.state.inventory_selected_row() else {
4319            anyhow::bail!("inventory empty");
4320        };
4321        if row.is_equip_shell {
4322            anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
4323        }
4324        if row.is_chest_shell {
4325            anyhow::bail!("can't destroy a placed chest from the inventory list");
4326        }
4327        let Some(instance_id) = row.stack.item_instance_id else {
4328            anyhow::bail!("item has no instance id");
4329        };
4330        if self.state.key_drop_blocked(&row.stack) {
4331            anyhow::bail!("cannot destroy the key while its chest is locked");
4332        }
4333        let item_label = row
4334            .stack
4335            .display_name
4336            .clone()
4337            .unwrap_or_else(|| row.stack.template_id.clone());
4338        self.state.destroy_picker = Some(DestroyPicker {
4339            item_instance_id: instance_id,
4340            from: row.from,
4341            item_label,
4342            stack_quantity: row.stack.quantity,
4343            quantity: row.stack.quantity,
4344        });
4345        self.state.destroy_confirm_pending = false;
4346        self.state.show_destroy_picker = true;
4347        self.state.show_move_picker = false;
4348        self.state.move_picker = None;
4349        Ok(())
4350    }
4351
4352    pub fn close_destroy_picker(&mut self) {
4353        self.state.show_destroy_picker = false;
4354        self.state.destroy_confirm_pending = false;
4355        self.state.destroy_picker = None;
4356    }
4357
4358    pub fn cancel_destroy_confirm(&mut self) {
4359        self.state.destroy_confirm_pending = false;
4360    }
4361
4362    pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
4363        if self.state.destroy_picker.is_none() {
4364            self.close_destroy_picker();
4365            return Ok(());
4366        }
4367        self.state.destroy_confirm_pending = true;
4368        Ok(())
4369    }
4370
4371    pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
4372        let Some(picker) = self.state.destroy_picker.clone() else {
4373            self.close_destroy_picker();
4374            return Ok(());
4375        };
4376        let qty = if picker.quantity >= picker.stack_quantity {
4377            None
4378        } else {
4379            Some(picker.quantity)
4380        };
4381        self.destroy_item(picker.item_instance_id, picker.from, qty)
4382            .await?;
4383        let destroyed = qty.unwrap_or(picker.stack_quantity);
4384        if destroyed >= picker.stack_quantity {
4385            self.state
4386                .push_log(format!("Destroyed {}", picker.item_label));
4387        } else {
4388            self.state.push_log(format!(
4389                "Destroyed {} ×{} of {}",
4390                picker.item_label, destroyed, picker.stack_quantity
4391            ));
4392        }
4393        self.close_destroy_picker();
4394        Ok(())
4395    }
4396
4397    pub async fn destroy_item(
4398        &mut self,
4399        item_instance_id: uuid::Uuid,
4400        from: flatland_protocol::InventoryLocation,
4401        quantity: Option<u32>,
4402    ) -> anyhow::Result<()> {
4403        self.seq += 1;
4404        self.session
4405            .submit_intent(Intent::DestroyItem {
4406                entity_id: self.state.entity_id,
4407                item_instance_id,
4408                from,
4409                quantity,
4410                seq: self.seq,
4411            })
4412            .await?;
4413        self.state.intents_sent += 1;
4414        Ok(())
4415    }
4416
4417    /// `l`: lock/unlock a placed chest — selected chest in inventory UI, else nearest.
4418    pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
4419        if let Some(row) = self.state.inventory_selected_row() {
4420            if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
4421                return self.toggle_placed_chest_lock(container_id).await;
4422            }
4423        }
4424        self.toggle_nearby_chest_lock().await
4425    }
4426
4427    pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
4428        let chest = self
4429            .state
4430            .placed_containers
4431            .iter()
4432            .find(|c| c.id == container_id)
4433            .cloned()
4434            .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
4435        let (px, py) = self.state.player_position();
4436        if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
4437            anyhow::bail!("too far from {}", chest.display_name);
4438        }
4439        if !chest.accessible && chest.locked {
4440            anyhow::bail!(
4441                "need the matching key for {} (each crafted chest has its own key)",
4442                chest.display_name
4443            );
4444        }
4445        let lock = !chest.locked;
4446        self.set_container_locked(
4447            flatland_protocol::InventoryLocation::Placed {
4448                container_id: chest.id.clone(),
4449            },
4450            lock,
4451        )
4452        .await?;
4453        self.state.push_log(if lock {
4454            format!("Locked {}", chest.display_name)
4455        } else {
4456            format!("Unlocked {}", chest.display_name)
4457        });
4458        Ok(())
4459    }
4460
4461    /// `l` outside inventory: lock/unlock the nearest placed chest (within `CONTAINER_RANGE_M`).
4462    pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
4463        let chest = self
4464            .state
4465            .nearest_placed_container(CONTAINER_RANGE_M)
4466            .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
4467        self.toggle_placed_chest_lock(&chest.id).await
4468    }
4469
4470    pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
4471        self.equip_mainhand(None).await
4472    }
4473
4474    pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
4475        let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
4476        for slot in slots {
4477            self.equip_worn(slot, None).await?;
4478        }
4479        Ok(())
4480    }
4481
4482    pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
4483        let (px, py) = self.state.player_position();
4484        let nearest = self
4485            .state
4486            .placed_containers
4487            .iter()
4488            .min_by(|a, b| {
4489                let da = (a.x - px).hypot(a.y - py);
4490                let db = (b.x - px).hypot(b.y - py);
4491                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
4492            })
4493            .cloned();
4494        let Some(chest) = nearest else {
4495            anyhow::bail!("no chest nearby");
4496        };
4497        if (chest.x - px).hypot(chest.y - py) > 2.0 {
4498            anyhow::bail!("too far from chest");
4499        }
4500        self.pickup_container(chest.id).await
4501    }
4502
4503    pub async fn equip_worn(
4504        &mut self,
4505        slot: BodySlot,
4506        instance_id: Option<uuid::Uuid>,
4507    ) -> anyhow::Result<()> {
4508        self.seq += 1;
4509        self.session
4510            .submit_intent(Intent::EquipWorn {
4511                entity_id: self.state.entity_id,
4512                slot,
4513                instance_id,
4514                seq: self.seq,
4515            })
4516            .await?;
4517        self.state.intents_sent += 1;
4518        Ok(())
4519    }
4520
4521    pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
4522        self.seq += 1;
4523        self.session
4524            .submit_intent(Intent::PlaceContainer {
4525                entity_id: self.state.entity_id,
4526                item_instance_id,
4527                seq: self.seq,
4528            })
4529            .await?;
4530        self.state.intents_sent += 1;
4531        Ok(())
4532    }
4533
4534    pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
4535        self.seq += 1;
4536        self.session
4537            .submit_intent(Intent::PickupContainer {
4538                entity_id: self.state.entity_id,
4539                container_id,
4540                seq: self.seq,
4541            })
4542            .await?;
4543        self.state.intents_sent += 1;
4544        Ok(())
4545    }
4546
4547    pub async fn move_item(
4548        &mut self,
4549        item_instance_id: uuid::Uuid,
4550        from: flatland_protocol::InventoryLocation,
4551        to: flatland_protocol::InventoryLocation,
4552        to_parent_instance_id: Option<uuid::Uuid>,
4553        quantity: Option<u32>,
4554    ) -> anyhow::Result<()> {
4555        self.seq += 1;
4556        self.session
4557            .submit_intent(Intent::MoveItem {
4558                entity_id: self.state.entity_id,
4559                item_instance_id,
4560                from,
4561                to,
4562                to_parent_instance_id,
4563                quantity,
4564                seq: self.seq,
4565            })
4566            .await?;
4567        self.state.intents_sent += 1;
4568        Ok(())
4569    }
4570
4571    pub async fn set_container_locked(
4572        &mut self,
4573        location: flatland_protocol::InventoryLocation,
4574        locked: bool,
4575    ) -> anyhow::Result<()> {
4576        self.seq += 1;
4577        self.session
4578            .submit_intent(Intent::SetContainerLocked {
4579                entity_id: self.state.entity_id,
4580                location,
4581                locked,
4582                seq: self.seq,
4583            })
4584            .await?;
4585        self.state.intents_sent += 1;
4586        Ok(())
4587    }
4588
4589    pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
4590        if !self.state.is_alive() {
4591            anyhow::bail!("you are dead");
4592        }
4593        self.seq += 1;
4594        self.session
4595            .submit_intent(Intent::Use {
4596                entity_id: self.state.entity_id,
4597                template_id: template_id.to_string(),
4598                seq: self.seq,
4599            })
4600            .await?;
4601        self.state.intents_sent += 1;
4602        Ok(())
4603    }
4604
4605    pub fn open_craft_menu(&mut self) {
4606        self.state.show_craft_menu = true;
4607        self.state.show_shop_menu = false;
4608        self.state.shop_catalog = None;
4609        self.state.show_stats = false;
4610        self.state.show_inventory_menu = false;
4611        if self.state.blueprints.is_empty() {
4612            self.state.craft_menu_index = 0;
4613            self.state.craft_batch_quantity = 1;
4614            return;
4615        }
4616        self.state.craft_menu_index = self
4617            .state
4618            .craft_menu_index
4619            .min(self.state.blueprints.len() - 1);
4620        if let Some(idx) = self
4621            .state
4622            .blueprints
4623            .iter()
4624            .position(|bp| self.state.can_craft_blueprint(bp))
4625        {
4626            self.state.craft_menu_index = idx;
4627        }
4628        self.state.clamp_craft_batch_quantity();
4629    }
4630
4631    pub fn close_craft_menu(&mut self) {
4632        self.state.show_craft_menu = false;
4633    }
4634
4635    pub fn toggle_keychain_menu(&mut self) {
4636        if self.state.show_keychain_menu {
4637            self.close_keychain_menu();
4638        } else {
4639            self.state.show_keychain_menu = true;
4640            self.state.show_craft_menu = false;
4641            self.state.show_shop_menu = false;
4642            self.state.show_inventory_menu = false;
4643            let n = self.state.keychain_entries().len();
4644            if n == 0 {
4645                self.state.keychain_menu_index = 0;
4646            } else {
4647                self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
4648            }
4649        }
4650    }
4651
4652    pub fn close_keychain_menu(&mut self) {
4653        self.state.show_keychain_menu = false;
4654    }
4655
4656    pub fn keychain_menu_move(&mut self, delta: i32) {
4657        let n = self.state.keychain_entries().len();
4658        if n == 0 {
4659            self.state.keychain_menu_index = 0;
4660            return;
4661        }
4662        let idx = self.state.keychain_menu_index as i32 + delta;
4663        self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
4664    }
4665
4666    pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
4667        if !self.state.is_alive() {
4668            anyhow::bail!("you are dead");
4669        }
4670        let entries = self.state.keychain_entries();
4671        let Some(entry) = entries.get(self.state.keychain_menu_index) else {
4672            anyhow::bail!("nothing selected");
4673        };
4674        let Some(instance_id) = entry.stack.item_instance_id else {
4675            anyhow::bail!("key has no instance id");
4676        };
4677        if entry.stowed {
4678            self.move_item(
4679                instance_id,
4680                flatland_protocol::InventoryLocation::Keychain,
4681                flatland_protocol::InventoryLocation::Root,
4682                None,
4683                Some(1),
4684            )
4685            .await
4686        } else {
4687            self.move_item(
4688                instance_id,
4689                flatland_protocol::InventoryLocation::Root,
4690                flatland_protocol::InventoryLocation::Keychain,
4691                None,
4692                Some(1),
4693            )
4694            .await
4695        }
4696    }
4697
4698    pub fn close_shop_menu(&mut self) {
4699        self.state.show_shop_menu = false;
4700        self.state.shop_catalog = None;
4701        self.state.clear_shop_trade_log();
4702    }
4703
4704    /// Close shop and return to the Talk/Trade verb menu when inside an NPC session.
4705    pub fn back_from_shop_menu(&mut self) {
4706        let return_to_verbs = self.state.npc_verb_target.is_some();
4707        self.close_shop_menu();
4708        if return_to_verbs {
4709            self.state.show_npc_verb_menu = true;
4710        }
4711    }
4712
4713    pub fn shop_tab_toggle(&mut self) {
4714        self.state.shop_tab = match self.state.shop_tab {
4715            ShopTab::Buy => ShopTab::Sell,
4716            ShopTab::Sell => ShopTab::Buy,
4717        };
4718        self.state.shop_menu_index = 0;
4719        if self.state.shop_tab == ShopTab::Sell {
4720            self.state.shop_quantity_set_max();
4721        }
4722        self.state.clamp_shop_selection();
4723    }
4724
4725    pub fn shop_menu_move(&mut self, delta: i32) {
4726        self.state.shop_menu_move(delta);
4727    }
4728
4729    pub fn shop_quantity_adjust(&mut self, delta: i32) {
4730        self.state.shop_quantity_adjust(delta);
4731    }
4732
4733    pub fn shop_quantity_set_max(&mut self) {
4734        self.state.shop_quantity_set_max();
4735    }
4736
4737    pub fn toggle_quest_menu(&mut self) {
4738        self.state.show_quest_menu = !self.state.show_quest_menu;
4739        if self.state.show_quest_menu {
4740            self.state.quest_menu_index = 0;
4741            self.state.quest_withdraw_confirm = false;
4742            self.state.show_workers_menu = false;
4743        }
4744    }
4745
4746    pub fn toggle_workers_menu(&mut self) {
4747        self.state.show_workers_menu = !self.state.show_workers_menu;
4748        if self.state.show_workers_menu {
4749            self.state.workers_menu_index = 0;
4750            self.state.show_quest_menu = false;
4751            self.close_worker_give_picker();
4752            self.close_worker_give_target_picker();
4753            self.close_worker_take_picker();
4754            self.close_worker_teach_picker();
4755            self.cancel_worker_rename();
4756        } else {
4757            self.close_worker_give_picker();
4758            self.close_worker_give_target_picker();
4759            self.close_worker_take_picker();
4760            self.close_worker_teach_picker();
4761            self.cancel_worker_rename();
4762        }
4763    }
4764
4765    pub fn workers_menu_move(&mut self, delta: i32) {
4766        let n = self.state.hired_workers.len();
4767        if n == 0 {
4768            return;
4769        }
4770        let idx = self.state.workers_menu_index as i32;
4771        self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
4772    }
4773
4774    pub fn toggle_workers_menu_compact(&mut self) {
4775        self.state.workers_menu_compact = !self.state.workers_menu_compact;
4776    }
4777
4778    pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
4779        let Some(worker) = self
4780            .state
4781            .hired_workers
4782            .get(self.state.workers_menu_index)
4783            .cloned()
4784        else {
4785            anyhow::bail!("no worker selected");
4786        };
4787        self.seq += 1;
4788        self.session
4789            .submit_intent(Intent::DismissWorker {
4790                entity_id: self.state.entity_id,
4791                worker_instance_id: worker.instance_id.clone(),
4792                seq: self.seq,
4793            })
4794            .await?;
4795        self.state.intents_sent += 1;
4796        self.state
4797            .hired_workers
4798            .retain(|w| w.instance_id != worker.instance_id);
4799        if self.state.workers_menu_index >= self.state.hired_workers.len() {
4800            self.state.workers_menu_index = self
4801                .state
4802                .hired_workers
4803                .len()
4804                .saturating_sub(1);
4805        }
4806        self.state.push_log(format!("Dismissed {}", worker.label));
4807        Ok(())
4808    }
4809
4810    pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
4811        let Some(worker) = self
4812            .state
4813            .hired_workers
4814            .get(self.state.workers_menu_index)
4815            .cloned()
4816        else {
4817            anyhow::bail!("no worker selected");
4818        };
4819        let mode = match worker.mode {
4820            flatland_protocol::WorkerModeView::Companion => "job_loop",
4821            flatland_protocol::WorkerModeView::JobLoop => "idle",
4822            flatland_protocol::WorkerModeView::Idle => "companion",
4823        };
4824        self.seq += 1;
4825        self.session
4826            .submit_intent(Intent::SetWorkerMode {
4827                entity_id: self.state.entity_id,
4828                worker_instance_id: worker.instance_id,
4829                mode: mode.into(),
4830                seq: self.seq,
4831            })
4832            .await?;
4833        self.state.intents_sent += 1;
4834        Ok(())
4835    }
4836
4837    pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
4838        if self.state.hired_workers.is_empty() {
4839            return self.hire_worker_laborer().await;
4840        }
4841        self.workers_toggle_mode_selected().await
4842    }
4843
4844    /// Open a nearby-worker picker for the selected inventory stack (inventory `g`).
4845    /// Always shows a chooser so you can pick Bruce vs Cookie when several are close.
4846    pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
4847        let row = self
4848            .state
4849            .inventory_selected_row()
4850            .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
4851            .clone();
4852        if row.from != flatland_protocol::InventoryLocation::Root {
4853            anyhow::bail!("select a carried item to give");
4854        }
4855        let Some(instance_id) = row.stack.item_instance_id else {
4856            anyhow::bail!("that stack can't be given");
4857        };
4858        let options = self.nearby_worker_give_targets();
4859        if options.is_empty() {
4860            anyhow::bail!(
4861                "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
4862            );
4863        }
4864        let item_label = row
4865            .stack
4866            .display_name
4867            .as_deref()
4868            .unwrap_or(&row.stack.template_id)
4869            .to_string();
4870        self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
4871            item_instance_id: instance_id,
4872            item_label,
4873            quantity: None,
4874            options,
4875        });
4876        self.state.worker_give_target_picker_index = 0;
4877        self.state.show_worker_give_target_picker = true;
4878        // Let the target picker own keys (inventory would otherwise swallow ↑↓/Enter).
4879        self.state.show_inventory_menu = false;
4880        Ok(())
4881    }
4882
4883    /// Hired workers within give/take range, nearest first.
4884    pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
4885        let (px, py, _) = self.state.player_position_with_z();
4886        let mut options: Vec<WorkerGiveTargetOption> = self
4887            .state
4888            .hired_workers
4889            .iter()
4890            .filter_map(|w| {
4891                let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
4892                if dist > WORKER_GIVE_RANGE_M {
4893                    return None;
4894                }
4895                Some(WorkerGiveTargetOption {
4896                    instance_id: w.instance_id.clone(),
4897                    label: w.label.clone(),
4898                    distance_m: dist,
4899                })
4900            })
4901            .collect();
4902        options.sort_by(|a, b| {
4903            a.distance_m
4904                .partial_cmp(&b.distance_m)
4905                .unwrap_or(std::cmp::Ordering::Equal)
4906        });
4907        options
4908    }
4909
4910    pub fn close_worker_give_target_picker(&mut self) {
4911        self.state.show_worker_give_target_picker = false;
4912        self.state.worker_give_target_picker = None;
4913        self.state.worker_give_target_picker_index = 0;
4914    }
4915
4916    pub fn worker_give_target_picker_move(&mut self, delta: i32) {
4917        let Some(picker) = &self.state.worker_give_target_picker else {
4918            return;
4919        };
4920        let n = picker.options.len();
4921        if n == 0 {
4922            return;
4923        }
4924        let idx = self.state.worker_give_target_picker_index as i32;
4925        self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
4926    }
4927
4928    pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
4929        let Some(picker) = self.state.worker_give_target_picker.clone() else {
4930            anyhow::bail!("give target picker not open");
4931        };
4932        let Some(opt) = picker
4933            .options
4934            .get(self.state.worker_give_target_picker_index)
4935            .cloned()
4936        else {
4937            anyhow::bail!("no worker selected");
4938        };
4939        let Some(worker) = self
4940            .state
4941            .hired_workers
4942            .iter()
4943            .find(|w| w.instance_id == opt.instance_id)
4944            .cloned()
4945        else {
4946            self.close_worker_give_target_picker();
4947            anyhow::bail!("worker no longer hired");
4948        };
4949        self.give_item_to_worker(
4950            &worker.instance_id,
4951            &worker.label,
4952            worker.x,
4953            worker.y,
4954            picker.item_instance_id,
4955            &picker.item_label,
4956            picker.quantity,
4957        )
4958        .await?;
4959        self.close_worker_give_target_picker();
4960        Ok(())
4961    }
4962
4963    /// Give the selected inventory stack to a hired worker (opens nearby-worker picker).
4964    pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
4965        self.open_worker_give_target_picker()
4966    }
4967
4968    /// Open the workers-menu give picker for the selected worker (must be in range).
4969    pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
4970        let Some(worker) = self
4971            .state
4972            .hired_workers
4973            .get(self.state.workers_menu_index)
4974            .cloned()
4975        else {
4976            anyhow::bail!("select a hired worker first");
4977        };
4978        let (px, py, _) = self.state.player_position_with_z();
4979        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
4980        if dist > WORKER_GIVE_RANGE_M {
4981            anyhow::bail!(
4982                "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
4983                worker.label
4984            );
4985        }
4986        let options = self.state.giveable_inventory_options();
4987        if options.is_empty() {
4988            anyhow::bail!("nothing in inventory to give");
4989        }
4990        self.state.worker_give_picker = Some(WorkerGivePicker {
4991            worker_instance_id: worker.instance_id,
4992            worker_label: worker.label,
4993            options,
4994        });
4995        self.state.worker_give_picker_index = 0;
4996        self.state.show_worker_give_picker = true;
4997        Ok(())
4998    }
4999
5000    pub fn close_worker_give_picker(&mut self) {
5001        self.state.show_worker_give_picker = false;
5002        self.state.worker_give_picker = None;
5003        self.state.worker_give_picker_index = 0;
5004    }
5005
5006    pub fn worker_give_picker_move(&mut self, delta: i32) {
5007        let Some(picker) = &self.state.worker_give_picker else {
5008            return;
5009        };
5010        let n = picker.options.len();
5011        if n == 0 {
5012            return;
5013        }
5014        let idx = self.state.worker_give_picker_index as i32;
5015        self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
5016    }
5017
5018    /// Confirm the selected row in the workers-menu give picker.
5019    pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
5020        let Some(picker) = self.state.worker_give_picker.clone() else {
5021            anyhow::bail!("give picker not open");
5022        };
5023        let Some(opt) = picker.options.get(self.state.worker_give_picker_index).cloned() else {
5024            anyhow::bail!("no item selected");
5025        };
5026        let Some(worker) = self
5027            .state
5028            .hired_workers
5029            .iter()
5030            .find(|w| w.instance_id == picker.worker_instance_id)
5031            .cloned()
5032        else {
5033            self.close_worker_give_picker();
5034            anyhow::bail!("worker no longer hired");
5035        };
5036        self.give_item_to_worker(
5037            &worker.instance_id,
5038            &worker.label,
5039            worker.x,
5040            worker.y,
5041            opt.item_instance_id,
5042            &opt.label,
5043            None,
5044        )
5045        .await?;
5046        // Refresh options (stack may be gone / reduced) or close when empty.
5047        let options = self.state.giveable_inventory_options();
5048        if options.is_empty() {
5049            self.close_worker_give_picker();
5050        } else {
5051            self.state.worker_give_picker = Some(WorkerGivePicker {
5052                worker_instance_id: picker.worker_instance_id,
5053                worker_label: picker.worker_label,
5054                options,
5055            });
5056            if self.state.worker_give_picker_index
5057                >= self
5058                    .state
5059                    .worker_give_picker
5060                    .as_ref()
5061                    .map(|p| p.options.len())
5062                    .unwrap_or(0)
5063            {
5064                self.state.worker_give_picker_index = self
5065                    .state
5066                    .worker_give_picker
5067                    .as_ref()
5068                    .map(|p| p.options.len().saturating_sub(1))
5069                    .unwrap_or(0);
5070            }
5071        }
5072        Ok(())
5073    }
5074
5075    /// Open the workers-menu teach picker for the selected worker (must be in range).
5076    pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
5077        let Some(worker) = self
5078            .state
5079            .hired_workers
5080            .get(self.state.workers_menu_index)
5081            .cloned()
5082        else {
5083            anyhow::bail!("select a hired worker first");
5084        };
5085        let (px, py, _) = self.state.player_position_with_z();
5086        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
5087        if dist > WORKER_GIVE_RANGE_M {
5088            anyhow::bail!(
5089                "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
5090                worker.label
5091            );
5092        }
5093        let options = self.state.teachable_blueprint_options(&worker);
5094        if options.is_empty() {
5095            anyhow::bail!("no recipes you know that {} still needs", worker.label);
5096        }
5097        self.state.worker_teach_picker = Some(WorkerTeachPicker {
5098            worker_instance_id: worker.instance_id,
5099            worker_label: worker.label,
5100            worker_level: worker.level,
5101            options,
5102        });
5103        self.state.worker_teach_picker_index = 0;
5104        self.state.show_worker_teach_picker = true;
5105        Ok(())
5106    }
5107
5108    pub fn close_worker_teach_picker(&mut self) {
5109        self.state.show_worker_teach_picker = false;
5110        self.state.worker_teach_picker = None;
5111        self.state.worker_teach_picker_index = 0;
5112    }
5113
5114    pub fn worker_teach_picker_move(&mut self, delta: i32) {
5115        let Some(picker) = &self.state.worker_teach_picker else {
5116            return;
5117        };
5118        let n = picker.options.len();
5119        if n == 0 {
5120            return;
5121        }
5122        let idx = self.state.worker_teach_picker_index as i32;
5123        self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
5124    }
5125
5126    pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
5127        let Some(picker) = self.state.worker_teach_picker.clone() else {
5128            anyhow::bail!("teach picker not open");
5129        };
5130        let Some(opt) = picker.options.get(self.state.worker_teach_picker_index).cloned() else {
5131            anyhow::bail!("nothing selected");
5132        };
5133        if !opt.level_ok {
5134            anyhow::bail!(
5135                "{} needs level {} (is level {})",
5136                picker.worker_label,
5137                opt.min_level,
5138                opt.worker_level
5139            );
5140        }
5141        if !opt.can_afford {
5142            anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
5143        }
5144        let Some(worker) = self
5145            .state
5146            .hired_workers
5147            .iter()
5148            .find(|w| w.instance_id == picker.worker_instance_id)
5149            .cloned()
5150        else {
5151            anyhow::bail!("worker gone");
5152        };
5153        let (px, py, _) = self.state.player_position_with_z();
5154        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
5155        if dist > WORKER_GIVE_RANGE_M {
5156            anyhow::bail!("worker {} too far — stand next to them", worker.label);
5157        }
5158        self.seq += 1;
5159        self.session
5160            .submit_intent(Intent::TeachWorkerBlueprint {
5161                entity_id: self.state.entity_id,
5162                worker_instance_id: picker.worker_instance_id.clone(),
5163                blueprint_id: opt.blueprint_id.clone(),
5164                seq: self.seq,
5165            })
5166            .await?;
5167        self.state.intents_sent += 1;
5168        self.state.push_log(format!(
5169            "Teaching {} to {} ({} cp)",
5170            opt.label, picker.worker_label, opt.cost_copper
5171        ));
5172        self.close_worker_teach_picker();
5173        Ok(())
5174    }
5175
5176    async fn give_item_to_worker(
5177        &mut self,
5178        worker_instance_id: &str,
5179        worker_label: &str,
5180        worker_x: f32,
5181        worker_y: f32,
5182        item_instance_id: uuid::Uuid,
5183        item_label: &str,
5184        quantity: Option<u32>,
5185    ) -> anyhow::Result<()> {
5186        let (px, py, _) = self.state.player_position_with_z();
5187        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
5188        if dist > WORKER_GIVE_RANGE_M {
5189            anyhow::bail!("worker {worker_label} too far — stand next to them");
5190        }
5191        self.seq += 1;
5192        self.session
5193            .submit_intent(Intent::GiveWorkerItem {
5194                entity_id: self.state.entity_id,
5195                worker_instance_id: worker_instance_id.to_string(),
5196                item_instance_id,
5197                quantity,
5198                seq: self.seq,
5199            })
5200            .await?;
5201        self.state.intents_sent += 1;
5202        self.state
5203            .push_log(format!("Gave {item_label} to {worker_label}"));
5204        Ok(())
5205    }
5206
5207    /// Open the workers-menu take picker for the selected worker (must be in range).
5208    pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
5209        let Some(worker) = self
5210            .state
5211            .hired_workers
5212            .get(self.state.workers_menu_index)
5213            .cloned()
5214        else {
5215            anyhow::bail!("select a hired worker first");
5216        };
5217        let (px, py, _) = self.state.player_position_with_z();
5218        let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
5219        if dist > WORKER_GIVE_RANGE_M {
5220            anyhow::bail!(
5221                "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
5222                worker.label
5223            );
5224        }
5225        let options = Self::worker_inventory_options(&worker);
5226        if options.is_empty() {
5227            anyhow::bail!("{} isn't carrying anything", worker.label);
5228        }
5229        self.state.worker_take_picker = Some(WorkerTakePicker {
5230            worker_instance_id: worker.instance_id,
5231            worker_label: worker.label,
5232            options,
5233        });
5234        self.state.worker_take_picker_index = 0;
5235        self.state.show_worker_take_picker = true;
5236        Ok(())
5237    }
5238
5239    fn worker_inventory_options(
5240        worker: &flatland_protocol::HiredWorkerView,
5241    ) -> Vec<WorkerGiveOption> {
5242        worker
5243            .inventory
5244            .iter()
5245            .filter_map(|stack| {
5246                let item_instance_id = stack.item_instance_id?;
5247                let label = stack
5248                    .display_name
5249                    .clone()
5250                    .unwrap_or_else(|| stack.template_id.clone());
5251                let label = if stack.quantity > 1 {
5252                    format!("{label} ×{}", stack.quantity)
5253                } else {
5254                    label
5255                };
5256                Some(WorkerGiveOption {
5257                    item_instance_id,
5258                    label,
5259                    quantity: stack.quantity,
5260                    template_id: stack.template_id.clone(),
5261                })
5262            })
5263            .collect()
5264    }
5265
5266    pub fn close_worker_take_picker(&mut self) {
5267        self.state.show_worker_take_picker = false;
5268        self.state.worker_take_picker = None;
5269        self.state.worker_take_picker_index = 0;
5270    }
5271
5272    pub fn worker_take_picker_move(&mut self, delta: i32) {
5273        let Some(picker) = &self.state.worker_take_picker else {
5274            return;
5275        };
5276        let n = picker.options.len();
5277        if n == 0 {
5278            return;
5279        }
5280        let idx = self.state.worker_take_picker_index as i32;
5281        self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
5282    }
5283
5284    pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
5285        let Some(picker) = self.state.worker_take_picker.clone() else {
5286            anyhow::bail!("take picker not open");
5287        };
5288        let Some(opt) = picker.options.get(self.state.worker_take_picker_index).cloned() else {
5289            anyhow::bail!("no item selected");
5290        };
5291        let Some(worker) = self
5292            .state
5293            .hired_workers
5294            .iter()
5295            .find(|w| w.instance_id == picker.worker_instance_id)
5296            .cloned()
5297        else {
5298            self.close_worker_take_picker();
5299            anyhow::bail!("worker no longer hired");
5300        };
5301        self.take_item_from_worker(
5302            &worker.instance_id,
5303            &worker.label,
5304            worker.x,
5305            worker.y,
5306            opt.item_instance_id,
5307            &opt.label,
5308            None,
5309        )
5310        .await?;
5311        // Refresh from live view after local optimistic wait — use last known
5312        // worker inventory minus the taken stack for immediate UI; next tick
5313        // will reconcile.
5314        if let Some(w) = self
5315            .state
5316            .hired_workers
5317            .iter()
5318            .find(|w| w.instance_id == picker.worker_instance_id)
5319        {
5320            let options = Self::worker_inventory_options(w)
5321                .into_iter()
5322                .filter(|o| o.item_instance_id != opt.item_instance_id)
5323                .collect::<Vec<_>>();
5324            if options.is_empty() {
5325                self.close_worker_take_picker();
5326            } else {
5327                self.state.worker_take_picker = Some(WorkerTakePicker {
5328                    worker_instance_id: picker.worker_instance_id,
5329                    worker_label: picker.worker_label,
5330                    options,
5331                });
5332                self.state.worker_take_picker_index = self
5333                    .state
5334                    .worker_take_picker_index
5335                    .min(
5336                        self.state
5337                            .worker_take_picker
5338                            .as_ref()
5339                            .map(|p| p.options.len().saturating_sub(1))
5340                            .unwrap_or(0),
5341                    );
5342            }
5343        } else {
5344            self.close_worker_take_picker();
5345        }
5346        Ok(())
5347    }
5348
5349    async fn take_item_from_worker(
5350        &mut self,
5351        worker_instance_id: &str,
5352        worker_label: &str,
5353        worker_x: f32,
5354        worker_y: f32,
5355        item_instance_id: uuid::Uuid,
5356        item_label: &str,
5357        quantity: Option<u32>,
5358    ) -> anyhow::Result<()> {
5359        let (px, py, _) = self.state.player_position_with_z();
5360        let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
5361        if dist > WORKER_GIVE_RANGE_M {
5362            anyhow::bail!("worker {worker_label} too far — stand next to them");
5363        }
5364        self.seq += 1;
5365        self.session
5366            .submit_intent(Intent::TakeWorkerItem {
5367                entity_id: self.state.entity_id,
5368                worker_instance_id: worker_instance_id.to_string(),
5369                item_instance_id,
5370                quantity,
5371                seq: self.seq,
5372            })
5373            .await?;
5374        self.state.intents_sent += 1;
5375        self.state
5376            .push_log(format!("Took {item_label} from {worker_label}"));
5377        Ok(())
5378    }
5379
5380    pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
5381        if !self.state.has_worker_lodging() {
5382            anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
5383        }
5384        self.seq += 1;
5385        self.session
5386            .submit_intent(Intent::HireWorker {
5387                entity_id: self.state.entity_id,
5388                def_id: "worker_laborer".into(),
5389                wage_copper_per_interval: 8,
5390                lodging_container_id: None,
5391                job_yaml: None,
5392                seq: self.seq,
5393            })
5394            .await?;
5395        self.state.intents_sent += 1;
5396        Ok(())
5397    }
5398
5399    pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
5400        let Some(worker) = self
5401            .state
5402            .hired_workers
5403            .get(self.state.workers_menu_index)
5404            .cloned()
5405        else {
5406            anyhow::bail!("select a hired worker first");
5407        };
5408        let lodging = worker.lodging_container_id.clone().or_else(|| {
5409            crate::worker_route_editor::owned_lodging_container_ids(
5410                &self.state.placed_containers,
5411                self.state.character_id,
5412            )
5413            .into_iter()
5414            .next()
5415            .map(|(id, _)| id)
5416        });
5417        let label = worker.label.clone();
5418        let editor = if let Some(route) = &worker.route {
5419            crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
5420                worker.instance_id,
5421                worker.label,
5422                route,
5423                lodging,
5424            )
5425        } else {
5426            crate::worker_route_editor::WorkerRouteEditorState::new(
5427                worker.instance_id,
5428                worker.label,
5429                lodging,
5430            )
5431        };
5432        self.state.worker_route_editor = Some(editor);
5433        self.state.show_workers_menu = false;
5434        self.state.push_log(format!(
5435            "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
5436        ));
5437        Ok(())
5438    }
5439
5440    pub fn close_worker_route_editor(&mut self) {
5441        self.state.worker_route_editor = None;
5442    }
5443
5444    pub fn worker_route_editor_toggle_panel(&mut self) {
5445        if let Some(ed) = self.state.worker_route_editor.as_mut() {
5446            ed.toggle_panel_collapsed();
5447        }
5448    }
5449
5450    pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
5451        let n = {
5452            let Some(ed) = self.state.worker_route_editor.as_mut() else {
5453                return;
5454            };
5455            ed.append_waypoint(x, y, z);
5456            ed.stop_count()
5457        };
5458        self.state
5459            .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
5460    }
5461
5462    // ---- picker candidate snapshots ----------------------------------
5463
5464    fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
5465        let (px, py, _) = self.state.player_position_with_z();
5466        crate::worker_route_editor::owned_container_candidates(
5467            &self.state.placed_containers,
5468            self.state.character_id,
5469            px,
5470            py,
5471        )
5472    }
5473
5474    fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
5475        let (px, py, _) = self.state.player_position_with_z();
5476        crate::worker_route_editor::node_candidates(&self.state.resource_nodes, px, py)
5477    }
5478
5479    fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
5480        let (px, py, _) = self.state.player_position_with_z();
5481        crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
5482    }
5483
5484    fn re_template_candidates(&self) -> Vec<String> {
5485        let mut extra = Vec::new();
5486        if let Some(ed) = self.state.worker_route_editor.as_ref() {
5487            for stop in &ed.stops {
5488                match stop {
5489                    crate::worker_route_editor::WorkerRouteStop::DepositAt {
5490                        filter: Some(filter),
5491                        ..
5492                    } => extra.extend(filter.iter().cloned()),
5493                    crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
5494                        extra.push(template.clone());
5495                    }
5496                    crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
5497                        if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint) {
5498                            extra.push(bp.output.clone());
5499                            for input in &bp.inputs {
5500                                extra.push(input.template_id.clone());
5501                            }
5502                        }
5503                    }
5504                    crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
5505                        for it in items {
5506                            extra.push(it.template.clone());
5507                        }
5508                    }
5509                    _ => {}
5510                }
5511            }
5512            // Worker-known recipe outputs even if the player hasn't learned them yet.
5513            if let Some(worker) = self
5514                .state
5515                .hired_workers
5516                .iter()
5517                .find(|w| w.instance_id == ed.worker_instance_id)
5518            {
5519                for recipe in &worker.known_blueprint_ids {
5520                    if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
5521                        extra.push(bp.output.clone());
5522                    }
5523                }
5524            }
5525        }
5526        crate::worker_route_editor::route_item_template_candidates(
5527            &self.state.placed_containers,
5528            self.state.character_id,
5529            &self.state.inventory,
5530            &self.state.blueprints,
5531            &self.state.resource_nodes,
5532            &extra,
5533        )
5534    }
5535
5536    fn re_blueprint_ids(&self) -> Vec<String> {
5537        let worker_known: Option<&[String]> = self
5538            .state
5539            .worker_route_editor
5540            .as_ref()
5541            .and_then(|ed| {
5542                self.state
5543                    .hired_workers
5544                    .iter()
5545                    .find(|w| w.instance_id == ed.worker_instance_id)
5546            })
5547            .map(|w| w.known_blueprint_ids.as_slice());
5548        crate::worker_route_editor::worker_craft_blueprint_ids(
5549            &self.state.blueprints,
5550            worker_known,
5551        )
5552    }
5553
5554    fn re_bed_candidates(&self) -> Vec<(String, String)> {
5555        crate::worker_route_editor::owned_lodging_container_ids(
5556            &self.state.placed_containers,
5557            self.state.character_id,
5558        )
5559    }
5560
5561    fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
5562        self.state
5563            .placed_containers
5564            .iter()
5565            .find(|c| c.id == container_id)
5566            .map(|c| c.contents.clone())
5567            .unwrap_or_default()
5568    }
5569
5570    // ---- sheet navigation (`plans/33`) --------------------------------
5571
5572    /// Row count of the current sheet (for cursor wrapping).
5573    pub fn re_sheet_row_count(&self) -> usize {
5574        use crate::worker_route_editor::RouteEditorSheet as S;
5575        let Some(ed) = self.state.worker_route_editor.as_ref() else {
5576            return 0;
5577        };
5578        match &ed.sheet {
5579            S::Stops => ed.stops.len(),
5580            S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
5581            S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
5582            S::WaypointMapPick => 0,
5583            S::HarvestPicker { .. } => self.re_node_candidates().len(),
5584            S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
5585                self.re_container_candidates().len()
5586            }
5587            S::WithdrawItems { lines, .. } => lines.len() + 1, // + Done row
5588            S::DepositFilter { rows, .. } => rows.len() + 1,   // + Done row
5589            S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, // + auto row
5590            S::SellItem { templates, .. } => templates.len() + 1, // + sell-all toggle
5591            S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
5592            S::WaitEntry { .. } => 1,
5593            S::BedPicker { .. } => self.re_bed_candidates().len(),
5594        }
5595    }
5596
5597    /// Current sheet cursor index (0 for sheets without one).
5598    pub fn re_sheet_index(&self) -> usize {
5599        use crate::worker_route_editor::RouteEditorSheet as S;
5600        let Some(ed) = self.state.worker_route_editor.as_ref() else {
5601            return 0;
5602        };
5603        match &ed.sheet {
5604            S::AddMenu { index }
5605            | S::WaypointMenu { index }
5606            | S::HarvestPicker { index }
5607            | S::WithdrawContainers { index }
5608            | S::DepositContainers { index }
5609            | S::SellNpcs { index }
5610            | S::CraftBlueprint { index }
5611            | S::BedPicker { index }
5612            | S::WithdrawItems { index, .. }
5613            | S::DepositFilter { index, .. }
5614            | S::SellItem { index, .. } => *index,
5615            _ => 0,
5616        }
5617    }
5618
5619    /// Move the current sheet's cursor, wrapping within its rows.
5620    pub fn re_sheet_move(&mut self, delta: i32) {
5621        use crate::worker_route_editor::RouteEditorSheet as S;
5622        let count = self.re_sheet_row_count();
5623        if count == 0 {
5624            return;
5625        }
5626        let cur = self.re_sheet_index() as i32;
5627        let next = (cur + delta).rem_euclid(count as i32) as usize;
5628        let Some(ed) = self.state.worker_route_editor.as_mut() else {
5629            return;
5630        };
5631        match &mut ed.sheet {
5632            S::AddMenu { index }
5633            | S::WaypointMenu { index }
5634            | S::HarvestPicker { index }
5635            | S::WithdrawContainers { index }
5636            | S::DepositContainers { index }
5637            | S::SellNpcs { index }
5638            | S::CraftBlueprint { index }
5639            | S::BedPicker { index }
5640            | S::WithdrawItems { index, .. }
5641            | S::DepositFilter { index, .. }
5642            | S::SellItem { index, .. } => *index = next,
5643            _ => {}
5644        }
5645    }
5646
5647    /// `[`/`]` on a sheet: adjust quantity (withdraw lines, wait ticks).
5648    pub fn re_sheet_adjust(&mut self, delta: i32) {
5649        use crate::worker_route_editor::RouteEditorSheet as S;
5650        let index = self.re_sheet_index();
5651        let Some(ed) = self.state.worker_route_editor.as_mut() else {
5652            return;
5653        };
5654        match &mut ed.sheet {
5655            S::WithdrawItems { lines, .. } => {
5656                if let Some(line) = lines.get_mut(index) {
5657                    line.adjust_qty(delta);
5658                }
5659            }
5660            S::WaitEntry { ticks } => {
5661                *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
5662            }
5663            _ => {}
5664        }
5665    }
5666
5667    pub fn re_sheet_back(&mut self) {
5668        let Some(ed) = self.state.worker_route_editor.as_mut() else {
5669            return;
5670        };
5671        use crate::worker_route_editor::RouteEditorSheet as S;
5672        let was_editing = ed.editing_index.is_some();
5673        let from_top_picker = matches!(
5674            ed.sheet,
5675            S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
5676        );
5677        ed.sheet_back();
5678        if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
5679            // Chest retarget commits on pick; Esc here just ends the edit sheet.
5680            self.state
5681                .push_log("Route: left edit sheet — press s to save current stops".to_string());
5682        }
5683    }
5684
5685    /// True when the editor is at its root sheet (Esc should close it).
5686    pub fn re_at_root_sheet(&self) -> bool {
5687        self.state
5688            .worker_route_editor
5689            .as_ref()
5690            .is_some_and(|ed| matches!(ed.sheet, crate::worker_route_editor::RouteEditorSheet::Stops))
5691    }
5692
5693    pub fn re_open_add_menu(&mut self) {
5694        if let Some(ed) = self.state.worker_route_editor.as_mut() {
5695            ed.open_add_menu();
5696        }
5697    }
5698
5699    pub fn re_open_bed_picker(&mut self) {
5700        let beds = self.re_bed_candidates();
5701        if beds.is_empty() {
5702            self.state
5703                .push_log("Route: place a camp bed first".to_string());
5704            return;
5705        }
5706        let current = self
5707            .state
5708            .worker_route_editor
5709            .as_ref()
5710            .and_then(|ed| ed.lodging_container_id.clone());
5711        let index = current
5712            .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
5713            .unwrap_or(0);
5714        self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
5715    }
5716
5717    fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
5718        if let Some(ed) = self.state.worker_route_editor.as_mut() {
5719            ed.open_sheet(sheet);
5720        }
5721    }
5722
5723    /// Confirm a sheet's stop and log the outcome.
5724    fn re_confirm_stop(
5725        &mut self,
5726        stop: crate::worker_route_editor::WorkerRouteStop,
5727        what: String,
5728    ) {
5729        let appended = self
5730            .state
5731            .worker_route_editor
5732            .as_mut()
5733            .is_some_and(|ed| ed.confirm_stop(stop));
5734        if appended {
5735            self.state.push_log(format!("Route: + {what}"));
5736        } else {
5737            self.state
5738                .push_log(format!("Route: {what} already in route — selected it"));
5739        }
5740    }
5741
5742    fn re_open_withdraw_items(&mut self, container_id: String) {
5743        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
5744        let contents = self.re_container_contents(&container_id);
5745        // When editing, keep the stop's item picks even if the player retargets
5746        // to a different (possibly empty) chest — otherwise Done has nothing to
5747        // confirm and the edit appears to "revert".
5748        let existing = self
5749            .state
5750            .worker_route_editor
5751            .as_ref()
5752            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
5753            .and_then(|stop| match stop {
5754                WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
5755                _ => None,
5756            })
5757            .unwrap_or_default();
5758        let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
5759        // Commit the new chest immediately while editing so Esc-before-Done still
5760        // keeps the retarget (items update only when Done is pressed).
5761        if let Some(ed) = self.state.worker_route_editor.as_mut() {
5762            let _ = ed.retarget_withdraw_container(container_id.clone());
5763        }
5764        self.re_open_sheet(S::WithdrawItems {
5765            container_id,
5766            lines,
5767            index: 0,
5768        });
5769    }
5770
5771    fn re_withdraw_items_activate(&mut self, index: usize) {
5772        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
5773        enum Outcome {
5774            Cycled,
5775            Confirmed(String),
5776            Empty,
5777        }
5778        let outcome = {
5779            let Some(ed) = self.state.worker_route_editor.as_mut() else {
5780                return;
5781            };
5782            let S::WithdrawItems {
5783                container_id,
5784                lines,
5785                index: sheet_index,
5786            } = &mut ed.sheet
5787            else {
5788                return;
5789            };
5790            *sheet_index = index;
5791            if index < lines.len() {
5792                lines[index].cycle();
5793                Outcome::Cycled
5794            } else {
5795                let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
5796                if items.is_empty() {
5797                    Outcome::Empty
5798                } else {
5799                    let stop = WorkerRouteStop::WithdrawFrom {
5800                        container_id: container_id.clone(),
5801                        items,
5802                    };
5803                    let summary = stop.summary();
5804                    ed.confirm_stop(stop);
5805                    Outcome::Confirmed(summary)
5806                }
5807            }
5808        };
5809        match outcome {
5810            Outcome::Cycled => {}
5811            Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
5812            Outcome::Empty => self
5813                .state
5814                .push_log("Route: pick at least one item (Space/Enter toggles All/qty)".to_string()),
5815        }
5816    }
5817
5818    fn re_open_deposit_filter(&mut self, container_id: String) {
5819        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
5820        // Preserve filter when retargeting the deposit chest while editing.
5821        let existing_filter = self
5822            .state
5823            .worker_route_editor
5824            .as_ref()
5825            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
5826            .and_then(|stop| match stop {
5827                WorkerRouteStop::DepositAt { filter, .. } => {
5828                    Some(filter.clone().unwrap_or_default())
5829                }
5830                _ => None,
5831            });
5832        let mut candidates = self.re_template_candidates();
5833        if let Some(ref chosen) = existing_filter {
5834            for t in chosen {
5835                if !candidates.iter().any(|c| c == t) {
5836                    candidates.push(t.clone());
5837                }
5838            }
5839            candidates.sort();
5840            candidates.dedup();
5841        }
5842        let rows: Vec<(String, bool)> = match existing_filter {
5843            Some(chosen) => candidates
5844                .iter()
5845                .map(|t| (t.clone(), chosen.contains(t)))
5846                .collect(),
5847            None => candidates.into_iter().map(|t| (t, false)).collect(),
5848        };
5849        if let Some(ed) = self.state.worker_route_editor.as_mut() {
5850            let _ = ed.retarget_deposit_container(container_id.clone());
5851        }
5852        self.re_open_sheet(S::DepositFilter {
5853            container_id,
5854            rows,
5855            index: 0,
5856        });
5857    }
5858
5859    fn re_deposit_filter_activate(&mut self, index: usize) {
5860        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
5861        let mut confirmed: Option<String> = None;
5862        {
5863            let Some(ed) = self.state.worker_route_editor.as_mut() else {
5864                return;
5865            };
5866            let S::DepositFilter {
5867                container_id,
5868                rows,
5869                index: sheet_index,
5870            } = &mut ed.sheet
5871            else {
5872                return;
5873            };
5874            *sheet_index = index;
5875            if index < rows.len() {
5876                rows[index].1 = !rows[index].1;
5877            } else {
5878                // "Done" row.
5879                let chosen: Vec<String> = rows
5880                    .iter()
5881                    .filter(|(_, on)| *on)
5882                    .map(|(t, _)| t.clone())
5883                    .collect();
5884                let filter = if chosen.is_empty() { None } else { Some(chosen) };
5885                let stop = WorkerRouteStop::DepositAt {
5886                    container_id: container_id.clone(),
5887                    filter,
5888                };
5889                confirmed = Some(stop.summary());
5890                ed.confirm_stop(stop);
5891            }
5892        }
5893        if let Some(what) = confirmed {
5894            self.state.push_log(format!("Route: + {what}"));
5895        }
5896    }
5897
5898    fn re_open_sell_item(&mut self, npc_id: Option<String>) {
5899        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
5900        let templates = self.re_template_candidates();
5901        if templates.is_empty() {
5902            self.state.push_log(
5903                "Route: no item templates available — learn a craft recipe or place a harvest node first"
5904                    .to_string(),
5905            );
5906            return;
5907        }
5908        // Prefill when editing an existing sell stop.
5909        let (pre_npc, pre_template, pre_all) = self
5910            .state
5911            .worker_route_editor
5912            .as_ref()
5913            .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
5914            .and_then(|stop| match stop {
5915                WorkerRouteStop::TradeWith {
5916                    npc_id,
5917                    template,
5918                    sell_all,
5919                } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
5920                _ => None,
5921            })
5922            .unwrap_or((None, None, true));
5923        let npc_id = npc_id.or(pre_npc);
5924        let index = pre_template
5925            .and_then(|t| templates.iter().position(|x| x == &t))
5926            .map(|i| i + 1) // +1 for the sell-all toggle row
5927            .unwrap_or(1);
5928        self.re_open_sheet(S::SellItem {
5929            npc_id,
5930            templates,
5931            index,
5932            sell_all: pre_all,
5933        });
5934    }
5935
5936    fn re_sell_item_activate(&mut self, index: usize) {
5937        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
5938        let mut confirmed: Option<String> = None;
5939        {
5940            let Some(ed) = self.state.worker_route_editor.as_mut() else {
5941                return;
5942            };
5943            let S::SellItem {
5944                npc_id,
5945                templates,
5946                index: sheet_index,
5947                sell_all,
5948            } = &mut ed.sheet
5949            else {
5950                return;
5951            };
5952            *sheet_index = index;
5953            if index == 0 {
5954                *sell_all = !*sell_all;
5955            } else if let Some(template) = templates.get(index - 1).cloned() {
5956                let stop = WorkerRouteStop::TradeWith {
5957                    npc_id: npc_id.clone(),
5958                    template,
5959                    sell_all: *sell_all,
5960                };
5961                confirmed = Some(stop.summary());
5962                ed.confirm_stop(stop);
5963            }
5964        }
5965        if let Some(what) = confirmed {
5966            self.state.push_log(format!("Route: + {what}"));
5967        }
5968    }
5969
5970    /// Enter on the stop list: open the selected stop's sheet, prefilled.
5971    pub fn re_edit_selected_stop(&mut self) {
5972        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
5973        let Some(stop) = self
5974            .state
5975            .worker_route_editor
5976            .as_ref()
5977            .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
5978        else {
5979            self.state
5980                .push_log("Route: no stop selected — press a to add one".to_string());
5981            return;
5982        };
5983        if let Some(ed) = self.state.worker_route_editor.as_mut() {
5984            ed.begin_edit_selected();
5985        }
5986        match stop {
5987            WorkerRouteStop::Waypoint { .. } => {
5988                self.re_open_sheet(S::WaypointMenu { index: 0 });
5989            }
5990            WorkerRouteStop::HarvestNode { node_id } => {
5991                let nodes = self.re_node_candidates();
5992                let index = nodes.iter().position(|n| n.id == node_id).unwrap_or(0);
5993                if nodes.is_empty() {
5994                    self.re_cancel_edit();
5995                    self.state
5996                        .push_log("Route: no harvestable nodes visible to retarget".to_string());
5997                } else {
5998                    self.re_open_sheet(S::HarvestPicker { index });
5999                }
6000            }
6001            WorkerRouteStop::WithdrawFrom { container_id, .. } => {
6002                // Open the container picker so the player can retarget storage
6003                // (previously jumped straight to items on the same chest).
6004                let containers = self.re_container_candidates();
6005                if containers.is_empty() {
6006                    self.re_cancel_edit();
6007                    self.state
6008                        .push_log("Route: place a storage chest first".to_string());
6009                } else {
6010                    let index = containers
6011                        .iter()
6012                        .position(|c| c.id == container_id)
6013                        .unwrap_or(0);
6014                    self.re_open_sheet(S::WithdrawContainers { index });
6015                }
6016            }
6017            WorkerRouteStop::DepositAt { container_id, .. } => {
6018                let containers = self.re_container_candidates();
6019                if containers.is_empty() {
6020                    self.re_cancel_edit();
6021                    self.state
6022                        .push_log("Route: place a storage chest first".to_string());
6023                } else {
6024                    let index = containers
6025                        .iter()
6026                        .position(|c| c.id == container_id)
6027                        .unwrap_or(0);
6028                    self.re_open_sheet(S::DepositContainers { index });
6029                }
6030            }
6031            WorkerRouteStop::TradeWith { npc_id, .. } => {
6032                let npcs = self.re_npc_candidates();
6033                // Row 0 is "auto / nearest"; rows 1.. map to npcs.
6034                let index = npc_id
6035                    .as_ref()
6036                    .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
6037                    .unwrap_or(0);
6038                self.re_open_sheet(S::SellNpcs { index });
6039            }
6040            WorkerRouteStop::CraftAt { blueprint, .. } => {
6041                let bps = self.re_blueprint_ids();
6042                let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
6043                if bps.is_empty() {
6044                    self.re_cancel_edit();
6045                    self.state
6046                        .push_log("Route: no known blueprints to retarget".to_string());
6047                } else {
6048                    self.re_open_sheet(S::CraftBlueprint { index });
6049                }
6050            }
6051            WorkerRouteStop::RestIfNeeded => {
6052                self.re_cancel_edit();
6053                self.state
6054                    .push_log("Route: rest has no settings (change the bed with l)".to_string());
6055            }
6056            WorkerRouteStop::Wait { wait_ticks } => {
6057                self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
6058            }
6059        }
6060    }
6061
6062    fn re_cancel_edit(&mut self) {
6063        if let Some(ed) = self.state.worker_route_editor.as_mut() {
6064            ed.editing_index = None;
6065        }
6066    }
6067
6068    // ---- mouse clicks inside the overlay ------------------------------
6069
6070    pub fn worker_route_editor_ui_click(
6071        &mut self,
6072        click: crate::worker_route_editor::RouteEditorClick,
6073    ) {
6074        use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
6075        match click {
6076            RouteEditorClick::SelectStop(i) => {
6077                if let Some(ed) = self.state.worker_route_editor.as_mut() {
6078                    ed.sheet = S::Stops;
6079                    ed.select_stop(i);
6080                }
6081            }
6082            RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
6083            RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
6084            RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
6085        }
6086    }
6087
6088    /// Activate (Enter / click) a row of the current sheet.
6089    pub fn re_sheet_row_activate(&mut self, row: usize) {
6090        use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
6091        let Some(sheet) = self
6092            .state
6093            .worker_route_editor
6094            .as_ref()
6095            .map(|ed| ed.sheet.clone())
6096        else {
6097            return;
6098        };
6099        match sheet {
6100            S::Stops => {
6101                if let Some(ed) = self.state.worker_route_editor.as_mut() {
6102                    ed.select_stop(row);
6103                }
6104            }
6105            S::AddMenu { .. } => match row {
6106                0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
6107                1 => {
6108                    if self.re_node_candidates().is_empty() {
6109                        self.state
6110                            .push_log("Route: no harvestable nodes visible in this region".to_string());
6111                    } else {
6112                        self.re_open_sheet(S::HarvestPicker { index: 0 });
6113                    }
6114                }
6115                2 | 3 => {
6116                    if self.re_container_candidates().is_empty() {
6117                        self.state
6118                            .push_log("Route: place a storage chest first".to_string());
6119                    } else if row == 2 {
6120                        self.re_open_sheet(S::WithdrawContainers { index: 0 });
6121                    } else {
6122                        self.re_open_sheet(S::DepositContainers { index: 0 });
6123                    }
6124                }
6125                4 => {
6126                    if self.re_template_candidates().is_empty() {
6127                        self.state.push_log(
6128                            "Route: no item templates available — learn a craft recipe or place a harvest node first"
6129                                .to_string(),
6130                        );
6131                    } else {
6132                        self.re_open_sheet(S::SellNpcs { index: 0 });
6133                    }
6134                }
6135                5 => {
6136                    if self.re_blueprint_ids().is_empty() {
6137                        self.state.push_log(
6138                            "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
6139                                .to_string(),
6140                        );
6141                    } else {
6142                        self.re_open_sheet(S::CraftBlueprint { index: 0 });
6143                    }
6144                }
6145                6 => self.re_confirm_stop(
6146                    WorkerRouteStop::RestIfNeeded,
6147                    "rest if needed".into(),
6148                ),
6149                7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
6150                _ => {}
6151            },
6152            S::WaypointMenu { .. } => match row {
6153                0 => {
6154                    let (x, y, z) = self.state.player_position_with_z();
6155                    let stop = WorkerRouteStop::Waypoint { x, y, z };
6156                    self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
6157                }
6158                1 => {
6159                    self.re_open_sheet(S::WaypointMapPick);
6160                    self.state.push_log("Route: click the map to place the waypoint (Esc to finish)".to_string());
6161                }
6162                _ => {}
6163            },
6164            S::HarvestPicker { .. } => {
6165                let nodes = self.re_node_candidates();
6166                if let Some(n) = nodes.get(row) {
6167                    let stop = WorkerRouteStop::HarvestNode {
6168                        node_id: n.id.clone(),
6169                    };
6170                    let label = n.label.clone();
6171                    self.re_confirm_stop(stop, format!("harvest {label}"));
6172                }
6173            }
6174            S::WithdrawContainers { .. } => {
6175                let containers = self.re_container_candidates();
6176                if let Some(c) = containers.get(row) {
6177                    let id = c.id.clone();
6178                    self.re_open_withdraw_items(id);
6179                }
6180            }
6181            S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
6182            S::DepositContainers { .. } => {
6183                let containers = self.re_container_candidates();
6184                if let Some(c) = containers.get(row) {
6185                    let id = c.id.clone();
6186                    self.re_open_deposit_filter(id);
6187                }
6188            }
6189            S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
6190            S::SellNpcs { .. } => {
6191                let npcs = self.re_npc_candidates();
6192                let npc_id = if row == 0 {
6193                    None
6194                } else {
6195                    npcs.get(row - 1).map(|n| n.id.clone())
6196                };
6197                if row == 0 || npc_id.is_some() {
6198                    self.re_open_sell_item(npc_id);
6199                }
6200            }
6201            S::SellItem { .. } => self.re_sell_item_activate(row),
6202            S::CraftBlueprint { .. } => {
6203                let bps = self.re_blueprint_ids();
6204                if let Some(bp) = bps.get(row) {
6205                    let stop = WorkerRouteStop::CraftAt {
6206                        device: "hand".into(),
6207                        blueprint: bp.clone(),
6208                        qty: None,
6209                    };
6210                    self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
6211                }
6212            }
6213            S::WaitEntry { ticks } => {
6214                let stop = WorkerRouteStop::Wait {
6215                    wait_ticks: ticks,
6216                };
6217                self.re_confirm_stop(stop, format!("wait {ticks}t"));
6218            }
6219            S::BedPicker { .. } => {
6220                let beds = self.re_bed_candidates();
6221                if let Some((id, name)) = beds.get(row) {
6222                    let (id, name) = (id.clone(), name.clone());
6223                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
6224                        ed.lodging_container_id = Some(id.clone());
6225                        ed.sheet = S::Stops;
6226                    }
6227                    self.state
6228                        .push_log(format!("Route: rest bed set to {name}"));
6229                }
6230            }
6231            S::WaypointMapPick => {}
6232        }
6233    }
6234
6235    // ---- map clicks (sheet-scoped accelerators) ------------------------
6236
6237    /// Map click while the route editor is open. When a picker sheet is open
6238    /// the click feeds *that* sheet (chest clicks choose the withdraw/deposit
6239    /// container, NPC clicks the merchant, node clicks the harvest target);
6240    /// otherwise the click quick-adds the nearest target (context-aware).
6241    pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
6242        use crate::worker_route_editor as wre;
6243        use wre::RouteEditorSheet as S;
6244        if self.state.worker_route_editor.is_none() {
6245            return;
6246        }
6247        let sheet = self
6248            .state
6249            .worker_route_editor
6250            .as_ref()
6251            .map(|ed| ed.sheet.clone())
6252            .unwrap_or(S::Stops);
6253        match sheet {
6254            S::WaypointMapPick => {
6255                let (_, _, z) = self.state.player_position_with_z();
6256                let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
6257                self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
6258                // Stay in map-pick mode when adding (not editing) for fast pathing.
6259                let editing = self
6260                    .state
6261                    .worker_route_editor
6262                    .as_ref()
6263                    .is_some_and(|ed| ed.editing_index.is_some());
6264                if !editing {
6265                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
6266                        ed.sheet = S::WaypointMapPick;
6267                    }
6268                }
6269            }
6270            S::HarvestPicker { .. } => {
6271                if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
6272                    let stop = wre::WorkerRouteStop::HarvestNode {
6273                        node_id: node.id.clone(),
6274                    };
6275                    let label = node.label.clone();
6276                    self.re_confirm_stop(stop, format!("harvest {label}"));
6277                }
6278            }
6279            S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
6280                // Chest click picks (or switches) the withdraw source.
6281                if let Some(cid) = wre::pick_storage_container_at(
6282                    &self.state.placed_containers,
6283                    self.state.character_id,
6284                    x,
6285                    y,
6286                ) {
6287                    self.re_open_withdraw_items(cid);
6288                }
6289            }
6290            S::DepositContainers { .. } | S::DepositFilter { .. } => {
6291                if let Some(cid) = wre::pick_storage_container_at(
6292                    &self.state.placed_containers,
6293                    self.state.character_id,
6294                    x,
6295                    y,
6296                ) {
6297                    self.re_open_deposit_filter(cid);
6298                }
6299            }
6300            S::SellNpcs { .. } => {
6301                if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
6302                    self.re_open_sell_item(Some(npc_id));
6303                }
6304            }
6305            S::SellItem { .. } => {
6306                if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
6307                    if let Some(ed) = self.state.worker_route_editor.as_mut() {
6308                        if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
6309                            *slot = Some(npc_id.clone());
6310                        }
6311                    }
6312                    self.state
6313                        .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
6314                }
6315            }
6316            // Stop list / other sheets: context-aware quick add.
6317            _ => self.worker_route_editor_quick_add_click(x, y),
6318        }
6319    }
6320
6321    /// Quick-add map click with no picker open: nearest of bed/chest/merchant/
6322    /// node wins; duplicates select the existing stop; a click on a merchant
6323    /// while a sell stop is selected pins it.
6324    fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
6325        use crate::worker_route_editor as wre;
6326        let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
6327            let dx = ax - bx;
6328            let dy = ay - by;
6329            (dx * dx + dy * dy).sqrt()
6330        };
6331
6332        // 1. Retarget the selected stop when the click names its target:
6333        //    sell stop → pin the merchant; withdraw stop → set the source chest.
6334        let selected_stop_kind = self
6335            .state
6336            .worker_route_editor
6337            .as_ref()
6338            .and_then(|ed| ed.stops.get(ed.selected_stop_index))
6339            .map(|s| match s {
6340                wre::WorkerRouteStop::TradeWith { .. } => 1,
6341                wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
6342                _ => 0,
6343            })
6344            .unwrap_or(0);
6345        if selected_stop_kind == 1 {
6346            if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
6347                if let Some(ed) = self.state.worker_route_editor.as_mut() {
6348                    ed.set_selected_trade_npc(npc_id.clone());
6349                }
6350                self.state
6351                    .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
6352                return;
6353            }
6354        }
6355        if selected_stop_kind == 2 {
6356            if let Some(cid) = wre::pick_storage_container_at(
6357                &self.state.placed_containers,
6358                self.state.character_id,
6359                x,
6360                y,
6361            ) {
6362                let name = self
6363                    .state
6364                    .placed_containers
6365                    .iter()
6366                    .find(|c| c.id == cid)
6367                    .map(|c| c.display_name.clone())
6368                    .unwrap_or_else(|| "container".into());
6369                if let Some(ed) = self.state.worker_route_editor.as_mut() {
6370                    ed.set_selected_withdraw_container(cid.clone());
6371                }
6372                self.state
6373                    .push_log(format!("Route: withdraw source → {name}"));
6374                return;
6375            }
6376        }
6377
6378        // 2. Nearest candidate of any kind wins. Category order is the
6379        //    tie-break for exact overlaps (bed over chest over merchant over node).
6380        enum Target {
6381            Bed(String),
6382            Container(String),
6383            Npc(String, String),
6384            Node(String, String),
6385        }
6386        let mut best: Option<(f32, u8, Target)> = None;
6387        let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
6388            let better = match best {
6389                None => true,
6390                Some((bd, brank, _)) => d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank),
6391            };
6392            if better {
6393                *best = Some((d, rank, t));
6394            }
6395        };
6396        if let Some(bed_id) = wre::pick_lodging_container_at(
6397            &self.state.placed_containers,
6398            self.state.character_id,
6399            x,
6400            y,
6401        ) {
6402            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
6403                // A bed that's already the rest bed doubles as a storage chest —
6404                // a second click on it means "deposit here", not "set bed again".
6405                let already_bed = self
6406                    .state
6407                    .worker_route_editor
6408                    .as_ref()
6409                    .is_some_and(|ed| ed.lodging_container_id.as_deref() == Some(bed_id.as_str()));
6410                if already_bed {
6411                    consider(dist(x, y, c.x, c.y), 1, Target::Container(bed_id), &mut best);
6412                } else {
6413                    consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
6414                }
6415            }
6416        }
6417        if let Some(cid) = wre::pick_storage_container_at(
6418            &self.state.placed_containers,
6419            self.state.character_id,
6420            x,
6421            y,
6422        ) {
6423            if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
6424                consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
6425            }
6426        }
6427        if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
6428            if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
6429                consider(
6430                    dist(x, y, n.x, n.y),
6431                    2,
6432                    Target::Npc(npc_id, label),
6433                    &mut best,
6434                );
6435            }
6436        }
6437        if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
6438            let d = dist(x, y, node.x, node.y);
6439            consider(
6440                d,
6441                3,
6442                Target::Node(node.id.clone(), node.label.clone()),
6443                &mut best,
6444            );
6445        }
6446
6447        match best.map(|(_, _, t)| t) {
6448            Some(Target::Bed(bed_id)) => {
6449                let name = self
6450                    .state
6451                    .placed_containers
6452                    .iter()
6453                    .find(|c| c.id == bed_id)
6454                    .map(|c| c.display_name.clone())
6455                    .unwrap_or_else(|| "camp bed".into());
6456                if let Some(ed) = self.state.worker_route_editor.as_mut() {
6457                    ed.lodging_container_id = Some(bed_id.clone());
6458                }
6459                self.state
6460                    .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
6461            }
6462            Some(Target::Container(cid)) => {
6463                let name = self
6464                    .state
6465                    .placed_containers
6466                    .iter()
6467                    .find(|c| c.id == cid)
6468                    .map(|c| c.display_name.clone())
6469                    .unwrap_or_else(|| "container".into());
6470                let added = self
6471                    .state
6472                    .worker_route_editor
6473                    .as_mut()
6474                    .is_some_and(|ed| ed.append_deposit_at(&cid));
6475                if added {
6476                    self.state
6477                        .push_log(format!("Route: + deposit at {name} ({cid})"));
6478                } else {
6479                    self.state.push_log(format!(
6480                        "Route: {name} already in route — selected it (d to remove)"
6481                    ));
6482                }
6483            }
6484            Some(Target::Npc(npc_id, label)) => {
6485                // Quick-add sell: first storage template (the full sell flow
6486                // lives in the a → Sell sheet).
6487                let template = self.re_template_candidates().into_iter().next();
6488                let Some(template) = template else {
6489                    self.state.push_log("Route: no items in your storage to sell — stock a chest first".to_string());
6490                    return;
6491                };
6492                let added = self
6493                    .state
6494                    .worker_route_editor
6495                    .as_mut()
6496                    .is_some_and(|ed| ed.append_trade_with(template.clone(), Some(npc_id.clone()), true));
6497                if added {
6498                    self.state
6499                        .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
6500                } else {
6501                    self.state.push_log(format!(
6502                        "Route: {label} already sells {template} — selected it (d to remove)"
6503                    ));
6504                }
6505            }
6506            Some(Target::Node(id, label)) => {
6507                let added = self
6508                    .state
6509                    .worker_route_editor
6510                    .as_mut()
6511                    .is_some_and(|ed| ed.append_harvest_node(&id));
6512                if added {
6513                    self.state
6514                        .push_log(format!("Route: + harvest node {label} ({id})"));
6515                } else {
6516                    self.state.push_log(format!(
6517                        "Route: {label} already in route — selected it (d to remove)"
6518                    ));
6519                }
6520            }
6521            None => {}
6522        }
6523    }
6524
6525    pub fn worker_route_editor_select(&mut self, delta: i32) {
6526        let Some(ed) = self.state.worker_route_editor.as_mut() else {
6527            return;
6528        };
6529        if ed.stops.is_empty() {
6530            return;
6531        }
6532        let n = ed.stops.len() as i32;
6533        let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
6534        ed.selected_stop_index = next;
6535    }
6536
6537    pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
6538        let Some(ed) = self.state.worker_route_editor.as_mut() else {
6539            return;
6540        };
6541        if delta < 0 {
6542            ed.move_selected_up();
6543        } else if delta > 0 {
6544            ed.move_selected_down();
6545        }
6546    }
6547
6548    pub fn worker_route_editor_delete_selected(&mut self) {
6549        let removed = self
6550            .state
6551            .worker_route_editor
6552            .as_mut()
6553            .is_some_and(|ed| {
6554                let before = ed.stop_count();
6555                ed.remove_selected_stop();
6556                ed.stop_count() < before
6557            });
6558        if removed {
6559            self.state.push_log("Route: removed selected stop");
6560        }
6561    }
6562
6563    /// Clear all stops. Saving afterwards parks the worker in idle mode
6564    /// instead of leaving it in a broken job loop.
6565    pub fn worker_route_editor_clear_stops(&mut self) {
6566        let Some(ed) = self.state.worker_route_editor.as_mut() else {
6567            return;
6568        };
6569        if ed.stops.is_empty() {
6570            self.state.push_log("Route: already empty — s saves an idle worker".to_string());
6571            return;
6572        }
6573        ed.stops.clear();
6574        ed.selected_stop_index = 0;
6575        self.state
6576            .push_log("Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string());
6577    }
6578
6579    pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
6580        if self.state.pending_worker_job_ack.is_some() {
6581            anyhow::bail!("route save still pending — wait for server ack");
6582        }
6583        let Some(ed) = self.state.worker_route_editor.clone() else {
6584            anyhow::bail!("route editor not open");
6585        };
6586        // Empty route = stand down: park the worker in idle mode rather than
6587        // erroring out or leaving it marching a ghost loop.
6588        let (job_yaml, idle) = if ed.stops.is_empty() {
6589            (ed.build_idle_job_yaml(), true)
6590        } else {
6591            (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
6592        };
6593        let worker_id = ed.worker_instance_id.clone();
6594        let route_view = if idle {
6595            None
6596        } else {
6597            Some(ed.to_route_view())
6598        };
6599        let mode = if idle {
6600            flatland_protocol::WorkerModeView::Idle
6601        } else {
6602            flatland_protocol::WorkerModeView::JobLoop
6603        };
6604        let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
6605            .state
6606            .hired_workers
6607            .iter()
6608            .find(|w| w.instance_id == worker_id)
6609            .map(|w| {
6610                (
6611                    w.route.clone(),
6612                    w.mode,
6613                    w.step_label.clone(),
6614                    w.last_error.clone(),
6615                )
6616            })
6617            .unwrap_or((
6618                None,
6619                flatland_protocol::WorkerModeView::Idle,
6620                String::new(),
6621                None,
6622            ));
6623        self.seq += 1;
6624        let seq = self.seq;
6625        self.session
6626            .submit_intent(Intent::SetWorkerJob {
6627                entity_id: self.state.entity_id,
6628                worker_instance_id: worker_id.clone(),
6629                job_yaml,
6630                seq,
6631            })
6632            .await?;
6633        self.state.intents_sent += 1;
6634        if let Some(w) = self
6635            .state
6636            .hired_workers
6637            .iter_mut()
6638            .find(|w| w.instance_id == worker_id)
6639        {
6640            w.route = route_view;
6641            w.mode = mode;
6642            w.last_error = None;
6643            if idle {
6644                w.step_label.clear();
6645            }
6646        }
6647        self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
6648            seq,
6649            worker_instance_id: worker_id,
6650            worker_label: ed.worker_label.clone(),
6651            idle,
6652            stop_count: ed.stops.len(),
6653            prev_route,
6654            prev_mode,
6655            prev_step_label,
6656            prev_last_error,
6657        });
6658        self.state.push_log(format!(
6659            "Route: saving for {}… (waiting for server)",
6660            ed.worker_label
6661        ));
6662        // Keep editor open until IntentAck; reject Interaction reverts optimistic state.
6663        Ok(())
6664    }
6665    pub fn quest_menu_move(&mut self, delta: i32) {
6666        let n = self.state.active_quest_entries().len();
6667        if n == 0 {
6668            return;
6669        }
6670        let idx = self.state.quest_menu_index as i32;
6671        self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
6672    }
6673
6674    pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
6675        let Some(offer) = self.state.pending_quest_offer.clone() else {
6676            anyhow::bail!("no quest offer");
6677        };
6678        self.seq += 1;
6679        let seq = self.seq;
6680        self.session
6681            .submit_intent(Intent::AcceptQuest {
6682                entity_id: self.state.entity_id,
6683                quest_id: offer.quest_id,
6684                seq,
6685            })
6686            .await?;
6687        self.state.intents_sent += 1;
6688        Ok(())
6689    }
6690
6691    pub fn quest_offer_decline(&mut self) {
6692        self.state.show_quest_offer = false;
6693        self.state.pending_quest_offer = None;
6694        if !self.state.show_npc_chat
6695            && !self.state.show_shop_menu
6696            && self.state.npc_verb_target.is_some()
6697        {
6698            self.state.show_npc_verb_menu = true;
6699        }
6700    }
6701
6702    pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
6703        if !self.state.show_quest_menu {
6704            return Ok(());
6705        }
6706        let active: Vec<_> = self
6707            .state
6708            .active_quest_entries()
6709            .into_iter()
6710            .cloned()
6711            .collect();
6712        let Some(entry) = active.get(self.state.quest_menu_index) else {
6713            return Ok(());
6714        };
6715        if self.state.quest_withdraw_confirm {
6716            if !entry.can_withdraw {
6717                anyhow::bail!("quest cannot be withdrawn");
6718            }
6719            self.seq += 1;
6720            let seq = self.seq;
6721            self.session
6722                .submit_intent(Intent::WithdrawQuest {
6723                    entity_id: self.state.entity_id,
6724                    quest_id: entry.quest_id.clone(),
6725                    seq,
6726                })
6727                .await?;
6728            self.state.intents_sent += 1;
6729            self.state.quest_withdraw_confirm = false;
6730            return Ok(());
6731        }
6732        self.seq += 1;
6733        let seq = self.seq;
6734        self.session
6735            .submit_intent(Intent::TrackQuest {
6736                entity_id: self.state.entity_id,
6737                quest_id: entry.quest_id.clone(),
6738                seq,
6739            })
6740            .await?;
6741        self.state.intents_sent += 1;
6742        Ok(())
6743    }
6744
6745    pub fn quest_request_withdraw(&mut self) {
6746        if self.state.show_quest_menu {
6747            self.state.quest_withdraw_confirm = true;
6748        }
6749    }
6750
6751    pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
6752        if !self.state.is_alive() {
6753            anyhow::bail!("you are dead");
6754        }
6755        let Some(catalog) = self.state.shop_catalog.clone() else {
6756            anyhow::bail!("no shop open");
6757        };
6758        self.seq += 1;
6759        let seq = self.seq;
6760        match self.state.shop_tab {
6761            ShopTab::Buy => {
6762                let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
6763                    anyhow::bail!("nothing selected");
6764                };
6765                if offer.already_owned {
6766                    anyhow::bail!("already owned");
6767                }
6768                self.session
6769                    .submit_intent(Intent::ShopBuy {
6770                        entity_id: self.state.entity_id,
6771                        npc_id: catalog.npc_id.clone(),
6772                        offer_id: offer.offer_id.clone(),
6773                        quantity: self.state.shop_quantity,
6774                        seq,
6775                    })
6776                    .await?;
6777            }
6778            ShopTab::Sell => {
6779                let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
6780                    anyhow::bail!("nothing to sell");
6781                };
6782                if line.quantity == 0 {
6783                    anyhow::bail!("you have no {}", line.label);
6784                }
6785                let quantity = self.state.shop_quantity.min(line.quantity).max(1);
6786                self.session
6787                    .submit_intent(Intent::ShopSell {
6788                        entity_id: self.state.entity_id,
6789                        npc_id: catalog.npc_id.clone(),
6790                        template_id: line.template_id.clone(),
6791                        quantity,
6792                        seq,
6793                    })
6794                    .await?;
6795            }
6796        }
6797        self.state.intents_sent += 1;
6798        Ok(())
6799    }
6800
6801    pub fn craft_menu_move(&mut self, delta: i32) {
6802        let n = self.state.blueprints.len();
6803        if n == 0 {
6804            return;
6805        }
6806        let idx = self.state.craft_menu_index as i32;
6807        let next = (idx + delta).rem_euclid(n as i32);
6808        self.state.craft_menu_index = next as usize;
6809        self.state.clamp_craft_batch_quantity();
6810    }
6811
6812    pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
6813        self.state.craft_batch_adjust_quantity(delta);
6814    }
6815
6816    pub fn craft_batch_set_max(&mut self) {
6817        self.state.craft_batch_set_max();
6818    }
6819
6820    pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
6821        let Some(blueprint) = self
6822            .state
6823            .blueprints
6824            .get(self.state.craft_menu_index)
6825            .cloned()
6826        else {
6827            anyhow::bail!("no blueprints known");
6828        };
6829        if !self.state.can_craft_blueprint(&blueprint) {
6830            let hint = self
6831                .state
6832                .craft_missing_hint(&blueprint)
6833                .unwrap_or_else(|| "missing materials".into());
6834            anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
6835        }
6836        let count = self.state.craft_batch_quantity;
6837        let max = self.state.max_craft_batches(&blueprint);
6838        if max == 0 {
6839            anyhow::bail!("cannot craft {}", blueprint.label);
6840        }
6841        let batches = count.min(max);
6842        self.craft(&blueprint.id, Some(batches)).await?;
6843        self.state.show_craft_menu = false;
6844        Ok(())
6845    }
6846
6847    pub async fn move_by(
6848        &mut self,
6849        forward: f32,
6850        strafe: f32,
6851        vertical: f32,
6852        sprint: bool,
6853    ) -> anyhow::Result<()> {
6854        if !self.state.is_alive() {
6855            anyhow::bail!("you are dead");
6856        }
6857        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
6858            self.last_move_forward = forward;
6859            self.last_move_strafe = strafe;
6860        }
6861        self.seq += 1;
6862        self.session
6863            .submit_intent(Intent::Move {
6864                entity_id: self.state.entity_id,
6865                forward,
6866                strafe,
6867                vertical,
6868                sprint,
6869                seq: self.seq,
6870            })
6871            .await?;
6872        self.state.intents_sent += 1;
6873        Ok(())
6874    }
6875
6876    pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
6877        if !self.state.connected {
6878            crate::harvest_trace!("harvest_nearest rejected: not connected");
6879            anyhow::bail!("not connected");
6880        }
6881        if !self.state.is_alive() {
6882            crate::harvest_trace!("harvest_nearest rejected: player dead");
6883            anyhow::bail!("you are dead");
6884        }
6885        if self.state.harvest_in_progress {
6886            if self.state.harvest_state_stale() {
6887                self.state.clear_harvest_state();
6888            } else {
6889                anyhow::bail!("already harvesting");
6890            }
6891        }
6892        let (px, py) = self
6893            .state
6894            .player
6895            .as_ref()
6896            .map(|p| (p.transform.position.x, p.transform.position.y))
6897            .unwrap_or((0.0, 0.0));
6898
6899        let available = self
6900            .state
6901            .resource_nodes
6902            .iter()
6903            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
6904            .count();
6905        let node_id = self
6906            .state
6907            .resource_nodes
6908            .iter()
6909            .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
6910            .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
6911            .min_by(|a, b| {
6912                let da = distance(px, py, a.x, a.y);
6913                let db = distance(px, py, b.x, b.y);
6914                da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
6915            })
6916            .map(|n| n.id.clone());
6917
6918        let Some(node_id) = node_id else {
6919            let has_loot = self
6920                .state
6921                .ground_drops
6922                .iter()
6923                .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
6924            if has_loot {
6925                return self.pickup_nearest().await;
6926            }
6927            anyhow::bail!(
6928                "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
6929            );
6930        };
6931
6932        self.seq += 1;
6933        let seq = self.seq;
6934        crate::harvest_trace!(
6935            entity_id = self.state.entity_id,
6936            node_id = %node_id,
6937            seq,
6938            px,
6939            py,
6940            available_nodes = available,
6941            "submitting harvest intent"
6942        );
6943        self.session
6944            .submit_intent(Intent::Harvest {
6945                entity_id: self.state.entity_id,
6946                node_id,
6947                seq,
6948            })
6949            .await?;
6950        self.state.intents_sent += 1;
6951        self.state.harvest_in_progress = true;
6952        self.state.harvest_started_at = Some(Instant::now());
6953        self.state.push_log("Harvesting…");
6954        crate::harvest_trace!(
6955            entity_id = self.state.entity_id,
6956            seq,
6957            "harvest intent queued to session"
6958        );
6959        Ok(())
6960    }
6961
6962    pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
6963        if !self.state.is_alive() {
6964            anyhow::bail!("you are dead");
6965        }
6966        let blueprint_id = self
6967            .state
6968            .blueprints
6969            .iter()
6970            .find(|bp| self.state.can_craft_blueprint(bp))
6971            .map(|bp| bp.id.clone())
6972            .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
6973        self.craft(&blueprint_id, None).await
6974    }
6975
6976    pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
6977        if !self.state.is_alive() {
6978            anyhow::bail!("you are dead");
6979        }
6980        self.seq += 1;
6981        self.session
6982            .submit_intent(Intent::Craft {
6983                entity_id: self.state.entity_id,
6984                blueprint_id: blueprint_id.to_string(),
6985                count,
6986                seq: self.seq,
6987            })
6988            .await?;
6989        self.state.intents_sent += 1;
6990        let (label, batches) = self
6991            .state
6992            .blueprints
6993            .iter()
6994            .find(|b| b.id == blueprint_id)
6995            .map(|b| {
6996                let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
6997                (b.label.as_str(), n)
6998            })
6999            .unwrap_or((blueprint_id, count.unwrap_or(1)));
7000        self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
7001        Ok(())
7002    }
7003
7004    pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
7005        if !self.state.is_alive() {
7006            anyhow::bail!("you are dead");
7007        }
7008        let target_id = match self.state.nearest_interact_target() {
7009            Some(id) => id,
7010            None => {
7011                anyhow::bail!("nothing to interact with nearby");
7012            }
7013        };
7014        if self.state.npcs.iter().any(|n| n.id == target_id) {
7015            self.state.show_npc_verb_menu = true;
7016            self.state.npc_verb_target = Some(target_id);
7017            self.state.npc_verb_index = 0;
7018            return Ok(());
7019        }
7020        self.seq += 1;
7021        self.session
7022            .submit_intent(Intent::Interact {
7023                entity_id: self.state.entity_id,
7024                target_id: target_id.clone(),
7025                seq: self.seq,
7026            })
7027            .await?;
7028        self.state.intents_sent += 1;
7029        Ok(())
7030    }
7031
7032    /// Context-sensitive world use: interact → pickup loot/chest → harvest/butcher.
7033    pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
7034        if !self.state.is_alive() {
7035            anyhow::bail!("you are dead");
7036        }
7037        if self.state.nearest_interact_target().is_some() {
7038            return self.interact_nearest().await;
7039        }
7040        // Prefer a clear "move closer" when a board is visible but out of reach,
7041        // instead of silently falling through to harvest.
7042        if let Some((label, dist)) = self.state.nearest_quest_board() {
7043            if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
7044                anyhow::bail!(
7045                    "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
7046                );
7047            }
7048        }
7049        let (px, py) = self.state.player_position();
7050        let has_loot = self
7051            .state
7052            .ground_drops
7053            .iter()
7054            .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
7055        if has_loot {
7056            return self.pickup_nearest().await;
7057        }
7058        if self
7059            .state
7060            .placed_containers
7061            .iter()
7062            .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
7063        {
7064            return self.pickup_nearest_container().await;
7065        }
7066        match self.harvest_nearest().await {
7067            Ok(()) => Ok(()),
7068            Err(err) => {
7069                let msg = err.to_string();
7070                if msg.contains("no harvestable")
7071                    || msg.contains("press p")
7072                    || msg.contains("press f")
7073                {
7074                    anyhow::bail!(
7075                        "nothing to use nearby — stand by an NPC/door, loot (*), chest, or resource"
7076                    );
7077                }
7078                Err(err)
7079            }
7080        }
7081    }
7082
7083    /// Cast a hotbar-bound ability (`1`–`9`). Heals prefer T2/self; others prefer T1.
7084    pub async fn cast_hotbar_ability(&mut self, ability_id: &str) -> anyhow::Result<()> {
7085        if !self.state.is_alive() {
7086            anyhow::bail!("you are dead");
7087        }
7088        let target = if ability_id == "heal_touch" {
7089            Some(
7090                self.state
7091                    .target_for_slot(2)
7092                    .unwrap_or(self.state.entity_id),
7093            )
7094        } else {
7095            self.state
7096                .target_for_slot(1)
7097                .or_else(|| self.state.target_for_slot(2))
7098        };
7099        let Some(target_id) = target else {
7100            anyhow::bail!("no target — Tab to select, then press the hotbar key");
7101        };
7102        self.cast_ability(ability_id, Some(target_id)).await
7103    }
7104
7105    pub fn npc_verb_options(&self) -> Vec<&'static str> {
7106        self.state.npc_verb_options()
7107    }
7108
7109    pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
7110        let Some(npc_id) = self.state.npc_verb_target.clone() else {
7111            return Ok(());
7112        };
7113        let options = self.npc_verb_options();
7114        let choice = options
7115            .get(self.state.npc_verb_index)
7116            .copied()
7117            .unwrap_or("Talk");
7118        self.seq += 1;
7119        match choice {
7120            "Trade" => {
7121                self.session
7122                    .submit_intent(Intent::Interact {
7123                        entity_id: self.state.entity_id,
7124                        target_id: npc_id,
7125                        seq: self.seq,
7126                    })
7127                    .await?;
7128            }
7129            _ => {
7130                self.session
7131                    .submit_intent(Intent::NpcTalkOpen {
7132                        entity_id: self.state.entity_id,
7133                        npc_id,
7134                        seq: self.seq,
7135                    })
7136                    .await?;
7137            }
7138        }
7139        self.state.intents_sent += 1;
7140        Ok(())
7141    }
7142
7143    pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
7144        let Some(chat) = self.state.npc_chat.clone() else {
7145            return Ok(());
7146        };
7147        let message = chat.input.trim().to_string();
7148        if message.is_empty() || chat.pending {
7149            return Ok(());
7150        }
7151        if let Some(c) = self.state.npc_chat.as_mut() {
7152            c.lines.push(format!("You: {message}"));
7153            c.input.clear();
7154            c.pending = true;
7155        }
7156        self.seq += 1;
7157        self.session
7158            .submit_intent(Intent::NpcTalkSay {
7159                entity_id: self.state.entity_id,
7160                npc_id: chat.npc_id,
7161                message,
7162                seq: self.seq,
7163            })
7164            .await?;
7165        self.state.intents_sent += 1;
7166        Ok(())
7167    }
7168
7169    pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
7170        let return_to_verbs = self.state.npc_verb_target.is_some();
7171        let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
7172            self.state.show_npc_chat = false;
7173            if return_to_verbs {
7174                self.state.show_npc_verb_menu = true;
7175            }
7176            return Ok(());
7177        };
7178        self.seq += 1;
7179        self.session
7180            .submit_intent(Intent::NpcTalkClose {
7181                entity_id: self.state.entity_id,
7182                npc_id,
7183                seq: self.seq,
7184            })
7185            .await?;
7186        self.state.intents_sent += 1;
7187        self.state.show_npc_chat = false;
7188        self.state.npc_chat = None;
7189        if return_to_verbs {
7190            self.state.show_npc_verb_menu = true;
7191        }
7192        Ok(())
7193    }
7194
7195    /// Esc/back inside Talk, Trade, quest-offer, or the verb menu — pop one layer, not the whole session.
7196    pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
7197        if self.state.show_quest_offer
7198            && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
7199        {
7200            self.quest_offer_decline();
7201            return Ok(());
7202        }
7203        if self.state.show_npc_chat {
7204            return self.npc_talk_close().await;
7205        }
7206        if self.state.show_shop_menu {
7207            self.back_from_shop_menu();
7208            return Ok(());
7209        }
7210        if self.state.show_npc_verb_menu {
7211            self.state.show_npc_verb_menu = false;
7212            self.state.npc_verb_target = None;
7213        }
7214        Ok(())
7215    }
7216
7217    pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
7218        self.seq += 1;
7219        self.session
7220            .submit_intent(Intent::TestDamage {
7221                entity_id: self.state.entity_id,
7222                amount,
7223                seq: self.seq,
7224            })
7225            .await?;
7226        self.state.intents_sent += 1;
7227        Ok(())
7228    }
7229
7230    pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
7231        self.cycle_combat_target_slot(1, reverse).await
7232    }
7233
7234    pub async fn cycle_combat_target_slot(
7235        &mut self,
7236        slot_index: u8,
7237        reverse: bool,
7238    ) -> anyhow::Result<()> {
7239        if !self.state.is_alive() {
7240            anyhow::bail!("you are dead");
7241        }
7242        let candidates = self.state.candidates_for_slot(slot_index);
7243        if candidates.is_empty() {
7244            anyhow::bail!("no targets nearby");
7245        }
7246        let current = self.state.target_for_slot(slot_index);
7247        let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
7248        let next_idx = match idx {
7249            None => 0,
7250            Some(i) if reverse => {
7251                if i == 0 {
7252                    candidates.len() - 1
7253                } else {
7254                    i - 1
7255                }
7256            }
7257            Some(i) => (i + 1) % candidates.len(),
7258        };
7259        if idx == Some(next_idx) && candidates.len() == 1 {
7260            self.clear_combat_target_slot(slot_index).await?;
7261            return Ok(());
7262        }
7263        let (target_id, label) = candidates[next_idx].clone();
7264        self.set_combat_target_slot(slot_index, target_id, &label)
7265            .await
7266    }
7267
7268    pub async fn set_combat_target_slot(
7269        &mut self,
7270        slot_index: u8,
7271        target_id: EntityId,
7272        label: &str,
7273    ) -> anyhow::Result<()> {
7274        if !self.state.is_alive() {
7275            anyhow::bail!("you are dead");
7276        }
7277        self.seq += 1;
7278        self.session
7279            .submit_intent(Intent::SetTargetSlot {
7280                entity_id: self.state.entity_id,
7281                slot_index,
7282                target_id,
7283                seq: self.seq,
7284            })
7285            .await?;
7286        self.state.intents_sent += 1;
7287        if slot_index == 1 {
7288            self.state.combat_target = Some(target_id);
7289            self.state.combat_target_label = Some(label.to_string());
7290        }
7291        self.state
7292            .push_log(format!("Slot {slot_index} target: {label}"));
7293        Ok(())
7294    }
7295
7296    pub async fn set_combat_target(
7297        &mut self,
7298        target_id: EntityId,
7299        label: &str,
7300    ) -> anyhow::Result<()> {
7301        self.set_combat_target_slot(1, target_id, label).await
7302    }
7303
7304    pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
7305        if slot_index == 1 && self.state.combat_target.is_none() {
7306            return Ok(());
7307        }
7308        self.seq += 1;
7309        self.session
7310            .submit_intent(Intent::ClearTargetSlot {
7311                entity_id: self.state.entity_id,
7312                slot_index,
7313                seq: self.seq,
7314            })
7315            .await?;
7316        if slot_index == 1 {
7317            self.state.combat_target = None;
7318            self.state.combat_target_label = None;
7319        }
7320        self.state.intents_sent += 1;
7321        self.state
7322            .push_log(format!("Slot {slot_index} target cleared"));
7323        Ok(())
7324    }
7325
7326    pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
7327        self.clear_combat_target_slot(1).await
7328    }
7329
7330    pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
7331        if !self.state.is_alive() {
7332            anyhow::bail!("you are dead");
7333        }
7334        self.seq += 1;
7335        self.session
7336            .submit_intent(Intent::AdvanceRotation {
7337                entity_id: self.state.entity_id,
7338                slot_index,
7339                seq: self.seq,
7340            })
7341            .await?;
7342        self.state.intents_sent += 1;
7343        Ok(())
7344    }
7345
7346    pub async fn assign_slot_preset(
7347        &mut self,
7348        slot_index: u8,
7349        preset_id: &str,
7350    ) -> anyhow::Result<()> {
7351        if !self.state.is_alive() {
7352            anyhow::bail!("you are dead");
7353        }
7354        self.seq += 1;
7355        self.session
7356            .submit_intent(Intent::AssignSlotPreset {
7357                entity_id: self.state.entity_id,
7358                slot_index,
7359                preset_id: preset_id.to_string(),
7360                seq: self.seq,
7361            })
7362            .await?;
7363        self.state.intents_sent += 1;
7364        if let Some(slot) = self
7365            .state
7366            .combat_slots
7367            .iter_mut()
7368            .find(|s| s.slot_index == slot_index)
7369        {
7370            slot.preset_id = Some(preset_id.to_string());
7371            if let Some(preset) = self
7372                .state
7373                .rotation_presets
7374                .iter()
7375                .find(|p| p.id == preset_id)
7376            {
7377                slot.preset_label = Some(preset.label.clone());
7378                slot.rotation = preset.abilities.clone();
7379                slot.rotation_index = 0;
7380            }
7381        }
7382        self.state
7383            .push_log(format!("T{slot_index} loadout → {preset_id}"));
7384        Ok(())
7385    }
7386
7387    pub async fn cast_ability(
7388        &mut self,
7389        ability_id: &str,
7390        target_id: Option<EntityId>,
7391    ) -> anyhow::Result<()> {
7392        if !self.state.is_alive() {
7393            anyhow::bail!("you are dead");
7394        }
7395        let target_id = target_id
7396            .or_else(|| self.state.target_for_slot(2))
7397            .or_else(|| self.state.target_for_slot(1))
7398            .unwrap_or(self.state.entity_id);
7399        self.seq += 1;
7400        self.session
7401            .submit_intent(Intent::Cast {
7402                entity_id: self.state.entity_id,
7403                ability_id: ability_id.to_string(),
7404                target_id,
7405                seq: self.seq,
7406            })
7407            .await?;
7408        self.state.intents_sent += 1;
7409        self.state
7410            .push_log(format!("Cast {ability_id} → {target_id}"));
7411        Ok(())
7412    }
7413
7414    pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
7415        self.seq += 1;
7416        self.session
7417            .submit_intent(Intent::UpsertRotationPreset {
7418                entity_id: self.state.entity_id,
7419                preset: preset.clone(),
7420                seq: self.seq,
7421            })
7422            .await?;
7423        self.state.intents_sent += 1;
7424        if let Some(existing) = self
7425            .state
7426            .rotation_presets
7427            .iter_mut()
7428            .find(|p| p.id == preset.id)
7429        {
7430            *existing = preset.clone();
7431        } else {
7432            self.state.rotation_presets.push(preset.clone());
7433        }
7434        for slot in &mut self.state.combat_slots {
7435            if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
7436                slot.preset_label = Some(preset.label.clone());
7437                slot.rotation = preset.abilities.clone();
7438            }
7439        }
7440        self.state
7441            .push_log(format!("Saved rotation: {}", preset.label));
7442        Ok(())
7443    }
7444
7445    pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
7446        self.seq += 1;
7447        self.session
7448            .submit_intent(Intent::DeleteRotationPreset {
7449                entity_id: self.state.entity_id,
7450                preset_id: preset_id.to_string(),
7451                seq: self.seq,
7452            })
7453            .await?;
7454        self.state.intents_sent += 1;
7455        self.state.rotation_presets.retain(|p| p.id != preset_id);
7456        for slot in &mut self.state.combat_slots {
7457            if slot.preset_id.as_deref() == Some(preset_id) {
7458                slot.preset_id = None;
7459                slot.preset_label = None;
7460                slot.rotation.clear();
7461                slot.rotation_index = 0;
7462            }
7463        }
7464        self.state
7465            .push_log(format!("Deleted rotation: {preset_id}"));
7466        Ok(())
7467    }
7468
7469    pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
7470        if !self.state.is_alive() {
7471            anyhow::bail!("you are dead");
7472        }
7473        let enabled = !self
7474            .state
7475            .combat_slots
7476            .iter()
7477            .find(|s| s.slot_index == slot_index)
7478            .map(|s| s.auto_enabled)
7479            .unwrap_or(false);
7480        self.seq += 1;
7481        self.session
7482            .submit_intent(Intent::SetAutoAttack {
7483                entity_id: self.state.entity_id,
7484                slot_index,
7485                enabled,
7486                seq: self.seq,
7487            })
7488            .await?;
7489        if slot_index == 1 {
7490            self.state.auto_attack = enabled;
7491        }
7492        self.state.intents_sent += 1;
7493        self.state.push_log(format!(
7494            "T{slot_index} auto {}",
7495            if enabled { "ON" } else { "OFF" }
7496        ));
7497        Ok(())
7498    }
7499
7500    pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
7501        if !self.state.connected {
7502            anyhow::bail!("not connected");
7503        }
7504        if !self.state.is_alive() {
7505            anyhow::bail!("you are dead");
7506        }
7507        let (px, py) = self.state.player_position();
7508        if self
7509            .state
7510            .ground_drops
7511            .iter()
7512            .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
7513        {
7514            anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
7515        }
7516        self.seq += 1;
7517        self.session
7518            .submit_intent(Intent::Pickup {
7519                entity_id: self.state.entity_id,
7520                drop_id: None,
7521                seq: self.seq,
7522            })
7523            .await?;
7524        self.state.intents_sent += 1;
7525        Ok(())
7526    }
7527
7528    pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
7529        self.toggle_auto_attack_slot(1).await
7530    }
7531
7532    pub async fn dodge(&mut self) -> anyhow::Result<()> {
7533        if !self.state.is_alive() {
7534            anyhow::bail!("you are dead");
7535        }
7536        self.seq += 1;
7537        self.session
7538            .submit_intent(Intent::Dodge {
7539                entity_id: self.state.entity_id,
7540                seq: self.seq,
7541            })
7542            .await?;
7543        self.state.intents_sent += 1;
7544        self.state.push_log("Dodge!");
7545        Ok(())
7546    }
7547
7548    pub async fn lunge(&mut self) -> anyhow::Result<()> {
7549        if !self.state.is_alive() {
7550            anyhow::bail!("you are dead");
7551        }
7552        let (forward, strafe) = self.last_move_axes();
7553        self.seq += 1;
7554        self.session
7555            .submit_intent(Intent::Lunge {
7556                entity_id: self.state.entity_id,
7557                forward,
7558                strafe,
7559                seq: self.seq,
7560            })
7561            .await?;
7562        self.state.intents_sent += 1;
7563        self.state.push_log("Lunge!");
7564        Ok(())
7565    }
7566
7567    pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
7568        if !self.state.is_alive() {
7569            anyhow::bail!("you are dead");
7570        }
7571        self.seq += 1;
7572        self.session
7573            .submit_intent(Intent::DirectionalJump {
7574                entity_id: self.state.entity_id,
7575                forward,
7576                strafe,
7577                seq: self.seq,
7578            })
7579            .await?;
7580        self.state.intents_sent += 1;
7581        self.state.push_log("Jump!");
7582        Ok(())
7583    }
7584
7585    /// Remembered WASD axes for lunge when not currently moving.
7586    pub fn last_move_axes(&self) -> (f32, f32) {
7587        (self.last_move_forward, self.last_move_strafe)
7588    }
7589
7590    pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
7591        if !self.state.is_alive() {
7592            anyhow::bail!("you are dead");
7593        }
7594        self.seq += 1;
7595        self.session
7596            .submit_intent(Intent::Block {
7597                entity_id: self.state.entity_id,
7598                enabled,
7599                seq: self.seq,
7600            })
7601            .await?;
7602        self.state.intents_sent += 1;
7603        if enabled {
7604            self.state.push_log("Blocking");
7605        }
7606        Ok(())
7607    }
7608
7609    pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
7610        if !self.state.is_alive() {
7611            anyhow::bail!("you are dead");
7612        }
7613        self.seq += 1;
7614        self.session
7615            .submit_intent(Intent::EquipMainhand {
7616                entity_id: self.state.entity_id,
7617                template_id,
7618                seq: self.seq,
7619            })
7620            .await?;
7621        self.state.intents_sent += 1;
7622        Ok(())
7623    }
7624
7625    pub async fn say(
7626        &mut self,
7627        channel: flatland_protocol::ChatChannel,
7628        text: &str,
7629    ) -> anyhow::Result<()> {
7630        self.seq += 1;
7631        self.session
7632            .submit_intent(Intent::Say {
7633                entity_id: self.state.entity_id,
7634                channel,
7635                text: text.to_string(),
7636                seq: self.seq,
7637            })
7638            .await?;
7639        self.state.intents_sent += 1;
7640        Ok(())
7641    }
7642
7643    pub async fn stop(&mut self) -> anyhow::Result<()> {
7644        self.seq += 1;
7645        self.session
7646            .submit_intent(Intent::Stop {
7647                entity_id: self.state.entity_id,
7648                seq: self.seq,
7649            })
7650            .await?;
7651        self.state.intents_sent += 1;
7652        Ok(())
7653    }
7654
7655    pub fn disconnect(&self) {
7656        self.session.disconnect();
7657    }
7658}
7659
7660fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
7661    let dx = ax - bx;
7662    let dy = ay - by;
7663    (dx * dx + dy * dy).sqrt()
7664}
7665
7666#[cfg(test)]
7667mod tests {
7668    use std::collections::BTreeMap;
7669
7670    use super::*;
7671    use flatland_protocol::{
7672        BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
7673    };
7674
7675    fn sample_state() -> GameState {
7676        let mut state = GameState {
7677            session_id: 1,
7678            entity_id: 1,
7679            character_id: None,
7680            tick: 0,
7681            chunk_rev: 0,
7682            content_rev: 0,
7683            publish_rev: 0,
7684            entities: vec![EntityState {
7685                id: 1,
7686                label: "You".into(),
7687                transform: Transform {
7688                    position: WorldCoord::surface(128.0, 128.0),
7689                    yaw: 0.0,
7690                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
7691                },
7692                vitals: None,
7693                attributes: None,
7694                skills: None,
7695                inside_building: None,
7696                tile_id: None,
7697                presentation_state: None,
7698                sprite_mode: None,
7699                progression_xp: None,
7700            }],
7701            player: None,
7702            resource_nodes: vec![ResourceNodeView {
7703                id: "oak-1".into(),
7704                label: "Oak".into(),
7705                x: 130.0,
7706                y: 128.0,
7707                z: 0.0,
7708                item_template: "oak_log".into(),
7709                state: ResourceNodeState::Available,
7710                blocking: true,
7711                blocking_radius_m: 0.8,
7712                tile_id: None,
7713                sprite_mode: None,
7714                presentation_state: None,
7715            }],
7716            ground_drops: vec![],
7717            placed_containers: vec![],
7718            buildings: vec![BuildingView {
7719                id: "broker-hut".into(),
7720                label: "Broker".into(),
7721                x: 148.0,
7722                y: 118.0,
7723                width_m: 8.0,
7724                depth_m: 6.0,
7725                interior_blueprint: Some("broker_hut".into()),
7726                tags: vec![],
7727            }],
7728            doors: vec![flatland_protocol::DoorView {
7729                id: "door-1".into(),
7730                building_id: "broker-hut".into(),
7731                x: 148.0,
7732                y: 118.0,
7733                open: false,
7734                portal: Some("front".into()),
7735            }],
7736            interior_map: None,
7737            npcs: vec![],
7738            blueprints: vec![],
7739            world_width_m: 256.0,
7740            world_height_m: 256.0,
7741            terrain_zones: Vec::new(),
7742            z_platforms: Vec::new(),
7743            z_transitions: Vec::new(),
7744            world_clock: flatland_protocol::WorldClock::default(),
7745            inventory: std::collections::HashMap::new(),
7746            inventory_hints: std::collections::HashMap::new(),
7747            logs: VecDeque::new(),
7748            intents_sent: 0,
7749            ticks_received: 0,
7750            connected: true,
7751            disconnect_reason: None,
7752            show_stats: false,
7753            show_craft_menu: false,
7754            craft_menu_index: 0,
7755            craft_batch_quantity: 1,
7756            show_shop_menu: false,
7757            shop_catalog: None,
7758            shop_tab: ShopTab::default(),
7759            shop_menu_index: 0,
7760            shop_quantity: 1,
7761            shop_trade_log: VecDeque::new(),
7762            show_npc_verb_menu: false,
7763            npc_verb_target: None,
7764            npc_verb_index: 0,
7765            show_npc_chat: false,
7766            npc_chat: None,
7767            show_inventory_menu: false,
7768            inventory_menu_index: 0,
7769            show_move_picker: false,
7770            show_rename_prompt: false,
7771            show_worker_rename: false,
7772            rename_buffer: String::new(),
7773            move_picker_index: 0,
7774            move_picker: None,
7775            show_destroy_picker: false,
7776            destroy_confirm_pending: false,
7777            destroy_picker: None,
7778            combat_target: None,
7779            combat_target_label: None,
7780            in_combat: false,
7781            auto_attack: true,
7782            combat_has_los: false,
7783            attack_cd_ticks: 0,
7784            gcd_ticks: 0,
7785            weapon_ability_id: "unarmed".into(),
7786            mainhand_template_id: None,
7787            mainhand_label: None,
7788            worn: BTreeMap::new(),
7789            carry_mass: 0.0,
7790            carry_mass_max: 0.0,
7791            encumbrance: flatland_protocol::EncumbranceState::Light,
7792            inventory_stacks: Vec::new(),
7793            keychain_stacks: Vec::new(),
7794            combat_target_detail: None,
7795            cast_progress: None,
7796            ability_cooldowns: Vec::new(),
7797            blocking_active: false,
7798            max_target_slots: 1,
7799            combat_slots: Vec::new(),
7800            rotation_presets: Vec::new(),
7801            show_loadout_menu: false,
7802            show_keychain_menu: false,
7803            keychain_menu_index: 0,
7804            show_rotation_editor: false,
7805            loadout_menu_index: 0,
7806            rotation_editor: RotationEditorState::default(),
7807            harvest_in_progress: false,
7808            harvest_started_at: None,
7809            pending_craft_ack: None,
7810            pending_worker_job_ack: None,
7811            quest_log: Vec::new(),
7812            interactables: Vec::new(),
7813            show_quest_offer: false,
7814            pending_quest_offer: None,
7815            show_quest_menu: false,
7816            quest_menu_index: 0,
7817            quest_withdraw_confirm: false,
7818            hired_workers: Vec::new(),
7819            show_workers_menu: false,
7820            workers_menu_index: 0,
7821            workers_menu_compact: false,
7822            worker_step_display: BTreeMap::new(),
7823            show_worker_give_picker: false,
7824            worker_give_picker_index: 0,
7825            worker_give_picker: None,
7826            show_worker_give_target_picker: false,
7827            worker_give_target_picker_index: 0,
7828            worker_give_target_picker: None,
7829            show_worker_take_picker: false,
7830            worker_take_picker_index: 0,
7831            worker_take_picker: None,
7832            show_worker_teach_picker: false,
7833            worker_teach_picker_index: 0,
7834            worker_teach_picker: None,
7835            worker_route_editor: None,
7836            progression_curve: None,
7837        };
7838        state.player = state.entities.first().cloned();
7839        state
7840    }
7841
7842    #[test]
7843    fn probe_use_world_npc_beats_nearby_loot() {
7844        let mut state = sample_state();
7845        state.npcs.push(flatland_protocol::NpcView {
7846            id: "ada".into(),
7847            label: "Ada".into(),
7848            role: "broker".into(),
7849            x: 129.0,
7850            y: 128.0,
7851            building_id: None,
7852            entity_id: None,
7853            life_state: None,
7854            hp_pct: None,
7855            can_trade: true,
7856            tile_id: None,
7857            behavior_state: None,
7858            presentation_state: None,
7859            sprite_mode: None,
7860        });
7861        state.ground_drops.push(flatland_protocol::GroundDropView {
7862            id: "d1".into(),
7863            template_id: "lumber".into(),
7864            quantity: 1,
7865            x: 128.5,
7866            y: 128.0,
7867            z: 0.0,
7868            tile_id: None,
7869        });
7870        let probe = state.probe_use_world();
7871        let primary = probe.primary.expect("primary");
7872        assert_eq!(primary.kind, crate::UseWorldKind::Npc);
7873        assert_eq!(primary.id, "ada");
7874    }
7875
7876    #[test]
7877    fn probe_use_world_harvest_when_in_range() {
7878        let state = sample_state(); // oak at 130,128 — player 128,128 → dist 2 > 1.5
7879        let probe = state.probe_use_world();
7880        assert!(
7881            probe.primary.is_none(),
7882            "oak is 2m away, out of harvest range"
7883        );
7884        assert!(probe
7885            .candidates
7886            .iter()
7887            .any(|c| c.kind == crate::UseWorldKind::Harvest));
7888
7889        let mut state = sample_state();
7890        state.resource_nodes[0].x = 129.0;
7891        let probe = state.probe_use_world();
7892        let primary = probe.primary.expect("primary");
7893        assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
7894    }
7895
7896    #[test]
7897    fn empty_entity_tick_preserves_welcome_snapshot() {
7898        let mut state = sample_state();
7899        state.inventory.insert("carrot".into(), 3);
7900        let delta = TickDelta {
7901            tick: 1,
7902            entities: vec![],
7903            resource_nodes: vec![],
7904            ground_drops: vec![],
7905            placed_containers: vec![],
7906            buildings: vec![],
7907            doors: vec![],
7908            interior_map: None,
7909            npcs: vec![],
7910            inventory: vec![],
7911            blueprints: vec![],
7912            world_clock: flatland_protocol::WorldClock::default(),
7913            combat: None,
7914            quest_log: vec![],
7915            hired_workers: Vec::new(),
7916            interactables: vec![],
7917        };
7918
7919        state.apply_tick_fields(&delta, 1);
7920
7921        assert_eq!(state.entities.len(), 1);
7922        assert!(state.player.is_some());
7923        assert_eq!(state.inventory.get("carrot"), Some(&3));
7924        assert_eq!(state.resource_nodes.len(), 1);
7925    }
7926
7927    #[test]
7928    fn tick_preserves_world_layers_when_delta_omits_them() {
7929        let mut state = sample_state();
7930        let delta = TickDelta {
7931            tick: 1,
7932            entities: state.entities.clone(),
7933            resource_nodes: vec![],
7934            ground_drops: vec![],
7935            placed_containers: vec![],
7936            buildings: vec![],
7937            doors: vec![],
7938            interior_map: None,
7939            npcs: vec![],
7940            inventory: vec![],
7941            blueprints: vec![],
7942            world_clock: flatland_protocol::WorldClock::default(),
7943            combat: None,
7944            quest_log: vec![],
7945            hired_workers: Vec::new(),
7946            interactables: vec![],
7947        };
7948
7949        state.apply_tick_fields(&delta, 1);
7950
7951        assert_eq!(state.resource_nodes.len(), 1);
7952        assert_eq!(state.buildings.len(), 1);
7953        assert_eq!(state.doors.len(), 1);
7954    }
7955
7956    #[test]
7957    fn tick_updates_resource_nodes_when_server_sends_them() {
7958        let mut state = sample_state();
7959        let delta = TickDelta {
7960            tick: 1,
7961            entities: state.entities.clone(),
7962            resource_nodes: vec![ResourceNodeView {
7963                id: "oak-1".into(),
7964                label: "Oak".into(),
7965                x: 130.0,
7966                y: 128.0,
7967                z: 0.0,
7968                item_template: "oak_log".into(),
7969                state: ResourceNodeState::Cooldown,
7970                blocking: true,
7971                blocking_radius_m: 0.8,
7972                tile_id: None,
7973                sprite_mode: None,
7974                presentation_state: None,
7975            }],
7976            buildings: vec![],
7977            doors: vec![],
7978            interior_map: None,
7979            npcs: vec![],
7980            inventory: vec![],
7981            blueprints: vec![],
7982            world_clock: flatland_protocol::WorldClock::default(),
7983            ground_drops: vec![],
7984            placed_containers: vec![],
7985            combat: None,
7986            quest_log: vec![],
7987            hired_workers: Vec::new(),
7988            interactables: vec![],
7989        };
7990
7991        state.apply_tick_fields(&delta, 1);
7992
7993        assert!(matches!(
7994            state.resource_nodes[0].state,
7995            ResourceNodeState::Cooldown
7996        ));
7997    }
7998
7999    #[test]
8000    fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
8001        let mut state = GameState {
8002            session_id: 1,
8003            entity_id: 1,
8004            character_id: None,
8005            tick: 0,
8006            chunk_rev: 0,
8007            content_rev: 0,
8008            publish_rev: 0,
8009            entities: vec![EntityState {
8010                id: 1,
8011                label: "You".into(),
8012                transform: Transform {
8013                    position: WorldCoord::surface(4.5, 2.0),
8014                    yaw: 0.0,
8015                    velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
8016                },
8017                vitals: None,
8018                attributes: None,
8019                skills: None,
8020                inside_building: Some("broker_hut".into()),
8021                tile_id: None,
8022                presentation_state: None,
8023                sprite_mode: None,
8024                progression_xp: None,
8025            }],
8026            player: None,
8027            resource_nodes: vec![],
8028            ground_drops: vec![],
8029            placed_containers: vec![],
8030            buildings: vec![BuildingView {
8031                id: "broker_hut".into(),
8032                label: "Broker".into(),
8033                x: 158.0,
8034                y: 124.0,
8035                width_m: 8.0,
8036                depth_m: 6.0,
8037                interior_blueprint: Some("broker_hut".into()),
8038                tags: vec![],
8039            }],
8040            doors: vec![flatland_protocol::DoorView {
8041                id: "broker_hut_exit".into(),
8042                building_id: "broker_hut".into(),
8043                x: 4.3,
8044                y: 0.9,
8045                open: true,
8046                portal: Some("front".into()),
8047            }],
8048            interior_map: None,
8049            npcs: vec![flatland_protocol::NpcView {
8050                id: "ada_broker".into(),
8051                label: "Ada".into(),
8052                x: 4.5,
8053                y: 2.0,
8054                building_id: Some("broker_hut".into()),
8055                role: "broker".into(),
8056                entity_id: None,
8057                life_state: None,
8058                hp_pct: None,
8059                can_trade: true,
8060                tile_id: None,
8061                behavior_state: None,
8062                presentation_state: None,
8063                sprite_mode: None,
8064            }],
8065            blueprints: vec![],
8066            world_width_m: 256.0,
8067            world_height_m: 256.0,
8068            terrain_zones: Vec::new(),
8069            z_platforms: Vec::new(),
8070            z_transitions: Vec::new(),
8071            world_clock: flatland_protocol::WorldClock::default(),
8072            inventory: std::collections::HashMap::new(),
8073            inventory_hints: std::collections::HashMap::new(),
8074            logs: VecDeque::new(),
8075            intents_sent: 0,
8076            ticks_received: 0,
8077            connected: true,
8078            disconnect_reason: None,
8079            show_stats: false,
8080            show_craft_menu: false,
8081            craft_menu_index: 0,
8082            craft_batch_quantity: 1,
8083            show_shop_menu: false,
8084            shop_catalog: None,
8085            shop_tab: ShopTab::default(),
8086            shop_menu_index: 0,
8087            shop_quantity: 1,
8088            shop_trade_log: VecDeque::new(),
8089            show_npc_verb_menu: false,
8090            npc_verb_target: None,
8091            npc_verb_index: 0,
8092            show_npc_chat: false,
8093            npc_chat: None,
8094            show_inventory_menu: false,
8095            inventory_menu_index: 0,
8096            show_move_picker: false,
8097            show_rename_prompt: false,
8098            show_worker_rename: false,
8099            rename_buffer: String::new(),
8100            move_picker_index: 0,
8101            move_picker: None,
8102            show_destroy_picker: false,
8103            destroy_confirm_pending: false,
8104            destroy_picker: None,
8105            combat_target: None,
8106            combat_target_label: None,
8107            in_combat: false,
8108            auto_attack: true,
8109            combat_has_los: false,
8110            attack_cd_ticks: 0,
8111            gcd_ticks: 0,
8112            weapon_ability_id: "unarmed".into(),
8113            mainhand_template_id: None,
8114            mainhand_label: None,
8115            worn: BTreeMap::new(),
8116            carry_mass: 0.0,
8117            carry_mass_max: 0.0,
8118            encumbrance: flatland_protocol::EncumbranceState::Light,
8119            inventory_stacks: Vec::new(),
8120            keychain_stacks: Vec::new(),
8121            combat_target_detail: None,
8122            cast_progress: None,
8123            ability_cooldowns: Vec::new(),
8124            blocking_active: false,
8125            max_target_slots: 1,
8126            combat_slots: Vec::new(),
8127            rotation_presets: Vec::new(),
8128            show_loadout_menu: false,
8129            show_keychain_menu: false,
8130            keychain_menu_index: 0,
8131            show_rotation_editor: false,
8132            loadout_menu_index: 0,
8133            rotation_editor: RotationEditorState::default(),
8134            harvest_in_progress: false,
8135            harvest_started_at: None,
8136            pending_craft_ack: None,
8137            pending_worker_job_ack: None,
8138            quest_log: Vec::new(),
8139            interactables: Vec::new(),
8140            show_quest_offer: false,
8141            pending_quest_offer: None,
8142            show_quest_menu: false,
8143            quest_menu_index: 0,
8144            quest_withdraw_confirm: false,
8145            hired_workers: Vec::new(),
8146            show_workers_menu: false,
8147            workers_menu_index: 0,
8148            workers_menu_compact: false,
8149            worker_step_display: BTreeMap::new(),
8150            show_worker_give_picker: false,
8151            worker_give_picker_index: 0,
8152            worker_give_picker: None,
8153            show_worker_give_target_picker: false,
8154            worker_give_target_picker_index: 0,
8155            worker_give_target_picker: None,
8156            show_worker_take_picker: false,
8157            worker_take_picker_index: 0,
8158            worker_take_picker: None,
8159            show_worker_teach_picker: false,
8160            worker_teach_picker_index: 0,
8161            worker_teach_picker: None,
8162            worker_route_editor: None,
8163            progression_curve: None,
8164        };
8165        state.player = state.entities.first().cloned();
8166        assert_eq!(
8167            state.nearest_interact_target().as_deref(),
8168            Some("ada_broker")
8169        );
8170    }
8171
8172    #[test]
8173    fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
8174        let mut state = sample_state();
8175        // Player is at (128, 128) per sample_state(). One chest just inside
8176        // CONTAINER_RANGE_M, one clearly beyond it.
8177        state.placed_containers = vec![
8178            flatland_protocol::PlacedContainerView {
8179                id: "near".into(),
8180                template_id: "wooden_chest_small".into(),
8181                display_name: "Wooden Chest".into(),
8182                x: 130.0,
8183                y: 128.0,
8184                z: 0.0,
8185                locked: true,
8186                accessible: true,
8187                owner_character_id: None,
8188                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
8189                lock_id: None,
8190                capacity_volume: None,
8191                item_instance_id: Some(uuid::Uuid::from_u128(1)),
8192                tile_id: None,
8193                worker_lodging_capacity: None,
8194            },
8195            flatland_protocol::PlacedContainerView {
8196                id: "far".into(),
8197                template_id: "wooden_chest_small".into(),
8198                display_name: "Distant Chest".into(),
8199                x: 128.0 + CONTAINER_RANGE_M + 5.0,
8200                y: 128.0,
8201                z: 0.0,
8202                locked: false,
8203                accessible: true,
8204                owner_character_id: None,
8205                contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
8206                lock_id: None,
8207                capacity_volume: None,
8208                item_instance_id: Some(uuid::Uuid::from_u128(2)),
8209                tile_id: None,
8210                worker_lodging_capacity: None,
8211            },
8212        ];
8213
8214        let nearby = state.nearby_containers();
8215        assert_eq!(
8216            nearby.len(),
8217            1,
8218            "far chest must not appear once out of range"
8219        );
8220        assert_eq!(nearby[0].view.id, "near");
8221        assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
8222        assert!(nearby[0].rows[0].is_chest_shell);
8223
8224        // The same chest, but locked and inaccessible (no key held), must hide
8225        // contents but still show the selectable chest shell row.
8226        state.placed_containers[0].accessible = false;
8227        let nearby = state.nearby_containers();
8228        assert_eq!(nearby.len(), 1);
8229        assert_eq!(nearby[0].rows.len(), 1);
8230        assert!(nearby[0].rows[0].is_chest_shell);
8231    }
8232
8233    #[test]
8234    fn chest_pickup_destinations_offer_person_and_worn_bag() {
8235        let mut state = sample_state();
8236        let back_id = uuid::Uuid::from_u128(42);
8237        state.worn.insert(
8238            BodySlot::Back,
8239            flatland_protocol::ItemStack {
8240                template_id: "travel_backpack".into(),
8241                quantity: 1,
8242                item_instance_id: Some(back_id),
8243                props: Default::default(),
8244                contents: Vec::new(),
8245                display_name: Some("Travel Backpack".into()),
8246                category: Some("container".into()),
8247                base_mass: Some(2.5),
8248                base_volume: Some(12.0),
8249                capacity_volume: Some(80.0),
8250                stackable: Some(false),
8251                world_placeable: Some(false),
8252                worker_lodging_capacity: None,
8253            },
8254        );
8255        let opts = state.chest_pickup_destinations("chest-1");
8256        assert!(opts.iter().any(|o| matches!(
8257            &o.kind,
8258            MoveOptionKind::PickupPlaced {
8259                nest_parent_instance_id: None,
8260                ..
8261            }
8262        )));
8263        assert!(opts.iter().any(|o| matches!(
8264            &o.kind,
8265            MoveOptionKind::PickupPlaced {
8266                nest_parent_instance_id: Some(id),
8267                ..
8268            } if *id == back_id
8269        )));
8270        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
8271    }
8272
8273    #[test]
8274    fn placed_container_public_label_hides_owner_custom_name() {
8275        let owner = uuid::Uuid::from_u128(99);
8276        let mut state = sample_state();
8277        state.character_id = Some(uuid::Uuid::from_u128(1));
8278        state.inventory_hints.insert(
8279            "wooden_chest_medium".into(),
8280            InventoryHint {
8281                display_name: "Medium Wooden Chest".into(),
8282                category: "container".into(),
8283                base_mass: None,
8284                base_volume: None,
8285                capacity_volume: None,
8286                stackable: false,
8287            },
8288        );
8289        let chest = flatland_protocol::PlacedContainerView {
8290            id: "c1".into(),
8291            template_id: "wooden_chest_medium".into(),
8292            display_name: "Barry's Loot #a3f2".into(),
8293            x: 128.0,
8294            y: 128.0,
8295            z: 0.0,
8296            locked: false,
8297            accessible: true,
8298            owner_character_id: Some(owner),
8299            contents: vec![],
8300            lock_id: None,
8301            capacity_volume: None,
8302            item_instance_id: None,
8303            tile_id: None,
8304            worker_lodging_capacity: None,
8305        };
8306        assert_eq!(
8307            state.placed_container_public_label(&chest),
8308            "Medium Wooden Chest"
8309        );
8310        state.character_id = Some(owner);
8311        assert_eq!(
8312            state.placed_container_public_label(&chest),
8313            "Barry's Loot #a3f2"
8314        );
8315    }
8316
8317    #[test]
8318    fn location_context_lists_nearby_resource_node() {
8319        let mut state = sample_state();
8320        state.player = state.entities.first().cloned();
8321        state.resource_nodes[0].x = 128.2;
8322        state.resource_nodes[0].y = 128.0;
8323        let lines = state.location_context_lines();
8324        assert!(
8325            lines
8326                .iter()
8327                .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
8328            "expected resource node in context: {:?}",
8329            lines
8330        );
8331    }
8332
8333    #[test]
8334    fn quest_board_usable_within_board_radius() {
8335        let mut state = sample_state();
8336        state.player = state.entities.first().cloned();
8337        state.interactables = vec![flatland_protocol::InteractableView {
8338            id: "board-1".into(),
8339            kind: "quest_board".into(),
8340            label: "Town Quest Board".into(),
8341            x: 130.5,
8342            y: 128.0,
8343            z: 0.0,
8344            board_id: Some("starter_town_board".into()),
8345        }];
8346        // ~2.5m away — outside the old 1.5m interact radius, inside the 3.0m board radius.
8347        assert_eq!(
8348            state.nearest_interact_target().as_deref(),
8349            Some("board-1"),
8350            "quest board should be selectable at ~2.5m"
8351        );
8352        let lines = state.location_context_lines();
8353        assert!(
8354            lines
8355                .iter()
8356                .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
8357            "HUD should advertise f when board is in range: {:?}",
8358            lines
8359        );
8360    }
8361
8362    #[test]
8363    fn inventory_selectable_rows_orders_worn_before_person_before_nearby() {
8364        let mut state = sample_state();
8365        state.worn.insert(
8366            BodySlot::Back,
8367            flatland_protocol::ItemStack {
8368                template_id: "travel_backpack".into(),
8369                quantity: 1,
8370                item_instance_id: Some(uuid::Uuid::from_u128(3)),
8371                props: Default::default(),
8372                contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
8373                display_name: None,
8374                category: None,
8375                base_mass: None,
8376                base_volume: None,
8377                capacity_volume: None,
8378                stackable: None,
8379                world_placeable: None,
8380                worker_lodging_capacity: None,
8381            },
8382        );
8383        state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
8384        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
8385            id: "chest-1".into(),
8386            template_id: "wooden_chest_small".into(),
8387            display_name: "Wooden Chest".into(),
8388            x: 129.0,
8389            y: 128.0,
8390            z: 0.0,
8391            locked: false,
8392            accessible: true,
8393            owner_character_id: None,
8394            contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
8395            lock_id: None,
8396            capacity_volume: None,
8397            item_instance_id: Some(uuid::Uuid::from_u128(4)),
8398            tile_id: None,
8399            worker_lodging_capacity: None,
8400        }];
8401
8402        let rows = state.inventory_selectable_rows();
8403        let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
8404        assert_eq!(
8405            sections,
8406            vec![
8407                InventorySection::Worn,   // backpack shell
8408                InventorySection::Worn,   // iron_ore nested in backpack
8409                InventorySection::Person, // lumber
8410                InventorySection::Nearby, // chest shell
8411                InventorySection::Nearby, // wood_axe in chest
8412            ]
8413        );
8414        assert_eq!(rows[0].stack.template_id, "travel_backpack");
8415        assert!(rows[0].is_equip_shell);
8416        assert_eq!(rows[1].stack.template_id, "iron_ore");
8417        assert_eq!(rows[1].depth, 1);
8418        assert_eq!(rows[2].stack.template_id, "lumber");
8419        assert!(rows[3].is_chest_shell);
8420        assert_eq!(rows[4].stack.template_id, "wood_axe");
8421        assert_eq!(rows[4].depth, 1);
8422
8423        let lines = state.inventory_browser_lines();
8424        assert!(lines.iter().any(|l| matches!(
8425            l,
8426            InventoryBrowserLine::Section(s) if s.contains("Worn")
8427        )));
8428        assert!(lines.iter().any(|l| matches!(
8429            l,
8430            InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
8431                || text.contains("backpack")
8432        )));
8433    }
8434
8435    #[test]
8436    fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
8437        let mut state = sample_state();
8438        let back_id = uuid::Uuid::from_u128(5);
8439        state.worn.insert(
8440            BodySlot::Back,
8441            flatland_protocol::ItemStack {
8442                template_id: "travel_backpack".into(),
8443                quantity: 1,
8444                item_instance_id: Some(back_id),
8445                props: Default::default(),
8446                contents: Vec::new(),
8447                display_name: None,
8448                category: Some("container".into()),
8449                base_mass: None,
8450                base_volume: None,
8451                capacity_volume: Some(80.0),
8452                stackable: None,
8453                world_placeable: None,
8454                worker_lodging_capacity: None,
8455            },
8456        );
8457        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
8458            id: "chest-1".into(),
8459            template_id: "wooden_chest_small".into(),
8460            display_name: "Wooden Chest".into(),
8461            x: 129.0,
8462            y: 128.0,
8463            z: 0.0,
8464            locked: false,
8465            accessible: true,
8466            owner_character_id: None,
8467            contents: Vec::new(),
8468            lock_id: None,
8469            capacity_volume: None,
8470            item_instance_id: Some(uuid::Uuid::from_u128(6)),
8471            tile_id: None,
8472            worker_lodging_capacity: None,
8473        }];
8474
8475        // Item currently sitting loose on the person (Root): backpack + nearby
8476        // chest should both be offered, plus Drop/Cancel, but not "Root" itself.
8477        let opts = state.move_destinations_for(
8478            &flatland_protocol::InventoryLocation::Root,
8479            None,
8480            None,
8481            "lumber",
8482        );
8483        assert!(!opts.iter().any(|o| matches!(
8484            &o.kind,
8485            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
8486        )));
8487        assert!(opts.iter().any(|o| matches!(
8488            &o.kind,
8489            MoveOptionKind::Move { location, parent_instance_id, .. }
8490                if *location == flatland_protocol::InventoryLocation::Worn {
8491                    slot: BodySlot::Back,
8492                } && *parent_instance_id == Some(back_id)
8493        )));
8494        assert!(opts.iter().any(|o| matches!(
8495            &o.kind,
8496            MoveOptionKind::Move { location, .. }
8497                if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
8498        )));
8499        assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
8500        assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
8501
8502        // Item currently inside the worn backpack: the backpack itself must be
8503        // excluded from its own destination list (can't move an item into the
8504        // container it's already in).
8505        let from_backpack = flatland_protocol::InventoryLocation::Worn {
8506            slot: BodySlot::Back,
8507        };
8508        let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
8509        assert!(!opts.iter().any(|o| matches!(
8510            &o.kind,
8511            MoveOptionKind::Move { location, parent_instance_id, .. }
8512                if *location == from_backpack && *parent_instance_id == Some(back_id)
8513        )));
8514        assert!(opts.iter().any(|o| matches!(
8515            &o.kind,
8516            MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
8517        )));
8518    }
8519
8520    #[test]
8521    fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
8522        let mut state = sample_state();
8523        // Insert out of display order — BTreeMap iteration must still yield the
8524        // canonical Head/Body/Arms/Legs/Feet/Back/Waist order regardless.
8525        state.worn.insert(
8526            BodySlot::Waist,
8527            flatland_protocol::ItemStack {
8528                template_id: "simple_belt".into(),
8529                quantity: 1,
8530                item_instance_id: Some(uuid::Uuid::from_u128(10)),
8531                props: Default::default(),
8532                contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
8533                display_name: None,
8534                category: Some("container".into()),
8535                base_mass: None,
8536                base_volume: None,
8537                capacity_volume: None,
8538                stackable: None,
8539                world_placeable: None,
8540                worker_lodging_capacity: None,
8541            },
8542        );
8543        state.worn.insert(
8544            BodySlot::Head,
8545            flatland_protocol::ItemStack {
8546                template_id: "cloth_cap".into(),
8547                quantity: 1,
8548                item_instance_id: Some(uuid::Uuid::from_u128(11)),
8549                props: Default::default(),
8550                contents: Vec::new(),
8551                display_name: None,
8552                category: Some("armor".into()),
8553                base_mass: None,
8554                base_volume: None,
8555                capacity_volume: None,
8556                stackable: None,
8557                world_placeable: None,
8558                worker_lodging_capacity: None,
8559            },
8560        );
8561        state.worn.insert(
8562            BodySlot::Back,
8563            flatland_protocol::ItemStack {
8564                template_id: "travel_backpack".into(),
8565                quantity: 1,
8566                item_instance_id: Some(uuid::Uuid::from_u128(12)),
8567                props: Default::default(),
8568                contents: Vec::new(),
8569                display_name: None,
8570                category: Some("container".into()),
8571                base_mass: None,
8572                base_volume: None,
8573                capacity_volume: None,
8574                stackable: None,
8575                world_placeable: None,
8576                worker_lodging_capacity: None,
8577            },
8578        );
8579
8580        let rows = state.worn_rows();
8581        // Head, then Back, then Waist (+ nested pouch) — enum declaration order.
8582        assert_eq!(rows.len(), 4);
8583        assert_eq!(rows[0].stack.template_id, "cloth_cap");
8584        assert!(rows[0].is_equip_shell);
8585        assert_eq!(rows[1].stack.template_id, "travel_backpack");
8586        assert!(rows[1].is_equip_shell);
8587        assert_eq!(rows[2].stack.template_id, "simple_belt");
8588        assert!(rows[2].is_equip_shell);
8589        assert_eq!(rows[3].stack.template_id, "leather_pouch");
8590        assert_eq!(rows[3].depth, 1);
8591        assert!(!rows[3].is_equip_shell);
8592    }
8593
8594    #[test]
8595    fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
8596        let mut state = sample_state();
8597        state.worn.insert(
8598            BodySlot::Waist,
8599            flatland_protocol::ItemStack {
8600                template_id: "simple_belt".into(),
8601                quantity: 1,
8602                item_instance_id: Some(uuid::Uuid::from_u128(20)),
8603                props: Default::default(),
8604                contents: Vec::new(),
8605                display_name: Some("Simple Belt".into()),
8606                category: Some("container".into()),
8607                base_mass: None,
8608                base_volume: None,
8609                capacity_volume: None,
8610                stackable: None,
8611                world_placeable: None,
8612                worker_lodging_capacity: None,
8613            },
8614        );
8615        state.worn.insert(
8616            BodySlot::Head,
8617            flatland_protocol::ItemStack {
8618                template_id: "cloth_cap".into(),
8619                quantity: 1,
8620                item_instance_id: Some(uuid::Uuid::from_u128(21)),
8621                props: Default::default(),
8622                contents: Vec::new(),
8623                display_name: Some("Cloth Cap".into()),
8624                category: Some("armor".into()),
8625                base_mass: None,
8626                base_volume: None,
8627                capacity_volume: None,
8628                stackable: None,
8629                world_placeable: None,
8630                worker_lodging_capacity: None,
8631            },
8632        );
8633
8634        let opts = state.move_destinations_for(
8635            &flatland_protocol::InventoryLocation::Root,
8636            None,
8637            None,
8638            "leather_pouch",
8639        );
8640        assert!(
8641            opts.iter().any(|o| matches!(
8642                &o.kind,
8643                MoveOptionKind::Move { location, .. }
8644                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
8645            )),
8646            "belt loop must be offered when moving a pouch"
8647        );
8648        assert!(
8649            !opts.iter().any(|o| matches!(
8650                &o.kind,
8651                MoveOptionKind::Move { location, .. }
8652                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
8653            )),
8654            "armor slots can't hold other items and must not appear as move destinations"
8655        );
8656        let belt_opt = opts
8657            .iter()
8658            .find(|o| matches!(
8659                &o.kind,
8660                MoveOptionKind::Move { location, .. }
8661                    if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
8662            ))
8663            .unwrap();
8664        assert!(belt_opt.label.contains("belt loop"));
8665
8666        let opts = state.move_destinations_for(
8667            &flatland_protocol::InventoryLocation::Root,
8668            None,
8669            None,
8670            "lumber",
8671        );
8672        assert!(
8673            !opts.iter().any(|o| o.label.contains("belt loop")),
8674            "loose materials must not target the belt shell — only nested pouches"
8675        );
8676    }
8677
8678    #[test]
8679    fn move_destinations_for_offers_dimensional_pouch_on_belt() {
8680        let mut state = sample_state();
8681        let belt_id = uuid::Uuid::from_u128(30);
8682        let pouch_id = uuid::Uuid::from_u128(31);
8683        state.worn.insert(
8684            BodySlot::Waist,
8685            flatland_protocol::ItemStack {
8686                template_id: "simple_belt".into(),
8687                quantity: 1,
8688                item_instance_id: Some(belt_id),
8689                props: Default::default(),
8690                world_placeable: None,
8691                worker_lodging_capacity: None,
8692                contents: vec![flatland_protocol::ItemStack {
8693                    template_id: "dimensional_pouch".into(),
8694                    quantity: 1,
8695                    item_instance_id: Some(pouch_id),
8696                    props: Default::default(),
8697                    contents: Vec::new(),
8698                    display_name: Some("Dimensional Pouch".into()),
8699                    category: Some("container".into()),
8700                    base_mass: None,
8701                    base_volume: None,
8702                    capacity_volume: Some(200.0),
8703                    stackable: None,
8704                    world_placeable: None,
8705                    worker_lodging_capacity: None,
8706                }],
8707                display_name: Some("Simple Belt".into()),
8708                category: Some("container".into()),
8709                base_mass: None,
8710                base_volume: None,
8711                capacity_volume: None,
8712                stackable: None,
8713            },
8714        );
8715
8716        let opts = state.move_destinations_for(
8717            &flatland_protocol::InventoryLocation::Root,
8718            None,
8719            None,
8720            "iron_ore",
8721        );
8722        assert!(
8723            opts.iter().any(|o| matches!(
8724                &o.kind,
8725                MoveOptionKind::Move {
8726                    location,
8727                    parent_instance_id,
8728                    ..
8729                } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
8730                    && *parent_instance_id == Some(pouch_id)
8731            )),
8732            "dimensional pouch clipped on belt must accept loose items"
8733        );
8734        assert!(
8735            opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
8736            "destination label should name the pouch"
8737        );
8738    }
8739
8740    #[test]
8741    fn container_volume_label_on_placed_chest_shell() {
8742        let mut state = sample_state();
8743        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
8744            id: "chest-1".into(),
8745            template_id: "wooden_chest_small".into(),
8746            display_name: "Camp Chest".into(),
8747            worker_lodging_capacity: None,
8748            x: 129.0,
8749            y: 128.0,
8750            z: 0.0,
8751            locked: false,
8752            accessible: true,
8753            owner_character_id: None,
8754            contents: vec![flatland_protocol::ItemStack {
8755                template_id: "iron_ore".into(),
8756                quantity: 2,
8757                item_instance_id: None,
8758                props: Default::default(),
8759                contents: Vec::new(),
8760                display_name: None,
8761                category: None,
8762                base_mass: None,
8763                base_volume: Some(2.0),
8764                capacity_volume: None,
8765                stackable: None,
8766                world_placeable: None,
8767                worker_lodging_capacity: None,
8768            }],
8769            lock_id: None,
8770            capacity_volume: Some(60.0),
8771            item_instance_id: Some(uuid::Uuid::from_u128(4)),
8772            tile_id: None,
8773        }];
8774        let nearby = state.nearby_containers();
8775        let label = state.container_volume_label(&nearby[0].rows[0]);
8776        assert!(
8777            label.contains("vol 4/60"),
8778            "expected used/cap in label, got {label}"
8779        );
8780        assert!(
8781            label.contains("56 free"),
8782            "expected free space, got {label}"
8783        );
8784    }
8785
8786    #[test]
8787    fn key_pair_chest_label_from_placed_lock_id() {
8788        let mut state = sample_state();
8789        let owner = uuid::Uuid::from_u128(77);
8790        state.character_id = Some(owner);
8791        let lock = uuid::Uuid::from_u128(99).to_string();
8792        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
8793            id: "chest-1".into(),
8794            template_id: "wooden_chest_small".into(),
8795            display_name: "Barry's Loot #a3f2".into(),
8796            x: 129.0,
8797            y: 128.0,
8798            z: 0.0,
8799            locked: true,
8800            accessible: true,
8801            owner_character_id: Some(owner),
8802            contents: Vec::new(),
8803            lock_id: Some(lock.clone()),
8804            capacity_volume: None,
8805            item_instance_id: Some(uuid::Uuid::from_u128(4)),
8806            tile_id: None,
8807            worker_lodging_capacity: None,
8808        }];
8809        let key_id = uuid::Uuid::from_u128(5);
8810        let key = flatland_protocol::ItemStack {
8811            template_id: KEY_TEMPLATE.into(),
8812            quantity: 1,
8813            item_instance_id: Some(key_id),
8814            props: BTreeMap::from([
8815                (PROP_OPENS_LOCK_ID.into(), lock),
8816                (
8817                    PROP_OPENS_CONTAINER_NAME.into(),
8818                    "Barry's Loot #a3f2".into(),
8819                ),
8820            ]),
8821            contents: Vec::new(),
8822            display_name: Some("Container Key".into()),
8823            category: Some("key".into()),
8824            base_mass: None,
8825            base_volume: None,
8826            capacity_volume: None,
8827            stackable: None,
8828            world_placeable: None,
8829            worker_lodging_capacity: None,
8830        };
8831        state.inventory_stacks = vec![key.clone()];
8832        assert_eq!(
8833            state.key_pair_chest_label(&key).as_deref(),
8834            Some("Barry's Loot #a3f2")
8835        );
8836        assert!(state.key_drop_blocked(&key));
8837    }
8838
8839    #[test]
8840    fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
8841        let mut state = sample_state();
8842        let lock = uuid::Uuid::from_u128(101).to_string();
8843        let key = flatland_protocol::ItemStack {
8844            template_id: KEY_TEMPLATE.into(),
8845            quantity: 1,
8846            item_instance_id: Some(uuid::Uuid::from_u128(7)),
8847            props: BTreeMap::from([
8848                (PROP_OPENS_LOCK_ID.into(), lock),
8849                (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
8850            ]),
8851            contents: Vec::new(),
8852            display_name: None,
8853            category: Some("key".into()),
8854            base_mass: None,
8855            base_volume: None,
8856            capacity_volume: None,
8857            stackable: None,
8858            world_placeable: None,
8859            worker_lodging_capacity: None,
8860        };
8861        state.placed_containers.clear();
8862        assert_eq!(
8863            state.key_pair_chest_label(&key).as_deref(),
8864            Some("Camp Stash")
8865        );
8866    }
8867
8868    #[test]
8869    fn key_drop_allowed_when_paired_chest_unlocked() {
8870        let mut state = sample_state();
8871        let lock = uuid::Uuid::from_u128(100).to_string();
8872        let key_id = uuid::Uuid::from_u128(6);
8873        state.placed_containers = vec![flatland_protocol::PlacedContainerView {
8874            id: "chest-1".into(),
8875            template_id: "wooden_chest_small".into(),
8876            display_name: "Camp Chest".into(),
8877            x: 129.0,
8878            y: 128.0,
8879            z: 0.0,
8880            locked: false,
8881            accessible: true,
8882            owner_character_id: None,
8883            contents: Vec::new(),
8884            lock_id: Some(lock.clone()),
8885            capacity_volume: None,
8886            item_instance_id: None,
8887            tile_id: None,
8888            worker_lodging_capacity: None,
8889        }];
8890        let key = flatland_protocol::ItemStack {
8891            template_id: KEY_TEMPLATE.into(),
8892            quantity: 1,
8893            item_instance_id: Some(key_id),
8894            props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
8895            contents: Vec::new(),
8896            display_name: None,
8897            category: Some("key".into()),
8898            base_mass: None,
8899            base_volume: None,
8900            capacity_volume: None,
8901            stackable: None,
8902            world_placeable: None,
8903            worker_lodging_capacity: None,
8904        };
8905        state.inventory_stacks = vec![key.clone()];
8906        assert!(!state.key_drop_blocked(&key));
8907        let opts = state.move_destinations_for(
8908            &flatland_protocol::InventoryLocation::Root,
8909            None,
8910            Some(key_id),
8911            KEY_TEMPLATE,
8912        );
8913        assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
8914    }
8915
8916    #[test]
8917    fn combat_hud_refreshes_progression_xp_when_entity_stale() {
8918        use flatland_protocol::{CombatHud, ProgressionXp, ProgressionCurve};
8919
8920        let mut state = sample_state();
8921        let curve = ProgressionCurve::default();
8922        let bootstrap = ProgressionXp::bootstrap_new(
8923            curve.baseline_display,
8924            curve.xp_base,
8925            curve.xp_growth,
8926        );
8927        let mut fresh = bootstrap.clone();
8928        fresh.strength += 0.08;
8929        if let Some(player) = state.player.as_mut() {
8930            player.progression_xp = Some(bootstrap);
8931        }
8932
8933        let combat = CombatHud {
8934            progression_xp: Some(fresh.clone()),
8935            progression_baseline: curve.baseline_display,
8936            progression_xp_base: curve.xp_base,
8937            progression_xp_growth: curve.xp_growth,
8938            attributes: state.player.as_ref().and_then(|p| p.attributes),
8939            skills: state.player.as_ref().and_then(|p| p.skills.clone()),
8940            ..CombatHud::default()
8941        };
8942        state.apply_combat_hud(&combat);
8943
8944        let xp = state
8945            .player
8946            .as_ref()
8947            .and_then(|p| p.progression_xp.as_ref())
8948            .expect("xp");
8949        assert!((xp.strength - fresh.strength).abs() < 0.001);
8950        assert!(state.progression_curve.is_some());
8951    }
8952
8953    #[test]
8954    fn loose_consumable_move_picker_offers_use_and_storage() {
8955        let mut state = sample_state();
8956        let inst = uuid::Uuid::from_u128(77);
8957        state.inventory_stacks = vec![flatland_protocol::ItemStack {
8958            template_id: "carrot".into(),
8959            quantity: 2,
8960            item_instance_id: Some(inst),
8961            props: Default::default(),
8962            contents: Vec::new(),
8963            display_name: Some("Wild Carrot".into()),
8964            category: Some("consumable".into()),
8965            base_mass: None,
8966            base_volume: None,
8967            capacity_volume: None,
8968            stackable: Some(true),
8969            world_placeable: None,
8970            worker_lodging_capacity: None,
8971        }];
8972        state.inventory_hints.insert(
8973            "carrot".into(),
8974            InventoryHint {
8975                display_name: "Wild Carrot".into(),
8976                category: "consumable".into(),
8977                base_mass: Some(0.15),
8978                base_volume: Some(0.3),
8979                capacity_volume: None,
8980                stackable: true,
8981            },
8982        );
8983        state.show_inventory_menu = true;
8984        state.inventory_menu_index = 0;
8985
8986        let row = state.inventory_selected_row().expect("carrot row");
8987        let mut options = state.move_destinations_for(
8988            &row.from,
8989            row.from_parent_instance_id,
8990            row.stack.item_instance_id,
8991            &row.stack.template_id,
8992        );
8993        if row.from == flatland_protocol::InventoryLocation::Root
8994            && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
8995        {
8996            options.insert(
8997                0,
8998                MoveOption {
8999                    label: "Use (eat / drink)".into(),
9000                    kind: MoveOptionKind::Use,
9001                },
9002            );
9003        }
9004
9005        assert_eq!(options.first().map(|o| &o.label), Some(&"Use (eat / drink)".into()));
9006        assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
9007        assert!(options.iter().any(|o| matches!(o.kind, MoveOptionKind::Drop)));
9008    }
9009}