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