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