Skip to main content

flatland_client_lib/
game.rs

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