1use std::collections::{BTreeMap, VecDeque};
2use std::time::{Duration, Instant};
3
4use flatland_protocol::{
5 AbilityCooldownHud, BlueprintView, BodySlot, BuildingView, CastProgressHud, CombatHud,
6 CombatSlotHud, CombatTargetHud, DoorView, EntityId, EntityState, Intent, InteriorMapView,
7 LifeState, NpcView, RotationPreset, Seq, SessionId, TerrainKindView, TerrainZoneView, Tick,
8 ZPlatformView, ZTransitionView,
9};
10
11use crate::session::{PlayConnection, SessionEvent};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum CharacterSheetTab {
15 #[default]
16 Character,
17 Ledger,
18 Career,
19}
20
21impl CharacterSheetTab {
22 pub fn cycle(self) -> Self {
23 match self {
24 Self::Character => Self::Ledger,
25 Self::Ledger => Self::Career,
26 Self::Career => Self::Character,
27 }
28 }
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum LedgerPeriod {
33 #[default]
34 Day,
35 Week,
36 Month,
37 Lifetime,
38}
39
40impl LedgerPeriod {
41 pub fn label(self) -> &'static str {
42 match self {
43 Self::Day => "Day",
44 Self::Week => "Week",
45 Self::Month => "Month",
46 Self::Lifetime => "All",
47 }
48 }
49
50 pub fn cycle(self) -> Self {
51 match self {
52 Self::Day => Self::Week,
53 Self::Week => Self::Month,
54 Self::Month => Self::Lifetime,
55 Self::Lifetime => Self::Day,
56 }
57 }
58
59 pub fn from_digit(c: char) -> Option<Self> {
60 match c {
61 '1' => Some(Self::Day),
62 '2' => Some(Self::Week),
63 '3' => Some(Self::Month),
64 '4' => Some(Self::Lifetime),
65 _ => None,
66 }
67 }
68}
69
70const KEY_TEMPLATE: &str = "container_key";
72const PROP_LOCK_ID: &str = "lock_id";
73const PROP_OPENS_LOCK_ID: &str = "opens_lock_id";
74const PROP_OPENS_CONTAINER_NAME: &str = "opens_container_name";
75const PROP_CUSTOM_NAME: &str = "custom_name";
76const PROP_LOCKED: &str = "locked";
77
78fn stack_is_locked(stack: &flatland_protocol::ItemStack) -> bool {
79 stack
80 .props
81 .get(PROP_LOCKED)
82 .is_some_and(|v| v == "true" || v == "1")
83}
84
85const MAX_LOG_LINES: usize = 200;
86const MAX_SHOP_TRADE_LOG_LINES: usize = 40;
87const INTERACTION_RADIUS_M: f32 = 1.5;
88const DOOR_INTERACTION_RADIUS_M: f32 = 3.5;
89const QUEST_BOARD_INTERACTION_RADIUS_M: f32 = 3.0;
90const HARVEST_CLIENT_TIMEOUT: Duration = Duration::from_secs(12);
91const CRAFT_STAMINA_COST: f32 = 3.0;
93const WORKER_STEP_HOLD: Duration = Duration::from_millis(1200);
95const WORKER_ERROR_HOLD: Duration = Duration::from_secs(45);
97
98#[derive(Debug, Clone, Default)]
100pub struct InventoryHint {
101 pub display_name: String,
102 pub category: String,
103 pub base_mass: Option<f32>,
104 pub base_volume: Option<f32>,
105 pub capacity_volume: Option<f32>,
106 pub stackable: bool,
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
111pub enum RotationEditorMode {
112 #[default]
113 List,
114 EditSequence,
115 PickAbility,
116 EditLabel,
117}
118
119#[derive(Debug, Clone, Default)]
121pub struct RotationEditorState {
122 pub mode: RotationEditorMode,
123 pub list_index: usize,
124 pub ability_index: usize,
125 pub picker_index: usize,
126 pub draft: Option<RotationPreset>,
127 pub label_buffer: String,
128}
129
130impl RotationEditorState {
131 pub fn reset(&mut self) {
132 *self = Self::default();
133 }
134}
135
136pub const CONTAINER_RANGE_M: f32 = 3.0;
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum InventorySection {
146 Worn,
148 Person,
150 Nearby,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
156pub enum InventoryTab {
157 #[default]
158 OnPerson,
159 Nearby,
160}
161
162impl InventoryTab {
163 pub fn label(self) -> &'static str {
164 match self {
165 Self::OnPerson => "On person",
166 Self::Nearby => "Nearby storage",
167 }
168 }
169
170 pub fn cycle(self, forward: bool) -> Self {
171 match (self, forward) {
172 (Self::OnPerson, true) | (Self::OnPerson, false) => Self::Nearby,
173 (Self::Nearby, true) | (Self::Nearby, false) => Self::OnPerson,
174 }
175 }
176}
177
178pub const LIST_PAGE_SIZE: usize = 10;
180
181pub fn list_label_matches(haystack: &str, filter: &str) -> bool {
183 if filter.is_empty() {
184 return true;
185 }
186 haystack
187 .to_ascii_lowercase()
188 .contains(&filter.to_ascii_lowercase())
189}
190
191pub fn page_list_index(index: usize, pages: i32, len: usize) -> usize {
193 if len == 0 {
194 return 0;
195 }
196 let page = LIST_PAGE_SIZE as i32;
197 let next = index as i32 + pages * page;
198 next.clamp(0, (len as i32) - 1) as usize
199}
200
201pub fn step_filtered_index(index: usize, delta: i32, len: usize, pred: impl Fn(usize) -> bool) -> usize {
203 if len == 0 {
204 return 0;
205 }
206 let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
207 if matching.is_empty() {
208 return index.min(len - 1);
209 }
210 let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
211 let next = (pos as i32 + delta).rem_euclid(matching.len() as i32) as usize;
212 matching[next]
213}
214
215pub fn page_filtered_index(
217 index: usize,
218 pages: i32,
219 len: usize,
220 pred: impl Fn(usize) -> bool,
221) -> usize {
222 if len == 0 {
223 return 0;
224 }
225 let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
226 if matching.is_empty() {
227 return index.min(len - 1);
228 }
229 let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
230 let next = page_list_index(pos, pages, matching.len());
231 matching[next]
232}
233
234pub fn inventory_category_group(category: &str) -> (&'static str, u8) {
236 match category {
237 "weapon" | "ammo" => ("Weapons", 0),
238 "armor" | "shield" | "offhand" => ("Armor", 1),
239 "consumable" => ("Consumables", 2),
240 "resource" | "harvest_node" => ("Resources", 3),
241 "container" | "lodging" => ("Containers", 4),
242 "currency" | "key" => ("Currency & keys", 5),
243 "tool" | "misc" | "furniture" | "quest" => ("Gear & misc", 6),
244 _ => ("Other", 7),
245 }
246}
247
248fn parse_bank_copper_amount(input: &str) -> Option<u64> {
250 let s = input.trim();
251 if s.is_empty() {
252 return Some(0);
253 }
254 s.parse::<u64>().ok()
255}
256
257fn parse_storage_quantity(input: &str) -> Option<Option<u32>> {
259 let s = input.trim();
260 if s.is_empty() || s == "0" {
261 return Some(None);
262 }
263 let n = s.parse::<u32>().ok()?;
264 if n == 0 {
265 return Some(None);
266 }
267 Some(Some(n))
268}
269
270fn storage_stack_label(stack: &flatland_protocol::ItemStack) -> String {
271 let name = stack
272 .display_name
273 .as_deref()
274 .unwrap_or(stack.template_id.as_str());
275 if stack.quantity > 1 {
276 format!("{name} ×{}", stack.quantity)
277 } else {
278 name.to_string()
279 }
280}
281
282pub fn body_slot_label(slot: BodySlot) -> &'static str {
285 match slot {
286 BodySlot::Head => "Head",
287 BodySlot::Chest => "Chest",
288 BodySlot::Forearms => "Forearms",
289 BodySlot::Legs => "Legs",
290 BodySlot::Feet => "Feet",
291 BodySlot::Cloak => "Cloak",
292 BodySlot::Back => "Back",
293 BodySlot::Waist => "Waist",
294 BodySlot::Earrings => "Earrings",
295 BodySlot::Necklace => "Necklace",
296 BodySlot::Eyeglasses => "Eyeglasses",
297 BodySlot::RingLeft1 => "Ring L1",
298 BodySlot::RingLeft2 => "Ring L2",
299 BodySlot::RingRight1 => "Ring R1",
300 BodySlot::RingRight2 => "Ring R2",
301 }
302}
303
304fn grant_target_matches_mode(stack: &flatland_protocol::ItemStack, mode: &str) -> bool {
305 let cat = stack.category.as_deref().unwrap_or("");
306 match mode {
307 "while_equipped" => {
308 stack.equip_slot.is_some()
309 || cat == "weapon"
310 || cat == "shield"
311 || cat == "offhand"
312 || cat == "armor"
313 }
314 _ => cat == "weapon" || cat == "ammo" || stack.props.contains_key("weapon_ability_id"),
315 }
316}
317
318fn grant_tags_match(stack: &flatland_protocol::ItemStack, grant_tags: &[&str]) -> bool {
319 if grant_tags.is_empty() {
320 return true;
321 }
322 let target_tags: Vec<&str> = stack
323 .props
324 .get("allowed_enchant_tags")
325 .map(|s| {
326 s.split(',')
327 .map(str::trim)
328 .filter(|t| !t.is_empty())
329 .collect()
330 })
331 .unwrap_or_default();
332 if target_tags.is_empty() {
333 return true;
334 }
335 grant_tags.iter().any(|t| target_tags.contains(t))
336}
337
338pub const DEFAULT_TICK_HZ: u32 = 30;
340
341pub fn format_binding_ttl(
343 binding: &flatland_protocol::ItemStatusBinding,
344 tick: u64,
345 tick_hz: u32,
346) -> String {
347 let Some(expires) = binding.expires_at_tick else {
348 return "permanent".into();
349 };
350 let hz = tick_hz.max(1) as f32;
351 let remaining = expires.saturating_sub(tick) as f32 / hz;
352 if remaining <= 0.0 {
353 return "expired".into();
354 }
355 if remaining >= 120.0 {
356 format!("{:.0}m left", remaining / 60.0)
357 } else if remaining >= 10.0 {
358 format!("{remaining:.0}s left")
359 } else {
360 format!("{remaining:.1}s left")
361 }
362}
363
364pub fn format_binding_mode(mode: flatland_protocol::ItemStatusBindingMode) -> &'static str {
365 match mode {
366 flatland_protocol::ItemStatusBindingMode::OnHit => "on hit",
367 flatland_protocol::ItemStatusBindingMode::WhileEquipped => "while equipped",
368 }
369}
370
371pub fn format_status_bindings_suffix(
373 bindings: &[flatland_protocol::ItemStatusBinding],
374 tick: u64,
375 tick_hz: u32,
376) -> String {
377 if bindings.is_empty() {
378 return String::new();
379 }
380 let parts: Vec<String> = bindings
381 .iter()
382 .map(|b| {
383 format!(
384 "{} ({}, {})",
385 b.effect_id,
386 format_binding_mode(b.mode),
387 format_binding_ttl(b, tick, tick_hz)
388 )
389 })
390 .collect();
391 format!(" · {}", parts.join("; "))
392}
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
395pub enum EquipPaperdollRow {
396 Body { slot: BodySlot, filled: bool },
397 Mainhand { filled: bool },
398 Offhand { filled: bool, locked: bool },
399}
400
401pub fn equip_paperdoll_rows(state: &GameState) -> Vec<EquipPaperdollRow> {
402 let mut rows: Vec<EquipPaperdollRow> = BodySlot::ALL
403 .iter()
404 .map(|slot| EquipPaperdollRow::Body {
405 slot: *slot,
406 filled: state.worn.contains_key(slot),
407 })
408 .collect();
409 let two_hand = state.mainhand_hand_slots >= 2;
410 rows.push(EquipPaperdollRow::Mainhand {
411 filled: state.mainhand_template_id.is_some(),
412 });
413 rows.push(EquipPaperdollRow::Offhand {
414 filled: state.offhand_template_id.is_some(),
415 locked: two_hand,
416 });
417 rows
418}
419
420fn first_inventory_for_slot(state: &GameState, slot: BodySlot) -> Option<uuid::Uuid> {
421 for stack in &state.inventory_stacks {
422 let matches = stack
423 .equip_slot
424 .map(|s| s == slot || (is_client_ring(s) && is_client_ring(slot)))
425 .unwrap_or(false)
426 || guess_body_slot(&stack.template_id) == Some(slot);
427 if matches {
428 return stack.item_instance_id;
429 }
430 }
431 None
432}
433
434fn is_client_ring(slot: BodySlot) -> bool {
435 matches!(
436 slot,
437 BodySlot::RingLeft1
438 | BodySlot::RingLeft2
439 | BodySlot::RingRight1
440 | BodySlot::RingRight2
441 )
442}
443
444fn first_inventory_weapon(state: &GameState) -> Option<String> {
445 for stack in &state.inventory_stacks {
446 if stack.category.as_deref() == Some("weapon") {
447 return Some(stack.template_id.clone());
448 }
449 }
450 None
451}
452
453fn first_inventory_offhand(state: &GameState) -> Option<String> {
454 for stack in &state.inventory_stacks {
455 let cat = stack.category.as_deref().unwrap_or("");
456 if matches!(cat, "shield" | "offhand") {
457 return Some(stack.template_id.clone());
458 }
459 }
460 None
461}
462
463fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
466 if template_id.contains("backpack") {
467 Some(BodySlot::Back)
468 } else if template_id.contains("belt") {
469 Some(BodySlot::Waist)
470 } else if template_id.contains("cloak") || template_id.contains("cape") {
471 Some(BodySlot::Cloak)
472 } else if template_id.contains("cap")
473 || template_id.contains("hat")
474 || template_id.contains("helm")
475 {
476 Some(BodySlot::Head)
477 } else if template_id.contains("shirt")
478 || template_id.contains("robe")
479 || template_id.contains("vest")
480 || template_id.contains("chest")
481 || template_id.contains("jerkin")
482 {
483 Some(BodySlot::Chest)
484 } else if template_id.contains("sleeves")
485 || template_id.contains("gloves")
486 || template_id.contains("gauntlets")
487 {
488 Some(BodySlot::Forearms)
489 } else if template_id.contains("pants") || template_id.contains("leggings") {
490 Some(BodySlot::Legs)
491 } else if template_id.contains("boots") || template_id.contains("shoes") {
492 Some(BodySlot::Feet)
493 } else if template_id.contains("earring") {
494 Some(BodySlot::Earrings)
495 } else if template_id.contains("necklace") || template_id.contains("amulet") {
496 Some(BodySlot::Necklace)
497 } else if template_id.contains("glass")
498 || template_id.contains("spectacles")
499 || template_id.contains("goggles")
500 {
501 Some(BodySlot::Eyeglasses)
502 } else if template_id.contains("ring") {
503 Some(BodySlot::RingLeft1)
504 } else {
505 None
506 }
507}
508
509#[derive(Debug, Clone)]
511pub struct InventoryRow {
512 pub depth: usize,
513 pub stack: flatland_protocol::ItemStack,
514 pub from: flatland_protocol::InventoryLocation,
516 pub from_parent_instance_id: Option<uuid::Uuid>,
518 pub is_equip_shell: bool,
520 pub is_chest_shell: bool,
522 pub section: InventorySection,
523}
524
525#[derive(Debug, Clone)]
527pub struct InventoryRowView {
528 pub depth: usize,
529 pub text: String,
531 pub title: String,
533 pub mass_kg: Option<f32>,
534 pub volume: Option<(f32, f32)>,
535}
536
537#[derive(Debug, Clone)]
539pub enum InventoryBrowserLine {
540 Section(String),
541 SlotLabel(String),
542 Hint(String),
543 Blank,
544 Item {
545 selectable_index: usize,
546 selected: bool,
547 depth: usize,
548 text: String,
549 title: String,
550 mass_kg: Option<f32>,
551 volume: Option<(f32, f32)>,
552 },
553}
554
555#[derive(Debug, Clone, PartialEq, Eq, Default)]
557pub enum BankUiMode {
558 #[default]
559 Menu,
560 DepositAmount {
561 input: String,
562 },
563 WithdrawAmount {
564 input: String,
565 },
566 TransferName {
567 input: String,
568 },
569 TransferAmount {
570 to_name: String,
571 input: String,
572 },
573}
574
575#[derive(Debug, Clone, PartialEq, Eq, Default)]
577pub enum StorageUiMode {
578 #[default]
579 Menu,
580 StorePick {
582 index: usize,
583 },
584 StoreAmount {
586 pick_index: usize,
587 item_instance_id: uuid::Uuid,
588 label: String,
589 max_qty: u32,
590 input: String,
591 },
592 TakePick {
594 index: usize,
595 },
596 TakeAmount {
598 pick_index: usize,
599 item_instance_id: uuid::Uuid,
600 label: String,
601 max_qty: u32,
602 input: String,
603 },
604 ShipPick {
606 dest_building_id: String,
607 dest_label: String,
608 index: usize,
609 },
610 ShipAmount {
612 dest_building_id: String,
613 dest_label: String,
614 pick_index: usize,
615 item_instance_id: uuid::Uuid,
616 label: String,
617 max_qty: u32,
618 input: String,
619 },
620}
621
622#[derive(Debug, Clone)]
624pub struct StoragePickOption {
625 pub item_instance_id: uuid::Uuid,
626 pub label: String,
627 pub quantity: u32,
628}
629
630#[derive(Debug, Clone)]
633pub struct NearbyContainer {
634 pub view: flatland_protocol::PlacedContainerView,
635 pub distance_m: f32,
636 pub rows: Vec<InventoryRow>,
637}
638
639#[derive(Debug, Clone)]
641pub struct KeychainEntry {
642 pub stack: flatland_protocol::ItemStack,
643 pub stowed: bool,
644}
645
646#[derive(Debug, Clone)]
648pub struct MoveOption {
649 pub label: String,
650 pub kind: MoveOptionKind,
651}
652
653#[derive(Debug, Clone, PartialEq)]
654pub enum MoveOptionKind {
655 Move {
656 location: flatland_protocol::InventoryLocation,
657 parent_instance_id: Option<uuid::Uuid>,
658 },
659 PickupPlaced {
661 container_id: String,
662 nest_location: flatland_protocol::InventoryLocation,
663 nest_parent_instance_id: Option<uuid::Uuid>,
664 },
665 Use,
667 GrantApply,
669 Drop,
670 Cancel,
671}
672
673#[derive(Debug, Clone)]
675pub struct GrantTargetPicker {
676 pub grant_instance_id: uuid::Uuid,
677 pub grant_label: String,
678 pub effect_id: String,
679 pub mode: String,
680 pub options: Vec<GrantTargetOption>,
681 pub filter: String,
682 pub filter_focused: bool,
683}
684
685#[derive(Debug, Clone)]
686pub struct GrantTargetOption {
687 pub label: String,
688 pub target_instance_id: uuid::Uuid,
689}
690
691#[derive(Debug, Clone)]
693pub struct MovePicker {
694 pub item_instance_id: uuid::Uuid,
695 pub from: flatland_protocol::InventoryLocation,
696 pub item_label: String,
697 pub template_id: String,
698 pub stack_quantity: u32,
699 pub quantity: u32,
700 pub options: Vec<MoveOption>,
701 pub filter: String,
702 pub filter_focused: bool,
703}
704
705#[derive(Debug, Clone)]
707pub struct DestroyPicker {
708 pub item_instance_id: uuid::Uuid,
709 pub from: flatland_protocol::InventoryLocation,
710 pub item_label: String,
711 pub stack_quantity: u32,
712 pub quantity: u32,
713}
714
715#[derive(Debug, Clone)]
717pub struct WorkerGiveOption {
718 pub item_instance_id: uuid::Uuid,
719 pub label: String,
720 pub quantity: u32,
721 pub template_id: String,
722}
723
724#[derive(Debug, Clone)]
726pub struct WorkerGivePicker {
727 pub worker_instance_id: String,
728 pub worker_label: String,
729 pub options: Vec<WorkerGiveOption>,
730}
731
732#[derive(Debug, Clone)]
734pub struct WorkerGiveTargetOption {
735 pub instance_id: String,
736 pub label: String,
737 pub distance_m: f32,
738}
739
740#[derive(Debug, Clone)]
742pub struct WorkerGiveTargetPicker {
743 pub item_instance_id: uuid::Uuid,
744 pub item_label: String,
745 pub quantity: Option<u32>,
746 pub options: Vec<WorkerGiveTargetOption>,
747}
748
749#[derive(Debug, Clone)]
751pub struct WorkerTakePicker {
752 pub worker_instance_id: String,
753 pub worker_label: String,
754 pub options: Vec<WorkerGiveOption>,
755}
756
757pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
759
760#[derive(Debug, Clone)]
762pub struct WorkerTeachOption {
763 pub blueprint_id: String,
764 pub label: String,
765 pub cost_copper: u64,
766 pub min_level: u32,
767 pub worker_level: u32,
768 pub can_afford: bool,
769 pub level_ok: bool,
770}
771
772#[derive(Debug, Clone)]
774pub struct WorkerTeachPicker {
775 pub worker_instance_id: String,
776 pub worker_label: String,
777 pub worker_level: u32,
778 pub options: Vec<WorkerTeachOption>,
779}
780
781#[derive(Debug, Clone, Default)]
784pub struct StickyWorkerStep {
785 shown: String,
786 pending: String,
787 pending_since: Option<Instant>,
788}
789
790impl StickyWorkerStep {
791 fn from_label(label: String) -> Self {
792 Self {
793 shown: label.clone(),
794 pending: label,
795 pending_since: Some(Instant::now()),
796 }
797 }
798
799 fn observe(&mut self, label: &str, now: Instant) {
800 let pending_since = self.pending_since.unwrap_or(now);
801 if label == self.pending {
802 if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD
803 {
804 self.shown = self.pending.clone();
805 }
806 return;
807 }
808 self.pending = label.to_string();
809 self.pending_since = Some(now);
810 if self.shown.is_empty() {
812 self.shown = self.pending.clone();
813 }
814 }
815}
816
817#[derive(Debug, Clone, Default)]
820pub struct StickyWorkerError {
821 message: String,
822 last_seen: Option<Instant>,
823}
824
825impl StickyWorkerError {
826 fn observe(&mut self, err: Option<&str>, now: Instant) {
827 if let Some(e) = err {
828 if !worker_error_is_transient(e) && !worker_error_is_hud_noise(e) {
829 self.message = e.to_string();
830 self.last_seen = Some(now);
831 }
832 return;
833 }
834 if let Some(seen) = self.last_seen {
835 if now.duration_since(seen) > WORKER_ERROR_HOLD {
836 self.message.clear();
837 self.last_seen = None;
838 }
839 }
840 }
841
842 pub fn shown(&self, now: Instant) -> Option<&str> {
843 if self.message.is_empty() {
844 return None;
845 }
846 let seen = self.last_seen?;
847 if now.duration_since(seen) > WORKER_ERROR_HOLD {
848 return None;
849 }
850 Some(self.message.as_str())
851 }
852}
853
854pub fn worker_attention_line(state: &GameState) -> Option<String> {
857 use flatland_protocol::WorkerStateView;
858 let now = Instant::now();
859 for w in &state.hired_workers {
860 if matches!(w.state, WorkerStateView::Strike) {
861 return Some(format!(
862 "Worker {}: on strike — fund bank, pay wages, or stock lodging chest",
863 w.label
864 ));
865 }
866 if let Some(err) = state
867 .worker_error_display
868 .get(&w.instance_id)
869 .and_then(|s| s.shown(now))
870 {
871 if !worker_error_is_hud_noise(err) {
872 return Some(format!("Worker {}: {err}", w.label));
873 }
874 }
875 if let Some(err) = &w.last_error {
876 if !worker_error_is_transient(err) && !worker_error_is_hud_noise(err) {
877 return Some(format!("Worker {}: {err}", w.label));
878 }
879 }
880 }
881 None
882}
883
884pub fn worker_error_is_transient(err: &str) -> bool {
886 let e = err.to_ascii_lowercase();
887 e.contains("continuing route")
888 || e.contains("storage full")
889 || e.starts_with("nothing to withdraw")
890}
891
892pub fn worker_error_is_hud_noise(err: &str) -> bool {
895 let e = err.to_ascii_lowercase();
896 e.contains("returned to lodging after path")
897 || e.contains("path failure")
898 || e.contains("no path to")
899 || e.contains("pathfinding")
900}
901
902#[derive(Debug, Clone)]
904pub struct PendingWorkerJobAck {
905 pub seq: u32,
906 pub worker_instance_id: String,
907 pub worker_label: String,
908 pub idle: bool,
909 pub stop_count: usize,
910 pub prev_route: Option<flatland_protocol::WorkerRouteView>,
911 pub prev_mode: flatland_protocol::WorkerModeView,
912 pub prev_step_label: String,
913 pub prev_last_error: Option<String>,
914}
915
916fn push_inventory_rows(
917 rows: &mut Vec<InventoryRow>,
918 depth: usize,
919 stack: &flatland_protocol::ItemStack,
920 from: &flatland_protocol::InventoryLocation,
921 from_parent_instance_id: Option<uuid::Uuid>,
922 section: InventorySection,
923) {
924 push_inventory_rows_filtered(
925 rows,
926 depth,
927 stack,
928 from,
929 from_parent_instance_id,
930 section,
931 "",
932 );
933}
934
935fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
936 if filter.is_empty() {
937 return true;
938 }
939 let f = filter.to_ascii_lowercase();
940 let name = stack
941 .display_name
942 .as_deref()
943 .unwrap_or("")
944 .to_ascii_lowercase();
945 let tid = stack.template_id.to_ascii_lowercase();
946 name.contains(&f)
947 || tid.contains(&f)
948 || stack
949 .contents
950 .iter()
951 .any(|c| stack_matches_filter(c, filter))
952}
953
954fn push_inventory_rows_filtered(
955 rows: &mut Vec<InventoryRow>,
956 depth: usize,
957 stack: &flatland_protocol::ItemStack,
958 from: &flatland_protocol::InventoryLocation,
959 from_parent_instance_id: Option<uuid::Uuid>,
960 section: InventorySection,
961 filter: &str,
962) {
963 if !filter.is_empty() && !stack_matches_filter(stack, filter) {
964 return;
965 }
966 let self_hit = filter.is_empty() || {
967 let f = filter.to_ascii_lowercase();
968 let name = stack
969 .display_name
970 .as_deref()
971 .unwrap_or("")
972 .to_ascii_lowercase();
973 let tid = stack.template_id.to_ascii_lowercase();
974 name.contains(&f) || tid.contains(&f)
975 };
976 rows.push(InventoryRow {
977 depth,
978 stack: stack.clone(),
979 from: from.clone(),
980 from_parent_instance_id,
981 is_equip_shell: false,
982 is_chest_shell: false,
983 section,
984 });
985 for child in &stack.contents {
986 if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
987 push_inventory_rows_filtered(
988 rows,
989 depth + 1,
990 child,
991 from,
992 stack.item_instance_id,
993 section,
994 if self_hit { "" } else { filter },
995 );
996 }
997 }
998}
999
1000#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1001pub enum ShopTab {
1002 #[default]
1003 Buy,
1004 Sell,
1005}
1006
1007#[derive(Debug, Clone)]
1008pub struct NpcChatState {
1009 pub npc_id: String,
1010 pub npc_label: String,
1011 pub lines: Vec<String>,
1012 pub input: String,
1013 pub pending: bool,
1014 pub talk_depth: flatland_protocol::NpcTalkDepth,
1015 pub trade_allowed: bool,
1016 pub banner: Option<String>,
1017}
1018
1019impl Default for NpcChatState {
1020 fn default() -> Self {
1021 Self {
1022 npc_id: String::new(),
1023 npc_label: String::new(),
1024 lines: Vec::new(),
1025 input: String::new(),
1026 pending: false,
1027 talk_depth: flatland_protocol::NpcTalkDepth::Full,
1028 trade_allowed: true,
1029 banner: None,
1030 }
1031 }
1032}
1033
1034#[derive(Debug, Clone)]
1035pub struct GameState {
1036 pub session_id: SessionId,
1037 pub entity_id: EntityId,
1038 pub character_id: Option<uuid::Uuid>,
1040 pub tick: Tick,
1041 pub chunk_rev: u64,
1042 pub content_rev: u64,
1043 pub publish_rev: u64,
1044 pub entities: Vec<EntityState>,
1045 pub player: Option<EntityState>,
1046 pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1047 pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1048 pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1049 pub buildings: Vec<BuildingView>,
1050 pub doors: Vec<DoorView>,
1051 pub interior_map: Option<InteriorMapView>,
1052 pub npcs: Vec<NpcView>,
1053 pub blueprints: Vec<BlueprintView>,
1054 pub world_width_m: f32,
1055 pub world_height_m: f32,
1056 pub terrain_zones: Vec<TerrainZoneView>,
1057 pub z_platforms: Vec<ZPlatformView>,
1058 pub z_transitions: Vec<ZTransitionView>,
1059 pub world_clock: flatland_protocol::WorldClock,
1060 pub inventory: std::collections::HashMap<String, u32>,
1061 pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1062 pub logs: VecDeque<String>,
1063 pub intents_sent: u64,
1064 pub ticks_received: u64,
1065 pub connected: bool,
1066 pub disconnect_reason: Option<String>,
1067 pub show_stats: bool,
1068 pub show_equip_menu: bool,
1069 pub equip_menu_index: usize,
1070 pub show_craft_menu: bool,
1071 pub craft_menu_index: usize,
1072 pub craft_batch_quantity: u32,
1074 pub show_shop_menu: bool,
1075 pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1076 pub bank_panel: Option<flatland_protocol::BankPanel>,
1077 pub bank_menu_index: usize,
1078 pub bank_ui_mode: BankUiMode,
1079 pub storage_panel: Option<flatland_protocol::StoragePanel>,
1080 pub storage_menu_index: usize,
1081 pub storage_ui_mode: StorageUiMode,
1082 pub shop_tab: ShopTab,
1083 pub shop_menu_index: usize,
1084 pub shop_quantity: u32,
1085 pub shop_trade_log: VecDeque<String>,
1087 pub show_npc_verb_menu: bool,
1088 pub npc_verb_target: Option<String>,
1089 pub npc_verb_index: usize,
1090 pub show_npc_chat: bool,
1091 pub npc_chat: Option<NpcChatState>,
1092 pub show_inventory_menu: bool,
1093 pub inventory_menu_index: usize,
1094 pub inventory_tab: InventoryTab,
1095 pub inventory_filter: String,
1096 pub inventory_filter_focused: bool,
1097 pub show_move_picker: bool,
1098 pub move_picker_index: usize,
1099 pub move_picker: Option<MovePicker>,
1100 pub show_grant_picker: bool,
1101 pub grant_picker_index: usize,
1102 pub grant_picker: Option<GrantTargetPicker>,
1103 pub show_destroy_picker: bool,
1104 pub destroy_confirm_pending: bool,
1105 pub destroy_picker: Option<DestroyPicker>,
1106 pub show_rename_prompt: bool,
1108 pub show_worker_rename: bool,
1110 pub rename_buffer: String,
1111 pub combat_target: Option<EntityId>,
1113 pub combat_target_label: Option<String>,
1114 pub combat_fx: Vec<flatland_protocol::CombatFx>,
1116 pub in_combat: bool,
1117 pub auto_attack: bool,
1118 pub combat_has_los: bool,
1119 pub attack_cd_ticks: u64,
1120 pub gcd_ticks: u64,
1121 pub weapon_ability_id: String,
1122 pub mainhand_template_id: Option<String>,
1123 pub mainhand_label: Option<String>,
1124 pub offhand_template_id: Option<String>,
1125 pub offhand_label: Option<String>,
1126 pub mainhand_hand_slots: u8,
1127 pub defense: Option<flatland_protocol::DefenseHud>,
1128 pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1130 pub carry_mass: f32,
1131 pub carry_mass_max: f32,
1132 pub encumbrance: flatland_protocol::EncumbranceState,
1133 pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1135 pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1137 pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1139 pub combat_target_detail: Option<CombatTargetHud>,
1140 pub cast_progress: Option<CastProgressHud>,
1141 pub ability_cooldowns: Vec<AbilityCooldownHud>,
1142 pub blocking_active: bool,
1143 pub max_target_slots: u8,
1144 pub combat_slots: Vec<CombatSlotHud>,
1145 pub rotation_presets: Vec<RotationPreset>,
1146 pub known_abilities: Vec<String>,
1148 pub hotbar: Vec<Option<String>>,
1150 pub max_abilities_per_rotation: u8,
1152 pub show_loadout_menu: bool,
1153 pub show_keychain_menu: bool,
1154 pub keychain_menu_index: usize,
1155 pub show_rotation_editor: bool,
1156 pub loadout_menu_index: usize,
1158 pub loadout_hotbar_slot: u8,
1160 pub loadout_ability_index: usize,
1162 pub loadout_focus_presets: bool,
1164 pub rotation_editor: RotationEditorState,
1165 pub harvest_in_progress: bool,
1167 pub harvest_started_at: Option<Instant>,
1169 pub pending_craft_ack: Option<(u32, String, u32)>,
1171 pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1172 pub interactables: Vec<flatland_protocol::InteractableView>,
1173 pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1174 pub career: Option<flatland_protocol::PlayerCareerView>,
1175 pub character_sheet_tab: CharacterSheetTab,
1176 pub ledger_period: LedgerPeriod,
1177 pub show_quest_offer: bool,
1178 pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
1179 pub show_quest_menu: bool,
1180 pub quest_menu_index: usize,
1181 pub quest_withdraw_confirm: bool,
1182 pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1183 pub show_workers_menu: bool,
1184 pub workers_menu_index: usize,
1185 pub workers_menu_compact: bool,
1187 pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1190 pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1192 pub show_worker_give_picker: bool,
1194 pub worker_give_picker_index: usize,
1195 pub worker_give_picker: Option<WorkerGivePicker>,
1196 pub show_worker_give_target_picker: bool,
1198 pub worker_give_target_picker_index: usize,
1199 pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1200 pub show_worker_take_picker: bool,
1202 pub worker_take_picker_index: usize,
1203 pub worker_take_picker: Option<WorkerTakePicker>,
1204 pub show_worker_teach_picker: bool,
1206 pub worker_teach_picker_index: usize,
1207 pub worker_teach_picker: Option<WorkerTeachPicker>,
1208 pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1210 pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1212 pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1214}
1215
1216impl GameState {
1217 pub fn push_log(&mut self, line: impl Into<String>) {
1218 self.logs.push_back(line.into());
1219 while self.logs.len() > MAX_LOG_LINES {
1220 self.logs.pop_front();
1221 }
1222 }
1223
1224 pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1225 self.shop_trade_log.push_back(line.into());
1226 while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1227 self.shop_trade_log.pop_front();
1228 }
1229 }
1230
1231 pub fn clear_shop_trade_log(&mut self) {
1232 self.shop_trade_log.clear();
1233 }
1234
1235 fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1236 if !self.show_shop_menu {
1237 return;
1238 }
1239 let msg = notice.message.trim();
1240 if msg.is_empty() {
1241 return;
1242 }
1243 if notice.coins_delta != 0
1244 || msg.starts_with("Bought ")
1245 || msg.starts_with("Sold ")
1246 || msg.contains("taught you how to craft")
1247 || msg.starts_with("need ")
1248 {
1249 self.push_shop_trade_log(msg);
1250 }
1251 }
1252
1253 pub fn is_alive(&self) -> bool {
1254 self.player
1255 .as_ref()
1256 .and_then(|p| p.vitals)
1257 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1258 .unwrap_or(true)
1259 }
1260
1261 pub fn npc_verb_options(&self) -> Vec<&'static str> {
1263 let Some(ref id) = self.npc_verb_target else {
1264 return vec![];
1265 };
1266 let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
1267 return vec!["Talk"];
1268 };
1269 let role = npc.role.as_str();
1270 if Self::npc_role_is_bank(role) {
1271 return vec!["Bank", "Talk"];
1272 }
1273 if Self::npc_role_is_storage(role) {
1274 return vec!["Storage", "Talk"];
1275 }
1276 if npc.can_trade || Self::npc_role_can_trade(role) {
1277 vec!["Talk", "Trade"]
1278 } else {
1279 vec!["Talk"]
1280 }
1281 }
1282
1283 fn npc_role_can_trade(role: &str) -> bool {
1284 matches!(role, "broker" | "cook" | "farmer" | "merchant")
1285 }
1286
1287 fn npc_role_is_bank(role: &str) -> bool {
1288 role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
1289 }
1290
1291 fn npc_role_is_storage(role: &str) -> bool {
1292 role.eq_ignore_ascii_case("storage_manager")
1293 }
1294
1295 pub fn bank_menu_options(&self) -> Vec<&'static str> {
1296 vec![
1297 "Deposit…",
1298 "Withdraw…",
1299 "Deposit all",
1300 "Withdraw all",
1301 "Transfer…",
1302 ]
1303 }
1304
1305 pub fn storage_menu_options(&self) -> Vec<String> {
1306 let mut opts = vec!["Store…".into(), "Take…".into()];
1307 if let Some(panel) = &self.storage_panel {
1308 for dest in &panel.ship_destinations {
1309 opts.push(format!(
1310 "Ship → {} ({} cp / {} ticks)",
1311 dest.label, dest.fee_copper, dest.travel_ticks
1312 ));
1313 }
1314 }
1315 opts
1316 }
1317
1318 pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
1320 self.person_rows()
1321 .into_iter()
1322 .filter(|r| r.depth == 0)
1323 .filter_map(|r| {
1324 let id = r.stack.item_instance_id?;
1325 Some(StoragePickOption {
1326 item_instance_id: id,
1327 label: storage_stack_label(&r.stack),
1328 quantity: r.stack.quantity,
1329 })
1330 })
1331 .collect()
1332 }
1333
1334 pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
1336 let Some(panel) = &self.storage_panel else {
1337 return Vec::new();
1338 };
1339 panel
1340 .contents
1341 .iter()
1342 .filter_map(|s| {
1343 let id = s.item_instance_id?;
1344 Some(StoragePickOption {
1345 item_instance_id: id,
1346 label: storage_stack_label(s),
1347 quantity: s.quantity,
1348 })
1349 })
1350 .collect()
1351 }
1352
1353 pub fn clear_harvest_state(&mut self) {
1354 self.harvest_in_progress = false;
1355 self.harvest_started_at = None;
1356 }
1357
1358 fn harvest_state_stale(&self) -> bool {
1359 match self.harvest_started_at {
1360 Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
1361 None => self.harvest_in_progress,
1362 }
1363 }
1364
1365 pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
1366 self.player.as_ref().and_then(|p| p.vitals)
1367 }
1368
1369 pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
1370 let materials_ok = blueprint.inputs.iter().all(|input| {
1371 self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
1372 });
1373 let tools_ok = blueprint
1374 .required_tools
1375 .iter()
1376 .all(|tool| self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1);
1377 let station_ok = match blueprint.station.as_deref() {
1378 None | Some("hand") => true,
1379 Some(tag) => self.player_at_station_tag(tag),
1380 };
1381 materials_ok && tools_ok && station_ok
1382 }
1383
1384 pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
1385 if !self.can_craft_blueprint(blueprint) {
1386 return 0;
1387 }
1388 let mut limit = u32::MAX;
1389 for input in &blueprint.inputs {
1390 if input.quantity == 0 {
1391 continue;
1392 }
1393 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
1394 limit = limit.min(have / input.quantity);
1395 }
1396 for tool in &blueprint.required_tools {
1397 if tool.consumed {
1398 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
1399 limit = limit.min(have);
1400 }
1401 }
1402 let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
1403 if CRAFT_STAMINA_COST > 0.0 {
1404 limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
1405 }
1406 limit
1407 }
1408
1409 pub fn clamp_craft_batch_quantity(&mut self) {
1410 let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
1411 self.craft_batch_quantity = 1;
1412 return;
1413 };
1414 let max = self.max_craft_batches(bp).max(1);
1415 self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
1416 }
1417
1418 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
1419 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1420 return;
1421 };
1422 let max = self.max_craft_batches(&bp).max(1);
1423 let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
1424 self.craft_batch_quantity = next as u32;
1425 }
1426
1427 pub fn craft_batch_set_max(&mut self) {
1428 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1429 return;
1430 };
1431 let max = self.max_craft_batches(&bp);
1432 self.craft_batch_quantity = if max == 0 { 1 } else { max };
1433 }
1434
1435 pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
1436 let preserve_ui = self.show_shop_menu;
1437 let tab = self.shop_tab;
1438 let index = self.shop_menu_index;
1439 let qty = self.shop_quantity;
1440
1441 self.show_shop_menu = true;
1442 self.bank_panel = None;
1443 self.show_craft_menu = false;
1444 self.show_inventory_menu = false;
1445 self.show_stats = false;
1446 if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
1447 self.npc_verb_target = Some(catalog.npc_id.clone());
1448 }
1449 self.shop_catalog = Some(catalog);
1450
1451 if preserve_ui {
1452 self.shop_tab = tab;
1453 self.shop_menu_index = index;
1454 self.shop_quantity = qty;
1455 } else {
1456 self.shop_tab = ShopTab::Buy;
1457 self.shop_menu_index = 0;
1458 self.shop_quantity = 1;
1459 self.clear_shop_trade_log();
1460 }
1461 self.show_npc_verb_menu = false;
1462 self.clamp_shop_selection();
1463 }
1464
1465 pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
1466 let same_teller = self
1467 .bank_panel
1468 .as_ref()
1469 .is_some_and(|p| p.npc_id == panel.npc_id);
1470 self.bank_panel = Some(panel);
1471 self.storage_panel = None;
1472 self.shop_catalog = None;
1473 self.show_shop_menu = false;
1474 self.show_craft_menu = false;
1475 self.show_inventory_menu = false;
1476 self.show_stats = false;
1477 self.show_npc_verb_menu = false;
1478 self.show_npc_chat = false;
1479 self.npc_chat = None;
1480 if !same_teller {
1481 self.bank_menu_index = 0;
1482 self.bank_ui_mode = BankUiMode::Menu;
1483 }
1484 if let Some(panel) = &self.bank_panel {
1485 if self.npc_verb_target.is_none() {
1486 self.npc_verb_target = Some(panel.npc_id.clone());
1487 }
1488 }
1489 }
1490
1491 pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
1492 let same_manager = self
1493 .storage_panel
1494 .as_ref()
1495 .is_some_and(|p| p.npc_id == panel.npc_id);
1496 self.storage_panel = Some(panel);
1497 self.bank_panel = None;
1498 self.bank_ui_mode = BankUiMode::Menu;
1499 self.shop_catalog = None;
1500 self.show_shop_menu = false;
1501 self.show_craft_menu = false;
1502 self.show_inventory_menu = false;
1503 self.show_stats = false;
1504 self.show_npc_verb_menu = false;
1505 self.show_npc_chat = false;
1506 self.npc_chat = None;
1507 if !same_manager {
1508 self.storage_menu_index = 0;
1509 self.storage_ui_mode = StorageUiMode::Menu;
1510 } else {
1511 self.clamp_storage_pick_index();
1512 }
1513 if let Some(panel) = &self.storage_panel {
1514 if self.npc_verb_target.is_none() {
1515 self.npc_verb_target = Some(panel.npc_id.clone());
1516 }
1517 }
1518 }
1519
1520 pub fn clear_bank_panel(&mut self) {
1521 self.bank_panel = None;
1522 self.bank_menu_index = 0;
1523 self.bank_ui_mode = BankUiMode::Menu;
1524 }
1525
1526 pub fn clear_storage_panel(&mut self) {
1527 self.storage_panel = None;
1528 self.storage_menu_index = 0;
1529 self.storage_ui_mode = StorageUiMode::Menu;
1530 }
1531
1532 fn clamp_storage_pick_index(&mut self) {
1533 match &self.storage_ui_mode {
1534 StorageUiMode::StorePick { index } => {
1535 let n = self.storage_store_options().len();
1536 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1537 self.storage_ui_mode = StorageUiMode::StorePick { index: next };
1538 }
1539 StorageUiMode::TakePick { index } => {
1540 let n = self.storage_vault_options().len();
1541 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1542 self.storage_ui_mode = StorageUiMode::TakePick { index: next };
1543 }
1544 StorageUiMode::ShipPick {
1545 dest_building_id,
1546 dest_label,
1547 index,
1548 } => {
1549 let n = self.storage_vault_options().len();
1550 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
1551 self.storage_ui_mode = StorageUiMode::ShipPick {
1552 dest_building_id: dest_building_id.clone(),
1553 dest_label: dest_label.clone(),
1554 index: next,
1555 };
1556 }
1557 StorageUiMode::Menu
1558 | StorageUiMode::StoreAmount { .. }
1559 | StorageUiMode::TakeAmount { .. }
1560 | StorageUiMode::ShipAmount { .. } => {}
1561 }
1562 }
1563
1564 pub fn shop_list_len(&self) -> usize {
1565 let Some(catalog) = &self.shop_catalog else {
1566 return 0;
1567 };
1568 match self.shop_tab {
1569 ShopTab::Buy => catalog.sells.len(),
1570 ShopTab::Sell => catalog.buys.len(),
1571 }
1572 }
1573
1574 pub fn shop_menu_move(&mut self, delta: i32) {
1575 let n = self.shop_list_len();
1576 if n == 0 {
1577 return;
1578 }
1579 let idx = self.shop_menu_index as i32;
1580 let next = (idx + delta).rem_euclid(n as i32);
1581 self.shop_menu_index = next as usize;
1582 self.clamp_shop_quantity();
1583 }
1584
1585 pub fn shop_quantity_adjust(&mut self, delta: i32) {
1586 let max = self.shop_quantity_max();
1587 if max == 0 {
1588 self.shop_quantity = 0;
1589 return;
1590 }
1591 let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
1592 self.shop_quantity = next as u32;
1593 }
1594
1595 pub(crate) fn clamp_shop_selection(&mut self) {
1596 let n = self.shop_list_len();
1597 if n == 0 {
1598 self.shop_menu_index = 0;
1599 } else {
1600 self.shop_menu_index = self.shop_menu_index.min(n - 1);
1601 }
1602 self.clamp_shop_quantity();
1603 }
1604
1605 fn shop_quantity_max(&self) -> u32 {
1606 let Some(catalog) = &self.shop_catalog else {
1607 return 1;
1608 };
1609 match self.shop_tab {
1610 ShopTab::Buy => {
1611 if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
1612 if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
1613 return 1;
1614 }
1615 }
1616 99
1617 }
1618 ShopTab::Sell => catalog
1619 .buys
1620 .get(self.shop_menu_index)
1621 .map(|l| l.quantity)
1622 .unwrap_or(0),
1623 }
1624 }
1625
1626 pub fn shop_quantity_set_max(&mut self) {
1627 self.shop_quantity = self.shop_quantity_max();
1628 }
1629
1630 fn clamp_shop_quantity(&mut self) {
1631 let max = self.shop_quantity_max();
1632 if max == 0 {
1633 self.shop_quantity = 0;
1634 } else {
1635 self.shop_quantity = self.shop_quantity.max(1).min(max);
1636 }
1637 }
1638
1639 pub fn player_at_station_tag(&self, tag: &str) -> bool {
1640 let Some(id) = self.effective_inside_building() else {
1641 return false;
1642 };
1643 self.buildings
1644 .iter()
1645 .find(|b| b.id == id)
1646 .is_some_and(|b| b.tags.iter().any(|t| t == tag))
1647 }
1648
1649 pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
1651 if self.can_craft_blueprint(blueprint) {
1652 return None;
1653 }
1654 let mut missing = Vec::new();
1655 for input in &blueprint.inputs {
1656 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
1657 if have < input.quantity {
1658 missing.push(format!(
1659 "{}×{} (have {have})",
1660 input.quantity, input.template_id
1661 ));
1662 }
1663 }
1664 for tool in &blueprint.required_tools {
1665 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
1666 if have < 1 {
1667 missing.push(format!("tool: {}", tool.item));
1668 }
1669 }
1670 if let Some(station) = blueprint.station.as_deref() {
1671 if station != "hand" && !self.player_at_station_tag(station) {
1672 missing.push(format!("station: {station} (enter building)"));
1673 }
1674 }
1675 if missing.is_empty() {
1676 None
1677 } else {
1678 Some(missing.join(", "))
1679 }
1680 }
1681
1682 pub fn player_entity(&self) -> Option<&EntityState> {
1683 self.player
1684 .as_ref()
1685 .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
1686 }
1687
1688 pub fn player_position(&self) -> (f32, f32) {
1689 let (x, y, _) = self.player_position_with_z();
1690 (x, y)
1691 }
1692
1693 pub fn player_position_with_z(&self) -> (f32, f32, f32) {
1694 if let Some(p) = self.player_entity() {
1695 (
1696 p.transform.position.x,
1697 p.transform.position.y,
1698 p.transform.position.z,
1699 )
1700 } else {
1701 (0.0, 0.0, 0.0)
1702 }
1703 }
1704
1705 pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
1706 let mut rows: Vec<(String, u32, String)> = self
1707 .inventory
1708 .iter()
1709 .filter(|(_, q)| **q > 0)
1710 .map(|(id, qty)| {
1711 let label = self
1712 .inventory_hints
1713 .get(id)
1714 .map(|h| h.display_name.clone())
1715 .unwrap_or_else(|| id.clone());
1716 (id.clone(), *qty, label)
1717 })
1718 .collect();
1719 rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
1720 rows
1721 }
1722
1723 pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
1724 self.inventory_hints
1725 .get(template_id)
1726 .map(|h| h.category.as_str())
1727 .filter(|c| !c.is_empty())
1728 }
1729
1730 pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
1731 stack
1732 .props
1733 .get("grants_item_status_effect")
1734 .map(|s| !s.is_empty())
1735 .unwrap_or(false)
1736 }
1737
1738 pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
1739 stack
1740 .props
1741 .get("grants_item_status_effect")
1742 .map(String::as_str)
1743 .filter(|s| !s.is_empty())
1744 }
1745
1746 pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
1747 stack
1748 .props
1749 .get("grants_item_status_mode")
1750 .map(String::as_str)
1751 .unwrap_or("on_hit")
1752 }
1753
1754 pub fn grant_target_options(
1756 &self,
1757 grant: &flatland_protocol::ItemStack,
1758 ) -> Vec<GrantTargetOption> {
1759 let mode = Self::grant_mode(grant);
1760 let grant_tags: Vec<&str> = grant
1761 .props
1762 .get("grants_item_status_tags")
1763 .map(|s| {
1764 s.split(',')
1765 .map(str::trim)
1766 .filter(|t| !t.is_empty())
1767 .collect()
1768 })
1769 .unwrap_or_default();
1770 let grant_id = grant.item_instance_id;
1771 let mut out = Vec::new();
1772 let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
1773 let Some(iid) = stack.item_instance_id else {
1774 return;
1775 };
1776 if Some(iid) == grant_id {
1777 return;
1778 }
1779 if stack.props.get("enchantable").map(String::as_str) == Some("0") {
1780 return;
1781 }
1782 if !grant_target_matches_mode(stack, mode) {
1783 return;
1784 }
1785 if !grant_tags_match(stack, &grant_tags) {
1786 return;
1787 }
1788 let name = stack
1789 .display_name
1790 .clone()
1791 .unwrap_or_else(|| stack.template_id.clone());
1792 let bindings = if stack.status_bindings.is_empty() {
1793 String::new()
1794 } else {
1795 format!(
1796 " · {}",
1797 stack
1798 .status_bindings
1799 .iter()
1800 .map(|b| b.effect_id.as_str())
1801 .collect::<Vec<_>>()
1802 .join(", ")
1803 )
1804 };
1805 out.push(GrantTargetOption {
1806 label: format!("{where_label}: {name}{bindings}"),
1807 target_instance_id: iid,
1808 });
1809 };
1810 fn walk(
1811 stacks: &[flatland_protocol::ItemStack],
1812 where_label: &str,
1813 push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
1814 ) {
1815 for s in stacks {
1816 push(s, where_label);
1817 if !s.contents.is_empty() {
1818 let nested = format!(
1819 "{where_label}/{}",
1820 s.display_name
1821 .as_deref()
1822 .unwrap_or(s.template_id.as_str())
1823 );
1824 walk(&s.contents, &nested, push);
1825 }
1826 }
1827 }
1828 walk(&self.inventory_stacks, "Bag", &mut push);
1829 for (slot, stack) in &self.worn {
1830 push(stack, body_slot_label(*slot));
1831 let nest = format!(
1832 "{}/{}",
1833 body_slot_label(*slot),
1834 stack
1835 .display_name
1836 .as_deref()
1837 .unwrap_or(stack.template_id.as_str())
1838 );
1839 walk(&stack.contents, &nest, &mut push);
1840 }
1841 out
1842 }
1843
1844 pub fn item_base_mass(&self, template_id: &str) -> f32 {
1845 self.inventory_hints
1846 .get(template_id)
1847 .and_then(|h| h.base_mass)
1848 .unwrap_or(0.5)
1849 }
1850
1851 pub fn item_base_volume(&self, template_id: &str) -> f32 {
1852 self.inventory_hints
1853 .get(template_id)
1854 .and_then(|h| h.base_volume)
1855 .unwrap_or(1.0)
1856 }
1857
1858 pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
1859 let unit = stack
1860 .base_mass
1861 .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
1862 unit * stack.quantity as f32
1863 }
1864
1865 fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
1866 let unit = stack.base_volume.unwrap_or(1.0);
1867 unit * stack.quantity as f32
1868 + stack
1869 .contents
1870 .iter()
1871 .map(Self::stack_tree_volume)
1872 .sum::<f32>()
1873 }
1874
1875 fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
1876 contents.iter().map(Self::stack_tree_volume).sum()
1877 }
1878
1879 fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
1880 self.inventory_hints
1881 .get(template_id)
1882 .and_then(|h| h.capacity_volume)
1883 .filter(|c| *c > 0.0)
1884 }
1885
1886 fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
1887 stack
1888 .capacity_volume
1889 .filter(|c| *c > 0.0)
1890 .or_else(|| self.template_capacity_volume(&stack.template_id))
1891 }
1892
1893 pub fn container_volume_label(&self, row: &InventoryRow) -> String {
1895 let Some((used, cap)) = self.container_volume_stats(row) else {
1896 return String::new();
1897 };
1898 let free = (cap - used).max(0.0);
1899 format!(" vol {used:.0}/{cap:.0} ({free:.0} free)")
1900 }
1901
1902 fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
1903 if row.is_chest_shell {
1904 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
1905 return None;
1906 };
1907 let chest = self
1908 .placed_containers
1909 .iter()
1910 .find(|c| c.id == *container_id)?;
1911 let cap = self
1912 .stack_capacity_volume(&row.stack)
1913 .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
1914 let used = if chest.accessible {
1915 Self::contents_used_volume(&chest.contents)
1916 } else {
1917 0.0
1918 };
1919 return Some((used, cap));
1920 }
1921
1922 let cap = self.stack_capacity_volume(&row.stack)?;
1923 let used = Self::contents_used_volume(&row.stack.contents);
1924 Some((used, cap))
1925 }
1926
1927 pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
1928 if row.is_chest_shell {
1929 return true;
1930 }
1931 if row.is_equip_shell {
1932 return self.inventory_item_category(&row.stack.template_id) == Some("container");
1933 }
1934 self.inventory_item_category(&row.stack.template_id) == Some("container")
1935 || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
1936 }
1937
1938 fn container_stack_for(
1939 &self,
1940 location: &flatland_protocol::InventoryLocation,
1941 parent_instance_id: Option<uuid::Uuid>,
1942 ) -> Option<flatland_protocol::ItemStack> {
1943 match location {
1944 flatland_protocol::InventoryLocation::Root => {
1945 let pid = parent_instance_id?;
1946 self.find_stack_by_instance(&self.inventory_stacks, pid)
1947 }
1948 flatland_protocol::InventoryLocation::Worn { slot } => {
1949 let worn = self.worn.get(slot)?;
1950 if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
1951 Some(worn.clone())
1952 } else {
1953 self.find_stack_by_instance(&worn.contents, parent_instance_id?)
1954 }
1955 }
1956 flatland_protocol::InventoryLocation::Placed { container_id } => {
1957 let chest = self
1958 .placed_containers
1959 .iter()
1960 .find(|c| c.id == *container_id)?;
1961 if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
1962 Some(flatland_protocol::ItemStack {
1963 template_id: chest.template_id.clone(),
1964 quantity: 1,
1965 item_instance_id: chest.item_instance_id,
1966 props: Default::default(),
1967 status_bindings: Vec::new(),
1968 contents: chest.contents.clone(),
1969 display_name: Some(chest.display_name.clone()),
1970 category: Some("container".into()),
1971 capacity_volume: self
1972 .inventory_hints
1973 .get(&chest.template_id)
1974 .and_then(|h| h.capacity_volume),
1975 worker_lodging_capacity: chest.worker_lodging_capacity,
1976 ..Default::default()
1977 })
1978 } else {
1979 self.find_stack_by_instance(&chest.contents, parent_instance_id?)
1980 }
1981 }
1982 flatland_protocol::InventoryLocation::Keychain => None,
1983 }
1984 }
1985
1986 fn find_stack_by_instance(
1987 &self,
1988 stacks: &[flatland_protocol::ItemStack],
1989 instance_id: uuid::Uuid,
1990 ) -> Option<flatland_protocol::ItemStack> {
1991 for stack in stacks {
1992 if stack.item_instance_id == Some(instance_id) {
1993 return Some(stack.clone());
1994 }
1995 if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
1996 return Some(found);
1997 }
1998 }
1999 None
2000 }
2001
2002 pub fn max_movable_to(
2004 &self,
2005 template_id: &str,
2006 stack_qty: u32,
2007 from: &flatland_protocol::InventoryLocation,
2008 to: &flatland_protocol::InventoryLocation,
2009 parent_instance_id: Option<uuid::Uuid>,
2010 ) -> u32 {
2011 let unit_vol = self.item_base_volume(template_id);
2012 let unit_mass = self.item_base_mass(template_id);
2013 let mut limit = stack_qty;
2014
2015 if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
2016 let cap = parent
2017 .capacity_volume
2018 .or_else(|| {
2019 self.inventory_hints
2020 .get(&parent.template_id)
2021 .and_then(|h| h.capacity_volume)
2022 })
2023 .unwrap_or(0.0);
2024 if cap > 0.0 && unit_vol > 0.0 {
2025 let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
2026 limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
2027 }
2028 }
2029
2030 let to_person = matches!(
2031 to,
2032 flatland_protocol::InventoryLocation::Root
2033 | flatland_protocol::InventoryLocation::Worn { .. }
2034 );
2035 let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
2036 if to_person && from_placed && unit_mass > 0.0 {
2037 let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
2038 if self.encumbrance == flatland_protocol::EncumbranceState::Over {
2039 limit = 0;
2040 } else {
2041 limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
2042 }
2043 }
2044
2045 limit.max(0).min(stack_qty)
2046 }
2047
2048 pub fn move_picker_max_at_selection(&self) -> u32 {
2049 let Some(picker) = &self.move_picker else {
2050 return 1;
2051 };
2052 let Some(opt) = picker.options.get(self.move_picker_index) else {
2053 return picker.stack_quantity;
2054 };
2055 match &opt.kind {
2056 MoveOptionKind::Cancel
2057 | MoveOptionKind::Drop
2058 | MoveOptionKind::Use
2059 | MoveOptionKind::GrantApply
2060 | MoveOptionKind::PickupPlaced { .. } => picker.stack_quantity,
2061 MoveOptionKind::Move {
2062 location,
2063 parent_instance_id,
2064 } => self.max_movable_to(
2065 &picker.template_id,
2066 picker.stack_quantity,
2067 &picker.from,
2068 location,
2069 *parent_instance_id,
2070 ),
2071 }
2072 }
2073
2074 pub fn clamp_move_picker_quantity(&mut self) {
2075 let max = self.move_picker_max_at_selection();
2076 if let Some(picker) = &mut self.move_picker {
2077 if max == 0 {
2078 picker.quantity = 1;
2079 } else {
2080 picker.quantity = picker.quantity.clamp(1, max);
2081 }
2082 }
2083 }
2084
2085 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
2086 let max = self.move_picker_max_at_selection().max(1);
2087 if let Some(picker) = &mut self.move_picker {
2088 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2089 picker.quantity = next as u32;
2090 }
2091 }
2092
2093 pub fn move_picker_set_quantity_max(&mut self) {
2094 let max = self.move_picker_max_at_selection();
2095 if let Some(picker) = &mut self.move_picker {
2096 picker.quantity = if max == 0 {
2097 1
2098 } else {
2099 max.min(picker.stack_quantity)
2100 };
2101 }
2102 }
2103
2104 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
2105 if let Some(picker) = &mut self.destroy_picker {
2106 let max = picker.stack_quantity.max(1);
2107 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2108 picker.quantity = next as u32;
2109 }
2110 }
2111
2112 pub fn destroy_picker_set_quantity_max(&mut self) {
2113 if let Some(picker) = &mut self.destroy_picker {
2114 picker.quantity = picker.stack_quantity.max(1);
2115 }
2116 }
2117
2118 pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2119 let have = self.inventory.get(template_id).copied().unwrap_or(0);
2120 (have, have >= need)
2121 }
2122
2123 pub fn currency_display(&self) -> String {
2124 crate::currency::currency_line(&self.inventory)
2125 }
2126
2127 pub fn in_shallow_water(&self) -> bool {
2129 let (px, py) = self.player_position();
2130 self.terrain_at(px, py)
2131 .is_some_and(|k| k == TerrainKindView::ShallowWater)
2132 }
2133
2134 pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
2135 self.terrain_zone_at(x, y).map(|z| z.kind)
2136 }
2137
2138 pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
2140 self.terrain_zones
2141 .iter()
2142 .enumerate()
2143 .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
2144 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
2145 .map(|(_, z)| z)
2146 }
2147
2148 pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
2150 self.terrain_zone_at(x, y)
2151 .map(|z| z.elevation)
2152 .unwrap_or(0.0)
2153 }
2154
2155 pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
2157 const TOL: f32 = 0.35;
2158 let mut levels = vec![self.elevation_at(x, y)];
2159 for p in &self.z_platforms {
2160 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2161 levels.push(p.z);
2162 }
2163 }
2164 levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2165 levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
2166 levels
2167 }
2168
2169 pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
2170 const TOL: f32 = 0.35;
2171 self.walkable_levels_at(x, y)
2172 .iter()
2173 .any(|&l| (l - z).abs() <= TOL)
2174 }
2175
2176 pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
2177 let mut top = self.elevation_at(x, y);
2178 for p in &self.z_platforms {
2179 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2180 top = top.max(p.z);
2181 }
2182 }
2183 top
2184 }
2185
2186 pub fn effective_inside_building(&self) -> Option<String> {
2188 self.player_entity().and_then(|p| p.inside_building.clone())
2189 }
2190
2191 pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
2192 self.inventory_stacks = stacks.to_vec();
2193 self.inventory.clear();
2194 self.inventory_hints.clear();
2195 fn walk(
2196 stacks: &[flatland_protocol::ItemStack],
2197 inventory: &mut std::collections::HashMap<String, u32>,
2198 hints: &mut std::collections::HashMap<String, InventoryHint>,
2199 ) {
2200 for stack in stacks {
2201 *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
2202 if stack.display_name.is_some()
2203 || stack.category.is_some()
2204 || stack.base_mass.is_some()
2205 || stack.base_volume.is_some()
2206 {
2207 hints.insert(
2208 stack.template_id.clone(),
2209 InventoryHint {
2210 display_name: stack
2211 .display_name
2212 .clone()
2213 .unwrap_or_else(|| stack.template_id.clone()),
2214 category: stack.category.clone().unwrap_or_default(),
2215 base_mass: stack.base_mass,
2216 base_volume: stack.base_volume,
2217 capacity_volume: stack.capacity_volume,
2218 stackable: stack.stackable.unwrap_or(true),
2219 },
2220 );
2221 }
2222 walk(&stack.contents, inventory, hints);
2223 }
2224 }
2225 walk(stacks, &mut self.inventory, &mut self.inventory_hints);
2226 for item in self.worn.values() {
2228 walk(
2229 std::slice::from_ref(item),
2230 &mut self.inventory,
2231 &mut self.inventory_hints,
2232 );
2233 }
2234 }
2235
2236 pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
2239 let subtract_items =
2240 notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
2241 for stack in ¬ice.inventory_delta {
2242 if stack.quantity == 0 {
2243 continue;
2244 }
2245 if subtract_items {
2246 crate::currency::drain_template_stacks(
2247 &mut self.inventory_stacks,
2248 &stack.template_id,
2249 stack.quantity,
2250 );
2251 continue;
2252 }
2253 let stackable = self
2254 .inventory_hints
2255 .get(&stack.template_id)
2256 .map(|h| h.stackable)
2257 .or(stack.stackable)
2258 .unwrap_or(true);
2259 if stackable {
2260 if let Some(existing) = self
2261 .inventory_stacks
2262 .iter_mut()
2263 .find(|s| s.template_id == stack.template_id)
2264 {
2265 existing.quantity = existing.quantity.saturating_add(stack.quantity);
2266 if stack.display_name.is_some() {
2267 existing.display_name = stack.display_name.clone();
2268 }
2269 if stack.category.is_some() {
2270 existing.category = stack.category.clone();
2271 }
2272 continue;
2273 }
2274 }
2275 self.inventory_stacks.push(stack.clone());
2276 }
2277 if notice.coins_delta != 0 {
2278 crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
2279 }
2280 if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
2281 let stacks = self.inventory_stacks.clone();
2282 self.sync_inventory_from_stacks(&stacks);
2283 }
2284 self.record_shop_trade_notice(notice);
2285 }
2286
2287 pub fn worn_rows(&self) -> Vec<InventoryRow> {
2292 let mut rows = Vec::new();
2293 for (slot, item) in &self.worn {
2294 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
2295 rows.push(InventoryRow {
2296 depth: 0,
2297 stack: item.clone(),
2298 from: from.clone(),
2299 from_parent_instance_id: None,
2300 is_equip_shell: true,
2301 is_chest_shell: false,
2302 section: InventorySection::Worn,
2303 });
2304 for child in &item.contents {
2305 push_inventory_rows(
2306 &mut rows,
2307 1,
2308 child,
2309 &from,
2310 item.item_instance_id,
2311 InventorySection::Worn,
2312 );
2313 }
2314 }
2315 rows
2316 }
2317
2318 pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
2320 self.inventory_stacks
2321 .iter()
2322 .filter_map(|stack| {
2323 let item_instance_id = stack.item_instance_id?;
2324 let label = stack
2325 .display_name
2326 .clone()
2327 .unwrap_or_else(|| stack.template_id.clone());
2328 let label = if stack.quantity > 1 {
2329 format!("{label} ×{}", stack.quantity)
2330 } else {
2331 label
2332 };
2333 Some(WorkerGiveOption {
2334 item_instance_id,
2335 label,
2336 quantity: stack.quantity,
2337 template_id: stack.template_id.clone(),
2338 })
2339 })
2340 .collect()
2341 }
2342
2343 pub fn teachable_blueprint_options(
2345 &self,
2346 worker: &flatland_protocol::HiredWorkerView,
2347 ) -> Vec<WorkerTeachOption> {
2348 let copper = crate::currency::copper_from_counts(&self.inventory);
2349 let mut options: Vec<WorkerTeachOption> = self
2350 .blueprints
2351 .iter()
2352 .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
2353 .map(|bp| {
2354 let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
2355 let cost = bp.worker_train_copper;
2356 WorkerTeachOption {
2357 blueprint_id: bp.id.clone(),
2358 label: if bp.label.is_empty() {
2359 bp.id.clone()
2360 } else {
2361 bp.label.clone()
2362 },
2363 cost_copper: cost,
2364 min_level,
2365 worker_level: worker.level,
2366 can_afford: copper >= cost,
2367 level_ok: worker.level >= min_level,
2368 }
2369 })
2370 .collect();
2371 options.sort_by(|a, b| a.label.cmp(&b.label));
2372 options
2373 }
2374
2375 pub fn person_rows(&self) -> Vec<InventoryRow> {
2378 self.person_rows_filtered("")
2379 }
2380
2381 pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
2382 let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
2383 roots.sort_by(|a, b| {
2384 let ca = a
2385 .category
2386 .as_deref()
2387 .or_else(|| self.inventory_item_category(&a.template_id))
2388 .unwrap_or("");
2389 let cb = b
2390 .category
2391 .as_deref()
2392 .or_else(|| self.inventory_item_category(&b.template_id))
2393 .unwrap_or("");
2394 let ga = inventory_category_group(ca).1;
2395 let gb = inventory_category_group(cb).1;
2396 ga.cmp(&gb).then_with(|| {
2397 let na = a
2398 .display_name
2399 .as_deref()
2400 .unwrap_or(a.template_id.as_str());
2401 let nb = b
2402 .display_name
2403 .as_deref()
2404 .unwrap_or(b.template_id.as_str());
2405 na.cmp(nb)
2406 })
2407 });
2408 let mut rows = Vec::new();
2409 for stack in roots {
2410 push_inventory_rows_filtered(
2411 &mut rows,
2412 0,
2413 stack,
2414 &flatland_protocol::InventoryLocation::Root,
2415 None,
2416 InventorySection::Person,
2417 filter,
2418 );
2419 }
2420 rows
2421 }
2422
2423 pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
2424 if filter.is_empty() {
2425 return self.worn_rows();
2426 }
2427 let mut rows = Vec::new();
2428 for (slot, item) in &self.worn {
2429 if !stack_matches_filter(item, filter) {
2430 continue;
2431 }
2432 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
2433 let self_hit = {
2434 let f = filter.to_ascii_lowercase();
2435 let name = item
2436 .display_name
2437 .as_deref()
2438 .unwrap_or("")
2439 .to_ascii_lowercase();
2440 let tid = item.template_id.to_ascii_lowercase();
2441 name.contains(&f) || tid.contains(&f)
2442 };
2443 rows.push(InventoryRow {
2444 depth: 0,
2445 stack: item.clone(),
2446 from: from.clone(),
2447 from_parent_instance_id: None,
2448 is_equip_shell: true,
2449 is_chest_shell: false,
2450 section: InventorySection::Worn,
2451 });
2452 for child in &item.contents {
2453 if self_hit || stack_matches_filter(child, filter) {
2454 push_inventory_rows_filtered(
2455 &mut rows,
2456 1,
2457 child,
2458 &from,
2459 item.item_instance_id,
2460 InventorySection::Worn,
2461 if self_hit { "" } else { filter },
2462 );
2463 }
2464 }
2465 }
2466 rows
2467 }
2468
2469 pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
2471 let mut rows = self.worn_rows();
2472 rows.extend(self.person_rows());
2473 rows.into_iter().map(|r| (r.depth, r.stack)).collect()
2474 }
2475
2476 pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
2480 let (px, py) = self.player_position();
2481 let mut list: Vec<NearbyContainer> = self
2482 .placed_containers
2483 .iter()
2484 .filter_map(|c| {
2485 let distance_m = (c.x - px).hypot(c.y - py);
2486 if distance_m > CONTAINER_RANGE_M {
2487 return None;
2488 }
2489 let mut rows = Vec::new();
2490 let from = flatland_protocol::InventoryLocation::Placed {
2491 container_id: c.id.clone(),
2492 };
2493 rows.push(InventoryRow {
2494 depth: 0,
2495 stack: flatland_protocol::ItemStack {
2496 template_id: c.template_id.clone(),
2497 quantity: 1,
2498 item_instance_id: c.item_instance_id,
2499 props: Default::default(),
2500 status_bindings: Vec::new(),
2501 contents: Vec::new(),
2502 display_name: Some(c.display_name.clone()),
2503 category: Some("container".into()),
2504 capacity_volume: c.capacity_volume,
2505 worker_lodging_capacity: c.worker_lodging_capacity,
2506 ..Default::default()
2507 },
2508 from: from.clone(),
2509 from_parent_instance_id: None,
2510 is_equip_shell: false,
2511 is_chest_shell: true,
2512 section: InventorySection::Nearby,
2513 });
2514 if c.accessible {
2515 for child in &c.contents {
2516 push_inventory_rows(
2517 &mut rows,
2518 1,
2519 child,
2520 &from,
2521 c.item_instance_id,
2522 InventorySection::Nearby,
2523 );
2524 }
2525 }
2526 Some(NearbyContainer {
2527 view: c.clone(),
2528 distance_m,
2529 rows,
2530 })
2531 })
2532 .collect();
2533 list.sort_by(|a, b| {
2534 a.distance_m
2535 .partial_cmp(&b.distance_m)
2536 .unwrap_or(std::cmp::Ordering::Equal)
2537 });
2538 list
2539 }
2540
2541 pub fn nearest_placed_container(
2543 &self,
2544 max_dist: f32,
2545 ) -> Option<flatland_protocol::PlacedContainerView> {
2546 let (px, py) = self.player_position();
2547 self.placed_containers
2548 .iter()
2549 .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
2550 .min_by(|a, b| {
2551 let da = (a.x - px).hypot(a.y - py);
2552 let db = (b.x - px).hypot(b.y - py);
2553 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
2554 })
2555 .cloned()
2556 }
2557
2558 pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
2561 let filter = self.inventory_filter.as_str();
2562 match self.inventory_tab {
2563 InventoryTab::OnPerson => {
2564 let mut rows = self.worn_rows_filtered(filter);
2565 rows.extend(self.person_rows_filtered(filter));
2566 rows
2567 }
2568 InventoryTab::Nearby => {
2569 let mut rows = Vec::new();
2570 for nc in self.nearby_containers() {
2571 if filter.is_empty() {
2572 rows.extend(nc.rows);
2573 continue;
2574 }
2575 let shell = nc.rows.first().cloned();
2576 let contents: Vec<_> = nc
2577 .rows
2578 .iter()
2579 .skip(1)
2580 .filter(|r| stack_matches_filter(&r.stack, filter))
2581 .cloned()
2582 .collect();
2583 let shell_hit = shell
2584 .as_ref()
2585 .map(|s| stack_matches_filter(&s.stack, filter))
2586 .unwrap_or(false);
2587 if shell_hit || !contents.is_empty() {
2588 if let Some(s) = shell {
2589 rows.push(s);
2590 }
2591 if shell_hit {
2592 rows.extend(nc.rows.into_iter().skip(1));
2593 } else {
2594 rows.extend(contents);
2595 }
2596 }
2597 }
2598 rows
2599 }
2600 }
2601 }
2602
2603 pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
2604 self.inventory_selectable_rows()
2605 .into_iter()
2606 .nth(self.inventory_menu_index)
2607 }
2608
2609 pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
2611 let cat = self
2612 .inventory_item_category(&row.stack.template_id)
2613 .unwrap_or("");
2614 let label = if cat == "key" {
2615 self.key_inventory_label(&row.stack)
2616 } else {
2617 row.stack
2618 .display_name
2619 .clone()
2620 .unwrap_or_else(|| row.stack.template_id.clone())
2621 };
2622 let hint: String = if row.is_equip_shell {
2623 " [worn — Enter to unequip]".into()
2624 } else if row.is_chest_shell {
2625 let (locked, lodging_note) = match &row.from {
2626 flatland_protocol::InventoryLocation::Placed { container_id } => {
2627 let locked = self
2628 .placed_containers
2629 .iter()
2630 .find(|c| c.id == *container_id)
2631 .map(|c| c.locked)
2632 .unwrap_or(false);
2633 let lodging_note = self
2634 .lodging_occupancy_label(container_id)
2635 .map(|who| format!(" [lodging: {who}]"))
2636 .unwrap_or_default();
2637 (locked, lodging_note)
2638 }
2639 _ => (false, String::new()),
2640 };
2641 if locked {
2642 format!(" [locked — Enter pick up · l unlock]{lodging_note}")
2643 } else {
2644 format!(" [Enter pick up · l lock]{lodging_note}")
2645 }
2646 } else if cat == "key" {
2647 self.key_inventory_hint(&row.stack)
2648 } else {
2649 match cat {
2650 "weapon" => " [weapon]".into(),
2651 "container" => " [bag/chest/belt]".into(),
2652 "lodging" => " [worker lodging]".into(),
2653 "armor" => " [armor]".into(),
2654 _ => String::new(),
2655 }
2656 };
2657 let qty = if row.stack.quantity > 1 {
2658 format!(" ×{}", row.stack.quantity)
2659 } else {
2660 String::new()
2661 };
2662 let bindings = format_status_bindings_suffix(
2663 &row.stack.status_bindings,
2664 self.tick,
2665 DEFAULT_TICK_HZ,
2666 );
2667 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
2668 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
2669 let mode = Self::grant_mode(&row.stack);
2670 format!(" [grant {effect} · {mode} — e apply]")
2671 } else {
2672 String::new()
2673 };
2674 let mass = self.stack_mass(&row.stack);
2675 let mass_kg = (mass >= 0.05).then_some(mass);
2676 let mass_str = mass_kg
2677 .map(|m| format!(" {m:.1} kg"))
2678 .unwrap_or_default();
2679 let volume = self.container_volume_stats(row);
2680 let vol_str = self.container_volume_label(row);
2681
2682 let mut title = label.clone();
2683 if let Some(id) = row.stack.item_instance_id {
2684 let hex: String = id
2685 .as_simple()
2686 .to_string()
2687 .chars()
2688 .filter(|c| c.is_ascii_hexdigit())
2689 .collect();
2690 if hex.len() >= 4 {
2691 title.push_str(&format!(" #{}", &hex[hex.len() - 4..]));
2692 }
2693 }
2694 title.push_str(&qty);
2695 if row.is_equip_shell {
2696 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
2697 title.push_str(&format!(" ({})", body_slot_label(slot)));
2698 }
2699 }
2700
2701 InventoryRowView {
2702 depth: row.depth,
2703 text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
2704 title: format!("{title}{grant_hint}{bindings}"),
2705 mass_kg,
2706 volume,
2707 }
2708 }
2709
2710 fn push_browser_item(
2711 &self,
2712 lines: &mut Vec<InventoryBrowserLine>,
2713 row: &InventoryRow,
2714 global_idx: &mut usize,
2715 target: usize,
2716 highlight: bool,
2717 ) {
2718 let view = self.format_inventory_row(row);
2719 lines.push(InventoryBrowserLine::Item {
2720 selectable_index: *global_idx,
2721 selected: highlight && *global_idx == target,
2722 depth: view.depth,
2723 text: view.text,
2724 title: view.title,
2725 mass_kg: view.mass_kg,
2726 volume: view.volume,
2727 });
2728 *global_idx += 1;
2729 }
2730
2731 pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
2734 let mut lines = Vec::new();
2735 let target = self.inventory_menu_index;
2736 let highlight = !self.show_move_picker && !self.show_grant_picker;
2737 let filter = self.inventory_filter.as_str();
2738 let mut global_idx = 0usize;
2739
2740 match self.inventory_tab {
2741 InventoryTab::OnPerson => {
2742 lines.push(InventoryBrowserLine::Section("— Worn —".into()));
2743 let worn = self.worn_rows_filtered(filter);
2744 if worn.is_empty() {
2745 lines.push(InventoryBrowserLine::Hint(
2746 " (nothing equipped — wear a backpack/belt from \"On you\" below)".into(),
2747 ));
2748 } else {
2749 for row in &worn {
2750 if row.is_equip_shell {
2751 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
2752 lines.push(InventoryBrowserLine::SlotLabel(format!(
2753 " {}:",
2754 body_slot_label(slot)
2755 )));
2756 }
2757 }
2758 self.push_browser_item(
2759 &mut lines,
2760 row,
2761 &mut global_idx,
2762 target,
2763 highlight,
2764 );
2765 }
2766 }
2767
2768 lines.push(InventoryBrowserLine::Blank);
2769 lines.push(InventoryBrowserLine::Section(
2770 "— On you (loose, not worn) —".into(),
2771 ));
2772 let person = self.person_rows_filtered(filter);
2773 if person.is_empty() {
2774 lines.push(InventoryBrowserLine::Hint(" (empty)".into()));
2775 } else {
2776 let mut last_group: Option<&'static str> = None;
2777 for row in &person {
2778 if row.depth == 0 {
2779 let cat = row
2780 .stack
2781 .category
2782 .as_deref()
2783 .or_else(|| self.inventory_item_category(&row.stack.template_id))
2784 .unwrap_or("");
2785 let (group, _) = inventory_category_group(cat);
2786 if last_group != Some(group) {
2787 lines.push(InventoryBrowserLine::SlotLabel(format!(
2788 " {group}"
2789 )));
2790 last_group = Some(group);
2791 }
2792 }
2793 self.push_browser_item(
2794 &mut lines,
2795 row,
2796 &mut global_idx,
2797 target,
2798 highlight,
2799 );
2800 }
2801 }
2802 }
2803 InventoryTab::Nearby => {
2804 let nearby = self.nearby_containers();
2805 if nearby.is_empty() {
2806 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
2807 lines.push(InventoryBrowserLine::Hint(
2808 " (none within reach — walk up to a chest)".into(),
2809 ));
2810 lines.push(InventoryBrowserLine::Hint(
2811 " Select an on-person item, then m / Enter → move into chest.".into(),
2812 ));
2813 } else {
2814 let mut any_visible = false;
2815 for nc in &nearby {
2816 let shell = nc.rows.first();
2817 let contents: Vec<&InventoryRow> = if filter.is_empty() {
2818 nc.rows.iter().skip(1).collect()
2819 } else {
2820 let shell_hit = shell
2821 .map(|s| {
2822 let f = filter.to_ascii_lowercase();
2823 let name = s
2824 .stack
2825 .display_name
2826 .as_deref()
2827 .unwrap_or("")
2828 .to_ascii_lowercase();
2829 let tid = s.stack.template_id.to_ascii_lowercase();
2830 name.contains(&f) || tid.contains(&f)
2831 })
2832 .unwrap_or(false);
2833 if shell_hit {
2834 nc.rows.iter().skip(1).collect()
2835 } else {
2836 nc.rows
2837 .iter()
2838 .skip(1)
2839 .filter(|r| stack_matches_filter(&r.stack, filter))
2840 .collect()
2841 }
2842 };
2843 let shell_visible = filter.is_empty()
2844 || shell
2845 .map(|s| stack_matches_filter(&s.stack, filter))
2846 .unwrap_or(false)
2847 || !contents.is_empty();
2848 if !shell_visible && shell.is_some() {
2849 continue;
2850 }
2851 any_visible = true;
2852 lines.push(InventoryBrowserLine::Blank);
2853 let lock_note = if nc.view.locked && nc.view.accessible {
2854 " unlocked with your key"
2855 } else if nc.view.locked {
2856 " locked"
2857 } else {
2858 ""
2859 };
2860 lines.push(InventoryBrowserLine::Section(format!(
2861 "— {} ({:.0}m away){lock_note} —",
2862 nc.view.display_name, nc.distance_m
2863 )));
2864 if !nc.view.accessible {
2865 lines.push(InventoryBrowserLine::Hint(
2866 " locked — need the matching key (l to try)".into(),
2867 ));
2868 } else if nc.rows.is_empty() {
2869 lines.push(InventoryBrowserLine::Hint(
2870 " (empty — switch to On person, select an item, m to move in)"
2871 .into(),
2872 ));
2873 } else if let Some(shell_row) = shell {
2874 self.push_browser_item(
2875 &mut lines,
2876 shell_row,
2877 &mut global_idx,
2878 target,
2879 highlight,
2880 );
2881 for row in contents {
2882 self.push_browser_item(
2883 &mut lines,
2884 row,
2885 &mut global_idx,
2886 target,
2887 highlight,
2888 );
2889 }
2890 }
2891 }
2892 if !any_visible {
2893 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
2894 lines.push(InventoryBrowserLine::Hint(
2895 " (no matching items — clear filter with Esc)".into(),
2896 ));
2897 }
2898 }
2899 }
2900 }
2901 lines
2902 }
2903
2904 pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
2906 let mut opts = Vec::new();
2907 opts.push(MoveOption {
2908 label: "On your person (loose)".into(),
2909 kind: MoveOptionKind::PickupPlaced {
2910 container_id: container_id.to_string(),
2911 nest_location: flatland_protocol::InventoryLocation::Root,
2912 nest_parent_instance_id: None,
2913 },
2914 });
2915 for (slot, item) in &self.worn {
2916 if item.category.as_deref() != Some("container") {
2917 continue;
2918 }
2919 if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
2920 continue;
2921 }
2922 let Some(parent_id) = item.item_instance_id else {
2923 continue;
2924 };
2925 let shell_name = item
2926 .display_name
2927 .clone()
2928 .unwrap_or_else(|| item.template_id.clone());
2929 opts.push(MoveOption {
2930 label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
2931 kind: MoveOptionKind::PickupPlaced {
2932 container_id: container_id.to_string(),
2933 nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
2934 nest_parent_instance_id: Some(parent_id),
2935 },
2936 });
2937 Self::append_chest_pickup_nested(
2939 &mut opts,
2940 container_id,
2941 flatland_protocol::InventoryLocation::Worn { slot: *slot },
2942 item,
2943 &format!("in {shell_name}"),
2944 );
2945 }
2946 opts.push(MoveOption {
2947 label: "Cancel".into(),
2948 kind: MoveOptionKind::Cancel,
2949 });
2950 opts
2951 }
2952
2953 fn append_chest_pickup_nested(
2954 opts: &mut Vec<MoveOption>,
2955 container_id: &str,
2956 location: flatland_protocol::InventoryLocation,
2957 parent: &flatland_protocol::ItemStack,
2958 context: &str,
2959 ) {
2960 for child in &parent.contents {
2961 if child.category.as_deref() != Some("container") {
2962 continue;
2963 }
2964 if !Self::is_volume_container_stack(child) {
2965 continue;
2966 }
2967 if child.world_placeable == Some(true) {
2969 continue;
2970 }
2971 let Some(child_id) = child.item_instance_id else {
2972 continue;
2973 };
2974 let name = child
2975 .display_name
2976 .clone()
2977 .unwrap_or_else(|| child.template_id.clone());
2978 opts.push(MoveOption {
2979 label: format!("{name} ({context})"),
2980 kind: MoveOptionKind::PickupPlaced {
2981 container_id: container_id.to_string(),
2982 nest_location: location.clone(),
2983 nest_parent_instance_id: Some(child_id),
2984 },
2985 });
2986 Self::append_chest_pickup_nested(
2987 opts,
2988 container_id,
2989 location.clone(),
2990 child,
2991 &format!("in {name}"),
2992 );
2993 }
2994 }
2995
2996 pub fn move_destinations_for(
2998 &self,
2999 from: &flatland_protocol::InventoryLocation,
3000 from_parent_instance_id: Option<uuid::Uuid>,
3001 moving_instance_id: Option<uuid::Uuid>,
3002 moving_template_id: &str,
3003 ) -> Vec<MoveOption> {
3004 let mut opts = Vec::new();
3005 if *from != flatland_protocol::InventoryLocation::Root {
3006 opts.push(MoveOption {
3007 label: "On your person (loose)".into(),
3008 kind: MoveOptionKind::Move {
3009 location: flatland_protocol::InventoryLocation::Root,
3010 parent_instance_id: None,
3011 },
3012 });
3013 }
3014 for (slot, item) in &self.worn {
3015 if item.category.as_deref() != Some("container") {
3016 continue;
3017 }
3018 let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3019 let shell_name = item
3020 .display_name
3021 .clone()
3022 .unwrap_or_else(|| item.template_id.clone());
3023
3024 if *slot != BodySlot::Waist
3026 && item.item_instance_id != moving_instance_id
3027 && Self::is_volume_container_stack(item)
3028 {
3029 Self::push_move_destination(
3030 &mut opts,
3031 format!("{shell_name} (worn {})", body_slot_label(*slot)),
3032 location.clone(),
3033 item.item_instance_id,
3034 from,
3035 from_parent_instance_id,
3036 );
3037 }
3038
3039 if *slot == BodySlot::Waist
3041 && Self::attaches_to_belt_loop(moving_template_id)
3042 && item.item_instance_id != moving_instance_id
3043 {
3044 Self::push_move_destination(
3045 &mut opts,
3046 format!("{shell_name} (belt loop)"),
3047 location.clone(),
3048 item.item_instance_id,
3049 from,
3050 from_parent_instance_id,
3051 );
3052 }
3053
3054 let context = if *slot == BodySlot::Waist {
3055 format!("on {shell_name}")
3056 } else {
3057 format!("in {shell_name}")
3058 };
3059 Self::append_nested_container_destinations(
3060 &mut opts,
3061 location,
3062 item,
3063 &context,
3064 from,
3065 from_parent_instance_id,
3066 moving_instance_id,
3067 );
3068 }
3069 for nc in self.nearby_containers() {
3070 if !nc.view.accessible {
3071 continue;
3072 }
3073 let location = flatland_protocol::InventoryLocation::Placed {
3074 container_id: nc.view.id.clone(),
3075 };
3076 Self::push_move_destination(
3077 &mut opts,
3078 format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
3079 location,
3080 nc.view.item_instance_id,
3081 from,
3082 from_parent_instance_id,
3083 );
3084 }
3085 let allow_drop = moving_instance_id
3086 .and_then(|id| self.stack_for_instance(id))
3087 .map(|stack| !self.key_drop_blocked(&stack))
3088 .unwrap_or(moving_template_id != KEY_TEMPLATE);
3089 if allow_drop {
3090 opts.push(MoveOption {
3091 label: "Drop on the ground".into(),
3092 kind: MoveOptionKind::Drop,
3093 });
3094 }
3095 opts.push(MoveOption {
3096 label: "Cancel".into(),
3097 kind: MoveOptionKind::Cancel,
3098 });
3099 opts
3100 }
3101
3102 fn is_same_container_dest(
3103 dest_location: &flatland_protocol::InventoryLocation,
3104 dest_parent: Option<uuid::Uuid>,
3105 from: &flatland_protocol::InventoryLocation,
3106 from_parent: Option<uuid::Uuid>,
3107 ) -> bool {
3108 dest_location == from && dest_parent == from_parent
3109 }
3110
3111 fn push_move_destination(
3112 opts: &mut Vec<MoveOption>,
3113 label: String,
3114 location: flatland_protocol::InventoryLocation,
3115 parent_instance_id: Option<uuid::Uuid>,
3116 from: &flatland_protocol::InventoryLocation,
3117 from_parent_instance_id: Option<uuid::Uuid>,
3118 ) {
3119 if Self::is_same_container_dest(
3120 &location,
3121 parent_instance_id,
3122 from,
3123 from_parent_instance_id,
3124 ) {
3125 return;
3126 }
3127 opts.push(MoveOption {
3128 label,
3129 kind: MoveOptionKind::Move {
3130 location,
3131 parent_instance_id,
3132 },
3133 });
3134 }
3135
3136 fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
3137 stack.capacity_volume.is_some_and(|c| c > 0.0)
3138 }
3139
3140 fn attaches_to_belt_loop(template_id: &str) -> bool {
3141 matches!(template_id, "leather_pouch" | "dimensional_pouch")
3142 }
3143
3144 fn append_nested_container_destinations(
3145 opts: &mut Vec<MoveOption>,
3146 location: flatland_protocol::InventoryLocation,
3147 container: &flatland_protocol::ItemStack,
3148 context: &str,
3149 from: &flatland_protocol::InventoryLocation,
3150 from_parent_instance_id: Option<uuid::Uuid>,
3151 moving_instance_id: Option<uuid::Uuid>,
3152 ) {
3153 for child in &container.contents {
3154 if Self::is_volume_container_stack(child)
3155 && child.item_instance_id != moving_instance_id
3156 {
3157 let name = child
3158 .display_name
3159 .clone()
3160 .unwrap_or_else(|| child.template_id.clone());
3161 Self::push_move_destination(
3162 opts,
3163 format!("{name} ({context})"),
3164 location.clone(),
3165 child.item_instance_id,
3166 from,
3167 from_parent_instance_id,
3168 );
3169 }
3170 let nested_context = format!(
3171 "in {}",
3172 child.display_name.as_deref().unwrap_or(&child.template_id)
3173 );
3174 Self::append_nested_container_destinations(
3175 opts,
3176 location.clone(),
3177 child,
3178 &nested_context,
3179 from,
3180 from_parent_instance_id,
3181 moving_instance_id,
3182 );
3183 }
3184 }
3185
3186 fn clamp_inventory_indices(&mut self) {
3187 let n = self.inventory_selectable_rows().len();
3188 self.inventory_menu_index = if n == 0 {
3189 0
3190 } else {
3191 self.inventory_menu_index.min(n - 1)
3192 };
3193 if let Some(picker) = &self.move_picker {
3194 let pn = picker.options.len();
3195 self.move_picker_index = if pn == 0 {
3196 0
3197 } else {
3198 self.move_picker_index.min(pn - 1)
3199 };
3200 }
3201 }
3202
3203 fn sync_interior_map_context(&mut self) {
3205 if self.effective_inside_building().is_none() {
3206 self.interior_map = None;
3207 }
3208 }
3209
3210 fn apply_snapshot_fields(
3211 &mut self,
3212 snapshot: &flatland_protocol::Snapshot,
3213 entity_id: EntityId,
3214 ) {
3215 self.tick = snapshot.tick;
3216 self.chunk_rev = snapshot.chunk_rev;
3217 self.content_rev = snapshot.content_rev;
3218 self.publish_rev = snapshot.publish_rev;
3219 self.resource_nodes = snapshot.resource_nodes.clone();
3220 self.ground_drops = snapshot.ground_drops.clone();
3221 self.placed_containers = snapshot.placed_containers.clone();
3222 self.world_width_m = snapshot.world_width_m;
3223 self.world_height_m = snapshot.world_height_m;
3224 self.world_clock = snapshot.world_clock;
3225 self.terrain_zones = snapshot.terrain_zones.clone();
3226 self.z_platforms = snapshot.z_platforms.clone();
3227 self.z_transitions = snapshot.z_transitions.clone();
3228 self.buildings = snapshot.buildings.clone();
3229 self.doors = snapshot.doors.clone();
3230 self.interior_map = snapshot.interior_map.clone();
3231 self.npcs = snapshot.npcs.clone();
3232 self.blueprints = snapshot.blueprints.clone();
3233 self.sync_inventory_from_stacks(&snapshot.inventory);
3234 self.player = snapshot
3235 .entities
3236 .iter()
3237 .find(|e| e.id == entity_id)
3238 .cloned();
3239 self.entities = snapshot.entities.clone();
3240 self.quest_log = snapshot.quest_log.clone();
3241 self.apply_hired_workers(snapshot.hired_workers.clone());
3242 self.interactables = snapshot.interactables.clone();
3243 self.ledger = snapshot.ledger.clone();
3244 self.career = snapshot.career.clone();
3245 self.combat_fx = snapshot.combat_fx.clone();
3246 self.sync_interior_map_context();
3247 }
3248
3249 fn refresh_inventory_ui(&mut self) {
3253 if let Some(picker) = &self.move_picker {
3254 let instance_id = picker.item_instance_id;
3255 let still_exists = self
3256 .inventory_selectable_rows()
3257 .iter()
3258 .any(|r| r.stack.item_instance_id == Some(instance_id));
3259 if !still_exists {
3260 self.move_picker = None;
3261 self.show_move_picker = false;
3262 }
3263 }
3264 if let Some(picker) = &self.destroy_picker {
3265 let instance_id = picker.item_instance_id;
3266 let still_exists = self
3267 .inventory_selectable_rows()
3268 .iter()
3269 .any(|r| r.stack.item_instance_id == Some(instance_id));
3270 if !still_exists {
3271 self.destroy_picker = None;
3272 self.show_destroy_picker = false;
3273 self.destroy_confirm_pending = false;
3274 }
3275 }
3276 self.clamp_inventory_indices();
3277 }
3278
3279 fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
3285 let selected_id = self
3286 .hired_workers
3287 .get(self.workers_menu_index)
3288 .map(|w| w.instance_id.clone());
3289 workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
3290 let now = Instant::now();
3291 for w in &workers {
3292 let prev_err = self
3293 .hired_workers
3294 .iter()
3295 .find(|p| p.instance_id == w.instance_id)
3296 .and_then(|p| p.last_error.as_deref());
3297 let new_err = w.last_error.as_deref();
3298 if new_err != prev_err {
3299 if let Some(err) = new_err {
3300 if !worker_error_is_transient(err) {
3301 self.push_log(format!("Worker {}: {err}", w.label));
3302 }
3303 }
3304 }
3305 }
3306 let mut next_display = BTreeMap::new();
3307 let mut next_errors = BTreeMap::new();
3308 for w in &workers {
3309 let mut sticky = self
3310 .worker_step_display
3311 .remove(&w.instance_id)
3312 .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
3313 sticky.observe(&w.step_label, now);
3314 next_display.insert(w.instance_id.clone(), sticky);
3315
3316 let mut err_sticky = self
3317 .worker_error_display
3318 .remove(&w.instance_id)
3319 .unwrap_or_default();
3320 err_sticky.observe(w.last_error.as_deref(), now);
3321 if err_sticky.shown(now).is_some() {
3322 next_errors.insert(w.instance_id.clone(), err_sticky);
3323 }
3324 }
3325 self.worker_step_display = next_display;
3326 self.worker_error_display = next_errors;
3327 self.hired_workers = workers;
3328 if let Some(id) = selected_id {
3329 if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
3330 self.workers_menu_index = idx;
3331 return;
3332 }
3333 }
3334 if self.workers_menu_index >= self.hired_workers.len() {
3335 self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
3336 }
3337 }
3338
3339 pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
3341 self.worker_step_display
3342 .get(worker_instance_id)
3343 .map(|s| s.shown.as_str())
3344 .or_else(|| {
3345 self.hired_workers
3346 .iter()
3347 .find(|w| w.instance_id == worker_instance_id)
3348 .map(|w| w.step_label.as_str())
3349 })
3350 .unwrap_or("")
3351 }
3352
3353 pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
3355 let now = Instant::now();
3356 self.worker_error_display
3357 .get(worker_instance_id)
3358 .and_then(|s| s.shown(now))
3359 .or_else(|| {
3360 self.hired_workers
3361 .iter()
3362 .find(|w| w.instance_id == worker_instance_id)
3363 .and_then(|w| w.last_error.as_deref())
3364 .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
3365 })
3366 .filter(|e| !worker_error_is_hud_noise(e))
3367 }
3368
3369 fn apply_combat_hud(&mut self, combat: &CombatHud) {
3370 self.in_combat = combat.in_combat;
3371 self.auto_attack = combat.auto_attack;
3372 self.combat_has_los = combat.has_los;
3373 self.attack_cd_ticks = combat.attack_cd_ticks;
3374 self.gcd_ticks = combat.gcd_ticks;
3375 self.weapon_ability_id = combat.ability_id.clone();
3376 self.mainhand_template_id = combat.mainhand_template_id.clone();
3377 self.mainhand_label = combat.mainhand_label.clone();
3378 self.offhand_template_id = combat.offhand_template_id.clone();
3379 self.offhand_label = combat.offhand_label.clone();
3380 self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
3381 1
3382 } else {
3383 combat.mainhand_hand_slots
3384 };
3385 self.defense = combat.defense.clone();
3386 self.worn = combat.worn.iter().cloned().collect();
3387 self.carry_mass = combat.carry_mass;
3388 self.carry_mass_max = combat.carry_mass_max;
3389 self.encumbrance = combat.encumbrance;
3390 self.cast_progress = combat.cast.clone();
3391 self.ability_cooldowns = combat.ability_cooldowns.clone();
3392 self.blocking_active = combat.blocking_active;
3393 self.max_target_slots = combat.max_target_slots.max(1);
3394 self.combat_slots = combat.slots.clone();
3395 self.rotation_presets = combat.rotation_presets.clone();
3396 self.known_abilities = combat.known_abilities.clone();
3397 self.hotbar = combat.hotbar.clone();
3398 self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
3399 self.keychain_stacks = combat.keychain.clone();
3400 self.combat_target_detail = combat.target.clone();
3401 self.statuses = combat.statuses.clone();
3402 self.combat_target = combat.target_entity_id;
3403 if combat.progression_xp_base > 0.0 {
3404 self.progression_curve = Some(flatland_protocol::ProgressionCurve {
3405 baseline_display: combat.progression_baseline,
3406 xp_base: combat.progression_xp_base,
3407 xp_growth: combat.progression_xp_growth,
3408 });
3409 }
3410 if let Some(xp) = &combat.progression_xp {
3411 if let Some(player) = &mut self.player {
3412 player.progression_xp = Some(xp.clone());
3413 if let Some(attrs) = combat.attributes {
3414 player.attributes = Some(attrs);
3415 }
3416 if let Some(skills) = &combat.skills {
3417 player.skills = Some(skills.clone());
3418 }
3419 }
3420 }
3421 if let Some(label) = &combat.target_label {
3422 self.combat_target_label = Some(label.clone());
3423 } else if let Some(id) = combat.target_entity_id {
3424 self.combat_target_label = self
3425 .entities
3426 .iter()
3427 .find(|e| e.id == id)
3428 .map(|e| e.label.clone())
3429 .or_else(|| self.combat_target_label.clone());
3430 }
3431 self.refresh_inventory_ui();
3432 }
3433
3434 pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
3436 self.combat_slots
3437 .iter()
3438 .find(|s| s.slot_index == slot)
3439 .and_then(|s| s.target_entity_id)
3440 .or_else(|| if slot == 1 { self.combat_target } else { None })
3441 }
3442
3443 pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
3445 if !(1..=9).contains(&slot_1_to_9) {
3446 return None;
3447 }
3448 self.hotbar
3449 .get((slot_1_to_9 - 1) as usize)
3450 .and_then(|a| a.as_deref())
3451 .filter(|id| !id.is_empty())
3452 }
3453
3454 pub fn loadout_ability_choices(&self) -> Vec<String> {
3456 let mut out = self.known_abilities.clone();
3457 let weapon = self.weapon_ability_id.trim();
3458 if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
3459 out.push(weapon.to_string());
3460 }
3461 out
3462 }
3463
3464 pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
3466 self.combat_candidates()
3467 }
3468
3469 pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
3471 let (px, py) = self.player_position();
3472 let dist = |id: EntityId| {
3473 self.entities
3474 .iter()
3475 .find(|e| e.id == id)
3476 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
3477 .unwrap_or(f32::MAX)
3478 };
3479
3480 let mut allies = Vec::new();
3481 if let Some(me) = self.player.as_ref() {
3483 let alive = me
3484 .vitals
3485 .as_ref()
3486 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
3487 .unwrap_or(true);
3488 if alive {
3489 allies.push((self.entity_id, "Yourself".into()));
3490 }
3491 }
3492 for entity in &self.entities {
3493 if entity.id == self.entity_id {
3494 continue;
3495 }
3496 if entity.vitals.is_some() {
3497 let alive = entity
3498 .vitals
3499 .as_ref()
3500 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
3501 .unwrap_or(true);
3502 if alive {
3503 allies.push((entity.id, entity.label.clone()));
3504 }
3505 }
3506 }
3507 allies.sort_by(|(a, _), (b, _)| {
3508 if *a == self.entity_id {
3509 return std::cmp::Ordering::Less;
3510 }
3511 if *b == self.entity_id {
3512 return std::cmp::Ordering::Greater;
3513 }
3514 dist(*a)
3515 .partial_cmp(&dist(*b))
3516 .unwrap_or(std::cmp::Ordering::Equal)
3517 });
3518
3519 let mut monsters = self.combat_candidates();
3520 monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
3521 allies.into_iter().chain(monsters).collect()
3522 }
3523
3524 fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
3525 match slot_index {
3526 2 => self.t2_candidates(),
3527 _ => self.t1_candidates(),
3528 }
3529 }
3530
3531 pub fn pick_combat_target_at(
3533 &self,
3534 wx: f32,
3535 wy: f32,
3536 slot_index: u8,
3537 radius_m: f32,
3538 ) -> Option<(EntityId, String)> {
3539 let mut best: Option<(f32, EntityId, String)> = None;
3540 for (id, label) in self.candidates_for_slot(slot_index) {
3541 let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
3542 if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
3544 let d = distance(wx, wy, npc.x, npc.y);
3545 if d <= radius_m {
3546 best = match best {
3547 Some((bd, _, _)) if bd <= d => best,
3548 _ => Some((d, id, label)),
3549 };
3550 }
3551 }
3552 continue;
3553 };
3554 let d = distance(
3555 wx,
3556 wy,
3557 entity.transform.position.x,
3558 entity.transform.position.y,
3559 );
3560 if d <= radius_m {
3561 best = match best {
3562 Some((bd, _, _)) if bd <= d => best,
3563 _ => Some((d, id, label)),
3564 };
3565 }
3566 }
3567 best.map(|(_, id, label)| (id, label))
3568 }
3569
3570 pub(crate) fn restore_from_welcome(
3572 &mut self,
3573 session_id: SessionId,
3574 entity_id: EntityId,
3575 snapshot: &flatland_protocol::Snapshot,
3576 ) {
3577 self.clear_harvest_state();
3578 self.disconnect_reason = None;
3579 self.show_stats = false;
3580 self.show_craft_menu = false;
3581 self.show_shop_menu = false;
3582 self.shop_catalog = None;
3583 self.show_inventory_menu = false;
3584 self.session_id = session_id;
3585 self.entity_id = entity_id;
3586 self.connected = true;
3587 self.apply_snapshot_fields(snapshot, entity_id);
3588 if let Some(combat) = &snapshot.combat {
3589 self.apply_combat_hud(combat);
3590 let stacks = self.inventory_stacks.clone();
3591 self.sync_inventory_from_stacks(&stacks);
3592 }
3593 }
3594
3595 fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
3596 self.tick = delta.tick;
3597 self.world_clock = delta.world_clock;
3598
3599 if delta.entities.is_empty() {
3601 self.combat_fx = delta.combat_fx.clone();
3602 if let Some(combat) = &delta.combat {
3603 self.apply_combat_hud(combat);
3604 let stacks = self.inventory_stacks.clone();
3605 self.sync_inventory_from_stacks(&stacks);
3606 }
3607 return;
3608 }
3609 if !delta.buildings.is_empty() {
3610 self.buildings = delta.buildings.clone();
3611 }
3612 if !delta.blueprints.is_empty() {
3613 self.blueprints = delta.blueprints.clone();
3614 }
3615 self.sync_inventory_from_stacks(&delta.inventory);
3616
3617 if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
3618 self.player = Some(updated.clone());
3619 }
3620 self.entities = delta.entities.clone();
3621 if self.player.is_none() {
3622 self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
3623 }
3624
3625 self.sync_interior_map_context();
3626
3627 if !delta.resource_nodes.is_empty() {
3631 self.resource_nodes = delta.resource_nodes.clone();
3632 } else if delta.interior_map.is_some()
3633 || self.effective_inside_building().is_some()
3634 {
3635 self.resource_nodes = delta.resource_nodes.clone();
3636 }
3637 if self
3638 .player
3639 .as_ref()
3640 .is_none_or(|p| p.inside_building.is_none())
3641 {
3642 self.ground_drops = delta.ground_drops.clone();
3643 self.placed_containers = delta.placed_containers.clone();
3644 }
3645 if !delta.doors.is_empty() {
3646 self.doors = delta.doors.clone();
3647 }
3648 if self.effective_inside_building().is_some() {
3649 if let Some(map) = &delta.interior_map {
3650 self.interior_map = Some(map.clone());
3651 }
3652 } else {
3653 self.interior_map = None;
3654 }
3655 self.npcs = delta.npcs.clone();
3657 if !delta.quest_log.is_empty() {
3658 self.quest_log = delta.quest_log.clone();
3659 }
3660 self.apply_hired_workers(delta.hired_workers.clone());
3661 if !delta.interactables.is_empty() {
3662 self.interactables = delta.interactables.clone();
3663 }
3664 if delta.ledger.is_some() {
3665 self.ledger = delta.ledger.clone();
3666 }
3667 if delta.career.is_some() {
3668 self.career = delta.career.clone();
3669 }
3670 self.combat_fx = delta.combat_fx.clone();
3671 if let Some(combat) = &delta.combat {
3672 self.apply_combat_hud(combat);
3673 let stacks = self.inventory_stacks.clone();
3674 self.sync_inventory_from_stacks(&stacks);
3675 } else {
3676 self.refresh_inventory_ui();
3677 }
3678 }
3679
3680 pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
3682 let (px, py) = self.player_position();
3683 let mut out = Vec::new();
3684 for npc in &self.npcs {
3685 let Some(eid) = npc.entity_id else {
3686 continue;
3687 };
3688 let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
3689 let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
3690 if alive && has_hp {
3691 out.push((eid, npc.label.clone()));
3692 }
3693 }
3694 out.sort_by(|(a_id, a_label), (b_id, b_label)| {
3695 let dist = |id: EntityId| {
3696 self.entities
3697 .iter()
3698 .find(|e| e.id == id)
3699 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
3700 .unwrap_or(f32::MAX)
3701 };
3702 dist(*a_id)
3703 .partial_cmp(&dist(*b_id))
3704 .unwrap_or(std::cmp::Ordering::Equal)
3705 .then_with(|| a_label.cmp(b_label))
3706 .then_with(|| a_id.cmp(b_id))
3707 });
3708 out
3709 }
3710
3711 pub fn refresh_combat_target_label(&mut self) {
3712 let Some(id) = self.combat_target else {
3713 return;
3714 };
3715 if let Some((_, label)) = self
3716 .combat_candidates()
3717 .into_iter()
3718 .find(|(eid, _)| *eid == id)
3719 {
3720 self.combat_target_label = Some(label);
3721 } else if let Some(label) = self
3722 .entities
3723 .iter()
3724 .find(|e| e.id == id)
3725 .map(|e| e.label.clone())
3726 {
3727 self.combat_target_label = Some(label);
3728 }
3729 }
3730
3731 pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
3732 self.quest_log
3733 .iter()
3734 .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
3735 .collect()
3736 }
3737
3738 pub fn has_worker_lodging(&self) -> bool {
3740 self.free_worker_lodging_slots() > 0
3741 }
3742
3743 pub fn free_worker_lodging_slots(&self) -> i64 {
3745 let slots: u32 = self
3746 .placed_containers
3747 .iter()
3748 .filter(|c| match (self.character_id, c.owner_character_id) {
3749 (Some(me), Some(owner)) => me == owner,
3750 (Some(_), None) => false,
3751 (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
3752 })
3753 .map(|c| c.worker_lodging_capacity.unwrap_or(0))
3754 .sum();
3755 let used = self.hired_workers.len() as u32;
3756 slots as i64 - used as i64
3757 }
3758
3759 pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
3761 let mut names: Vec<String> = self
3762 .hired_workers
3763 .iter()
3764 .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
3765 .map(|w| w.label.clone())
3766 .collect();
3767 names.sort();
3768 names
3769 }
3770
3771 pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
3773 let is_lodging = self
3774 .placed_containers
3775 .iter()
3776 .find(|c| c.id == container_id)
3777 .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
3778 if !is_lodging {
3779 return None;
3780 }
3781 let names = self.lodging_occupant_labels(container_id);
3782 Some(if names.is_empty() {
3783 "vacant".into()
3784 } else {
3785 names.join(", ")
3786 })
3787 }
3788
3789 pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
3790 self.quest_log
3791 .iter()
3792 .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
3793 .or_else(|| {
3794 self.quest_log
3795 .iter()
3796 .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
3797 })
3798 }
3799
3800 pub fn nearest_interact_target(&self) -> Option<String> {
3802 let (px, py) = self.player_position();
3803 let inside = self.effective_inside_building();
3804
3805 #[derive(Clone, Copy, PartialEq, Eq)]
3806 enum Kind {
3807 Npc,
3808 QuestBoard,
3809 ExitDoor,
3810 EnterDoor,
3811 Well,
3812 Water,
3813 }
3814
3815 fn kind_priority(kind: Kind) -> u8 {
3816 match kind {
3817 Kind::Npc => 0,
3818 Kind::QuestBoard => 1,
3819 Kind::ExitDoor => 2,
3820 Kind::EnterDoor => 3,
3821 Kind::Well => 4,
3822 Kind::Water => 5,
3823 }
3824 }
3825
3826 let mut best: Option<(f32, Kind, String)> = None;
3827
3828 let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
3829 if dist > max {
3830 return;
3831 }
3832 let replace = match best {
3833 None => true,
3834 Some((bd, _bk, _)) if dist < bd - 0.05 => true,
3835 Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
3836 kind_priority(kind) < kind_priority(bk)
3837 }
3838 _ => false,
3839 };
3840 if replace {
3841 best = Some((dist, kind, id));
3842 }
3843 };
3844
3845 for npc in &self.npcs {
3846 consider(
3847 distance(px, py, npc.x, npc.y),
3848 INTERACTION_RADIUS_M,
3849 Kind::Npc,
3850 npc.id.clone(),
3851 );
3852 }
3853
3854 for door in &self.doors {
3855 if let Some(ref bid) = inside {
3856 if door.building_id != *bid {
3857 continue;
3858 }
3859 let is_exit = door.portal.is_some();
3860 let max = if is_exit {
3861 INTERACTION_RADIUS_M
3862 } else {
3863 DOOR_INTERACTION_RADIUS_M
3864 };
3865 let kind = if is_exit {
3866 Kind::ExitDoor
3867 } else {
3868 Kind::EnterDoor
3869 };
3870 consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
3871 continue;
3872 }
3873 consider(
3874 distance(px, py, door.x, door.y),
3875 DOOR_INTERACTION_RADIUS_M,
3876 Kind::EnterDoor,
3877 door.id.clone(),
3878 );
3879 }
3880
3881 if inside.is_none() {
3882 for inter in &self.interactables {
3883 if inter.kind == "quest_board" {
3884 consider(
3885 distance(px, py, inter.x, inter.y),
3886 QUEST_BOARD_INTERACTION_RADIUS_M,
3887 Kind::QuestBoard,
3888 inter.id.clone(),
3889 );
3890 }
3891 }
3892 for building in &self.buildings {
3893 if !building.tags.iter().any(|t| t == "well") {
3894 continue;
3895 }
3896 consider(
3897 distance(px, py, building.x, building.y),
3898 INTERACTION_RADIUS_M,
3899 Kind::Well,
3900 building.id.clone(),
3901 );
3902 }
3903 if self.in_shallow_water() {
3904 consider(
3905 0.0,
3906 INTERACTION_RADIUS_M,
3907 Kind::Water,
3908 "water_source".into(),
3909 );
3910 }
3911 }
3912
3913 best.map(|(_, _, id)| id)
3914 }
3915
3916 pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
3918 if self.effective_inside_building().is_some() {
3919 return None;
3920 }
3921 let (px, py) = self.player_position();
3922 self.interactables
3923 .iter()
3924 .filter(|i| i.kind == "quest_board")
3925 .map(|i| {
3926 let label = if i.label.is_empty() {
3927 "Quest board".to_string()
3928 } else {
3929 i.label.clone()
3930 };
3931 (label, distance(px, py, i.x, i.y))
3932 })
3933 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
3934 }
3935
3936 pub fn template_display_name(&self, template_id: &str) -> String {
3938 self.inventory_hints
3939 .get(template_id)
3940 .map(|h| h.display_name.clone())
3941 .filter(|n| !n.is_empty())
3942 .unwrap_or_else(|| humanize_template_id(template_id))
3943 }
3944
3945 pub fn placed_container_public_label(
3947 &self,
3948 c: &flatland_protocol::PlacedContainerView,
3949 ) -> String {
3950 let is_owner = match (self.character_id, c.owner_character_id) {
3951 (Some(me), Some(owner)) => me == owner,
3952 _ => false,
3953 };
3954 if is_owner {
3955 c.display_name.clone()
3956 } else {
3957 self.template_display_name(&c.template_id)
3958 }
3959 }
3960
3961 pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
3963 let mut out = Vec::new();
3964 for stack in &self.inventory_stacks {
3965 if stack.template_id == KEY_TEMPLATE {
3966 out.push(KeychainEntry {
3967 stack: stack.clone(),
3968 stowed: false,
3969 });
3970 }
3971 }
3972 for stack in &self.keychain_stacks {
3973 if stack.template_id == KEY_TEMPLATE {
3974 out.push(KeychainEntry {
3975 stack: stack.clone(),
3976 stowed: true,
3977 });
3978 }
3979 }
3980 out
3981 }
3982
3983 pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
3985 if stack.template_id != KEY_TEMPLATE {
3986 return None;
3987 }
3988 if let Some(name) = stack
3989 .props
3990 .get(PROP_OPENS_CONTAINER_NAME)
3991 .filter(|n| !n.is_empty())
3992 {
3993 return Some(name.clone());
3994 }
3995 let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
3996 self.container_name_for_lock_id(opens)
3997 }
3998
3999 pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
4001 if stack.template_id == KEY_TEMPLATE {
4002 self.template_display_name(KEY_TEMPLATE)
4003 } else {
4004 stack
4005 .display_name
4006 .clone()
4007 .unwrap_or_else(|| stack.template_id.clone())
4008 }
4009 }
4010
4011 pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
4013 if stack.template_id != KEY_TEMPLATE {
4014 return String::new();
4015 }
4016 match self.key_pair_chest_label(stack) {
4017 Some(chest) if self.key_drop_blocked(stack) => {
4018 format!(" [key for {chest} — can't drop while locked]")
4019 }
4020 Some(chest) => format!(" [key for {chest}]"),
4021 None => " [key — unpaired]".into(),
4022 }
4023 }
4024
4025 pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
4027 for c in &self.placed_containers {
4028 if c.lock_id.as_deref() == Some(lock) {
4029 return Some(c.display_name.clone());
4030 }
4031 }
4032 Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
4033 self.worn
4034 .values()
4035 .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
4036 })
4037 }
4038
4039 pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
4041 if stack.template_id != KEY_TEMPLATE {
4042 return false;
4043 }
4044 let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
4045 return false;
4046 };
4047 for c in &self.placed_containers {
4048 if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
4049 return true;
4050 }
4051 }
4052 if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
4053 return true;
4054 }
4055 self.worn
4056 .values()
4057 .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
4058 }
4059
4060 fn container_name_in_stacks(
4061 stacks: &[flatland_protocol::ItemStack],
4062 lock: &str,
4063 ) -> Option<String> {
4064 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
4065 for s in stacks {
4066 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
4067 return Some(GameState::stack_container_label(s));
4068 }
4069 if let Some(name) = walk(&s.contents, lock) {
4070 return Some(name);
4071 }
4072 }
4073 None
4074 }
4075 walk(stacks, lock)
4076 }
4077
4078 fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
4079 stack
4080 .props
4081 .get(PROP_CUSTOM_NAME)
4082 .cloned()
4083 .or_else(|| stack.display_name.clone())
4084 .unwrap_or_else(|| stack.template_id.clone())
4085 }
4086
4087 fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
4088 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
4089 for s in stacks {
4090 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
4091 return true;
4092 }
4093 if walk(&s.contents, lock) {
4094 return true;
4095 }
4096 }
4097 false
4098 }
4099 walk(stacks, lock)
4100 }
4101
4102 fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
4103 if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
4104 return Some(stack.clone());
4105 }
4106 for worn in self.worn.values() {
4107 if worn.item_instance_id == Some(instance_id) {
4108 return Some(worn.clone());
4109 }
4110 if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
4111 return Some(stack.clone());
4112 }
4113 }
4114 None
4115 }
4116
4117 pub fn location_context_lines(&self) -> Vec<ContextLine> {
4119 let (px, py) = self.player_position();
4120 let inside = self.effective_inside_building();
4121 let mut lines = Vec::new();
4122
4123 if let Some(kind) = self.terrain_at(px, py) {
4124 lines.push(ContextLine {
4125 on_top: true,
4126 text: format!("Terrain: {}", terrain_kind_label(kind)),
4127 });
4128 }
4129
4130 if let Some(id) = inside.as_ref() {
4131 if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
4132 lines.push(ContextLine {
4133 on_top: true,
4134 text: format!("Inside: {}", b.label),
4135 });
4136 }
4137 }
4138
4139 let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
4140
4141 for node in &self.resource_nodes {
4142 let dist = distance(px, py, node.x, node.y);
4143 if dist > NEARBY_SCAN_M {
4144 continue;
4145 }
4146 let on_top = dist <= ON_TOP_RADIUS_M;
4147 let state = match node.state {
4148 flatland_protocol::ResourceNodeState::Available => " — f harvest",
4149 flatland_protocol::ResourceNodeState::Harvesting => " (being harvested)",
4150 flatland_protocol::ResourceNodeState::Cooldown => " (depleted)",
4151 };
4152 let prefix = if on_top { "On" } else { "Near" };
4153 nearby.push((
4154 dist,
4155 ContextLine {
4156 on_top,
4157 text: format!("{prefix}: {} ({dist:.1}m){state}", node.label),
4158 },
4159 ));
4160 }
4161
4162 for drop in &self.ground_drops {
4163 let dist = distance(px, py, drop.x, drop.y);
4164 if dist > INTERACTION_RADIUS_M {
4165 continue;
4166 }
4167 let on_top = dist <= ON_TOP_RADIUS_M;
4168 let name = self.template_display_name(&drop.template_id);
4169 let prefix = if on_top { "On" } else { "Near" };
4170 let qty = if drop.quantity > 1 {
4171 format!(" ×{}", drop.quantity)
4172 } else {
4173 String::new()
4174 };
4175 nearby.push((
4176 dist,
4177 ContextLine {
4178 on_top,
4179 text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
4180 },
4181 ));
4182 }
4183
4184 for c in &self.placed_containers {
4185 let dist = distance(px, py, c.x, c.y);
4186 if dist > CONTAINER_RANGE_M {
4187 continue;
4188 }
4189 let on_top = dist <= ON_TOP_RADIUS_M;
4190 let name = self.placed_container_public_label(c);
4191 let lock = if c.locked { " [locked]" } else { "" };
4192 let prefix = if on_top { "On" } else { "Near" };
4193 nearby.push((
4194 dist,
4195 ContextLine {
4196 on_top,
4197 text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
4198 },
4199 ));
4200 }
4201
4202 for npc in &self.npcs {
4203 let dist = distance(px, py, npc.x, npc.y);
4204 if dist > NEARBY_SCAN_M {
4205 continue;
4206 }
4207 let on_top = dist <= ON_TOP_RADIUS_M;
4208 let prefix = if on_top { "On" } else { "Near" };
4209 nearby.push((
4210 dist,
4211 ContextLine {
4212 on_top,
4213 text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
4214 },
4215 ));
4216 }
4217
4218 for door in &self.doors {
4219 let dist = distance(px, py, door.x, door.y);
4220 if dist > DOOR_INTERACTION_RADIUS_M {
4221 continue;
4222 }
4223 let building = self
4224 .buildings
4225 .iter()
4226 .find(|b| b.id == door.building_id)
4227 .map(|b| b.label.as_str())
4228 .unwrap_or(door.building_id.as_str());
4229 let action = if inside.is_some() && door.portal.is_some() {
4230 "exit"
4231 } else {
4232 "enter"
4233 };
4234 nearby.push((
4235 dist,
4236 ContextLine {
4237 on_top: dist <= ON_TOP_RADIUS_M,
4238 text: format!("{building} door ({dist:.1}m) — f {action}"),
4239 },
4240 ));
4241 }
4242
4243 if inside.is_none() {
4244 for inter in &self.interactables {
4245 if inter.kind != "quest_board" {
4246 continue;
4247 }
4248 let dist = distance(px, py, inter.x, inter.y);
4249 if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
4250 continue;
4251 }
4252 let on_top = dist <= ON_TOP_RADIUS_M;
4253 let prefix = if on_top { "On" } else { "Near" };
4254 let label = if inter.label.is_empty() {
4255 "Quest board".to_string()
4256 } else {
4257 inter.label.clone()
4258 };
4259 nearby.push((
4260 dist,
4261 ContextLine {
4262 on_top,
4263 text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
4264 },
4265 ));
4266 }
4267 }
4268
4269 if self.in_shallow_water() {
4270 let already = self
4271 .terrain_at(px, py)
4272 .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
4273 if !already {
4274 nearby.push((
4275 0.0,
4276 ContextLine {
4277 on_top: true,
4278 text: "Shallow water — f fill bottle".into(),
4279 },
4280 ));
4281 } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
4282 line.text.push_str(" — f fill bottle");
4283 }
4284 }
4285
4286 for entity in &self.entities {
4287 if entity.id == self.entity_id {
4288 continue;
4289 }
4290 let dist = distance(
4291 px,
4292 py,
4293 entity.transform.position.x,
4294 entity.transform.position.y,
4295 );
4296 if dist > NEARBY_SCAN_M {
4297 continue;
4298 }
4299 let label = if entity.label.is_empty() {
4300 format!("entity {}", entity.id)
4301 } else {
4302 entity.label.clone()
4303 };
4304 nearby.push((
4305 dist,
4306 ContextLine {
4307 on_top: dist <= ON_TOP_RADIUS_M,
4308 text: format!("Near: {label} ({dist:.1}m)"),
4309 },
4310 ));
4311 }
4312
4313 nearby.sort_by(|a, b| {
4314 a.0.partial_cmp(&b.0)
4315 .unwrap_or(std::cmp::Ordering::Equal)
4316 .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
4317 });
4318 lines.extend(nearby.into_iter().map(|(_, l)| l));
4319
4320 if lines.is_empty() {
4321 lines.push(ContextLine {
4322 on_top: false,
4323 text: "(nothing notable nearby)".into(),
4324 });
4325 }
4326
4327 lines
4328 }
4329}
4330
4331#[derive(Debug, Clone)]
4333pub struct ContextLine {
4334 pub on_top: bool,
4335 pub text: String,
4336}
4337
4338const ON_TOP_RADIUS_M: f32 = 0.65;
4339const NEARBY_SCAN_M: f32 = 5.0;
4340
4341fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
4342 use flatland_protocol::TerrainKindView;
4343 match kind {
4344 TerrainKindView::Grass => "Grass",
4345 TerrainKindView::Dirt => "Dirt",
4346 TerrainKindView::Tilled => "Tilled",
4347 TerrainKindView::Desert => "Desert",
4348 TerrainKindView::Hill => "Hills",
4349 TerrainKindView::Bog => "Bog",
4350 TerrainKindView::ShallowWater => "Shallow water",
4351 TerrainKindView::DeepWater => "Deep water",
4352 TerrainKindView::Trail => "Trail",
4353 TerrainKindView::Road => "Road",
4354 TerrainKindView::Rock => "Rock",
4355 }
4356}
4357
4358fn humanize_template_id(template_id: &str) -> String {
4359 template_id
4360 .split('_')
4361 .map(|word| {
4362 let mut chars = word.chars();
4363 match chars.next() {
4364 None => String::new(),
4365 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
4366 }
4367 })
4368 .collect::<Vec<_>>()
4369 .join(" ")
4370}
4371
4372const HARVEST_RANGE_M: f32 = 1.5;
4374
4375pub struct GameClient<S: PlayConnection> {
4376 session: S,
4377 seq: Seq,
4378 pub state: GameState,
4379 last_move_forward: f32,
4380 last_move_strafe: f32,
4381}
4382
4383impl<S: PlayConnection> GameClient<S> {
4384 pub fn new(session: S) -> Self {
4385 let session_id = session.session_id();
4386 let entity_id = session.entity_id();
4387 Self {
4388 session,
4389 seq: 0,
4390 last_move_forward: 0.0,
4391 last_move_strafe: 0.0,
4392 state: GameState {
4393 session_id,
4394 entity_id,
4395 character_id: None,
4396 tick: 0,
4397 chunk_rev: 0,
4398 content_rev: 0,
4399 publish_rev: 0,
4400 entities: Vec::new(),
4401 player: None,
4402 resource_nodes: Vec::new(),
4403 ground_drops: Vec::new(),
4404 placed_containers: Vec::new(),
4405 buildings: Vec::new(),
4406 doors: Vec::new(),
4407 interior_map: None,
4408 npcs: Vec::new(),
4409 blueprints: Vec::new(),
4410 world_width_m: 0.0,
4411 world_height_m: 0.0,
4412 terrain_zones: Vec::new(),
4413 z_platforms: Vec::new(),
4414 z_transitions: Vec::new(),
4415 world_clock: flatland_protocol::WorldClock::default(),
4416 inventory: std::collections::HashMap::new(),
4417 inventory_hints: std::collections::HashMap::new(),
4418 logs: VecDeque::new(),
4419 intents_sent: 0,
4420 ticks_received: 0,
4421 connected: false,
4422 disconnect_reason: None,
4423 show_stats: false,
4424 show_equip_menu: false,
4425 equip_menu_index: 0,
4426 show_craft_menu: false,
4427 craft_menu_index: 0,
4428 craft_batch_quantity: 1,
4429 show_shop_menu: false,
4430 shop_catalog: None,
4431 bank_panel: None,
4432 bank_menu_index: 0,
4433 bank_ui_mode: BankUiMode::Menu,
4434 storage_panel: None,
4435 storage_menu_index: 0,
4436 storage_ui_mode: StorageUiMode::Menu,
4437 shop_tab: ShopTab::default(),
4438 shop_menu_index: 0,
4439 shop_quantity: 1,
4440 shop_trade_log: VecDeque::new(),
4441 show_npc_verb_menu: false,
4442 npc_verb_target: None,
4443 npc_verb_index: 0,
4444 show_npc_chat: false,
4445 npc_chat: None,
4446 show_inventory_menu: false,
4447 inventory_menu_index: 0,
4448 inventory_tab: InventoryTab::OnPerson,
4449 inventory_filter: String::new(),
4450 inventory_filter_focused: false,
4451 show_move_picker: false,
4452 show_rename_prompt: false,
4453 show_worker_rename: false,
4454 rename_buffer: String::new(),
4455 move_picker_index: 0,
4456 move_picker: None,
4457 show_grant_picker: false,
4458 grant_picker_index: 0,
4459 grant_picker: None,
4460 show_destroy_picker: false,
4461 destroy_confirm_pending: false,
4462 destroy_picker: None,
4463 combat_target: None,
4464 combat_target_label: None,
4465 combat_fx: Vec::new(),
4466 in_combat: false,
4467 auto_attack: true,
4468 combat_has_los: false,
4469 attack_cd_ticks: 0,
4470 gcd_ticks: 0,
4471 weapon_ability_id: "unarmed".into(),
4472 mainhand_template_id: None,
4473 mainhand_label: None,
4474 offhand_template_id: None,
4475 offhand_label: None,
4476 mainhand_hand_slots: 1,
4477 defense: None,
4478 worn: BTreeMap::new(),
4479 carry_mass: 0.0,
4480 carry_mass_max: 0.0,
4481 encumbrance: flatland_protocol::EncumbranceState::Light,
4482 inventory_stacks: Vec::new(),
4483 keychain_stacks: Vec::new(),
4484 combat_target_detail: None,
4485 statuses: Vec::new(),
4486 cast_progress: None,
4487 ability_cooldowns: Vec::new(),
4488 blocking_active: false,
4489 max_target_slots: 1,
4490 combat_slots: Vec::new(),
4491 rotation_presets: Vec::new(),
4492 known_abilities: Vec::new(),
4493 hotbar: vec![None; 9],
4494 max_abilities_per_rotation: 0,
4495 show_loadout_menu: false,
4496 show_keychain_menu: false,
4497 keychain_menu_index: 0,
4498 show_rotation_editor: false,
4499 loadout_menu_index: 0,
4500 loadout_hotbar_slot: 1,
4501 loadout_ability_index: 0,
4502 loadout_focus_presets: false,
4503 rotation_editor: RotationEditorState::default(),
4504 harvest_in_progress: false,
4505 harvest_started_at: None,
4506 pending_craft_ack: None,
4507 pending_worker_job_ack: None,
4508 quest_log: Vec::new(),
4509 interactables: Vec::new(),
4510 ledger: None,
4511 career: None,
4512 character_sheet_tab: CharacterSheetTab::Character,
4513 ledger_period: LedgerPeriod::Day,
4514 show_quest_offer: false,
4515 pending_quest_offer: None,
4516 show_quest_menu: false,
4517 quest_menu_index: 0,
4518 quest_withdraw_confirm: false,
4519 hired_workers: Vec::new(),
4520 show_workers_menu: false,
4521 workers_menu_index: 0,
4522 workers_menu_compact: false,
4523 worker_step_display: BTreeMap::new(),
4524 worker_error_display: BTreeMap::new(),
4525 show_worker_give_picker: false,
4526 worker_give_picker_index: 0,
4527 worker_give_picker: None,
4528 show_worker_give_target_picker: false,
4529 worker_give_target_picker_index: 0,
4530 worker_give_target_picker: None,
4531 show_worker_take_picker: false,
4532 worker_take_picker_index: 0,
4533 worker_take_picker: None,
4534 show_worker_teach_picker: false,
4535 worker_teach_picker_index: 0,
4536 worker_teach_picker: None,
4537 worker_route_editor: None,
4538 progression_curve: None,
4539 },
4540 }
4541 }
4542
4543 pub fn entity_id(&self) -> EntityId {
4544 self.state.entity_id
4545 }
4546
4547 pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
4548 if self.state.connected {
4549 return Ok(());
4550 }
4551
4552 loop {
4553 match self.session.next_event().await {
4554 Some(SessionEvent::Welcome {
4555 session_id,
4556 entity_id,
4557 snapshot,
4558 }) => {
4559 self.state
4560 .restore_from_welcome(session_id, entity_id, &snapshot);
4561 self.state.push_log(format!(
4562 "Connected — session {session_id}, entity {entity_id}"
4563 ));
4564 return Ok(());
4565 }
4566 Some(SessionEvent::Disconnected { .. }) => {
4567 anyhow::bail!("disconnected before welcome");
4568 }
4569 Some(_) => continue,
4570 None => anyhow::bail!("session closed before welcome"),
4571 }
4572 }
4573 }
4574
4575 pub fn drain_events(&mut self) {
4577 while let Some(event) = self.session.try_next_event() {
4578 if self.handle_event_sync(event).is_err() {
4579 break;
4580 }
4581 }
4582 }
4583
4584 pub async fn next_event(&mut self) -> Option<SessionEvent> {
4586 self.session.next_event().await
4587 }
4588
4589 pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
4590 self.handle_event_sync(event)
4591 }
4592
4593 fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
4594 match event {
4595 SessionEvent::Welcome {
4596 session_id,
4597 entity_id,
4598 snapshot,
4599 } => {
4600 let resumed = self.state.connected;
4601 self.state
4602 .restore_from_welcome(session_id, entity_id, &snapshot);
4603 if resumed {
4604 self.state.push_log(format!(
4605 "Session restored — session {session_id}, entity {entity_id}"
4606 ));
4607 }
4608 }
4609 SessionEvent::ContentUpdated { snapshot } => {
4610 self.state
4611 .apply_snapshot_fields(&snapshot, self.state.entity_id);
4612 self.state.push_log(format!(
4613 "World updated (content rev {})",
4614 snapshot.content_rev
4615 ));
4616 }
4617 SessionEvent::Tick(delta) => {
4618 self.state.apply_tick_fields(&delta, self.state.entity_id);
4619 self.state.ticks_received += 1;
4620 }
4621 SessionEvent::IntentAck {
4622 entity_id,
4623 seq,
4624 tick,
4625 } => {
4626 crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
4627 if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
4628 if *craft_seq == seq {
4629 let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
4630 if batches > 1 {
4631 self.state.push_log(format!("Crafting {label} ×{batches}…"));
4632 } else {
4633 self.state.push_log(format!("Crafting {label}…"));
4634 }
4635 }
4636 }
4637 if self
4638 .state
4639 .pending_worker_job_ack
4640 .as_ref()
4641 .is_some_and(|p| p.seq == seq)
4642 {
4643 let pending = self.state.pending_worker_job_ack.take().unwrap();
4644 if pending.idle {
4645 self.state.push_log(format!(
4646 "Route cleared for {} — worker idle",
4647 pending.worker_label
4648 ));
4649 } else {
4650 self.state.push_log(format!(
4651 "Route saved for {} — {} stop(s), job loop active",
4652 pending.worker_label, pending.stop_count
4653 ));
4654 }
4655 if self
4656 .state
4657 .worker_route_editor
4658 .as_ref()
4659 .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
4660 {
4661 self.close_worker_route_editor();
4662 }
4663 }
4664 }
4665 SessionEvent::Chat(msg) => {
4666 let label = match msg.channel {
4667 flatland_protocol::ChatChannel::Nearby => "nearby",
4668 flatland_protocol::ChatChannel::WhisperStone => "whisper",
4669 };
4670 self.state
4671 .push_log(format!("[{label}] {}: {}", msg.from_name, msg.text));
4672 }
4673 SessionEvent::HarvestResult(result) => {
4674 self.state.clear_harvest_state();
4675 crate::harvest_trace!(
4676 entity_id = self.state.entity_id,
4677 node_id = %result.node_id,
4678 template = %result.item_template,
4679 quantity = result.quantity,
4680 client_tick = self.state.tick,
4681 "client applied harvest result"
4682 );
4683 self.state.push_log(format!(
4684 "Harvested {} x{} (on the ground — press P to pick up)",
4685 result.item_template, result.quantity
4686 ));
4687 }
4688 SessionEvent::CraftResult(result) => {
4689 for stack in &result.consumed {
4690 if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
4691 *qty = qty.saturating_sub(stack.quantity);
4692 if *qty == 0 {
4693 self.state.inventory.remove(&stack.template_id);
4694 }
4695 }
4696 }
4697 for stack in &result.outputs {
4698 *self
4699 .state
4700 .inventory
4701 .entry(stack.template_id.clone())
4702 .or_insert(0) += stack.quantity;
4703 }
4704 if let Some(output) = result.outputs.first() {
4705 if result.batch_total > 1 {
4706 self.state.push_log(format!(
4707 "Crafted {} x{} ({}/{})",
4708 output.template_id,
4709 output.quantity,
4710 result.batch_index,
4711 result.batch_total
4712 ));
4713 } else {
4714 self.state.push_log(format!(
4715 "Crafted {} x{}",
4716 output.template_id, output.quantity
4717 ));
4718 }
4719 } else {
4720 self.state
4721 .push_log(format!("Craft finished: {}", result.blueprint_id));
4722 }
4723 }
4724 SessionEvent::Death(notice) => {
4725 self.state.clear_harvest_state();
4726 self.state.push_log(notice.message.clone());
4727 self.state.push_log(format!(
4728 "Respawned at ({:.1}, {:.1})",
4729 notice.respawn_x, notice.respawn_y
4730 ));
4731 }
4732 SessionEvent::Interaction(notice) => {
4733 if notice.message.starts_with("Harvest failed:") {
4734 self.state.clear_harvest_state();
4735 }
4736 if notice.message.starts_with("Can't do that:") {
4737 self.state.pending_craft_ack = None;
4738 if let Some(pending) = self.state.pending_worker_job_ack.take() {
4739 if let Some(w) = self
4740 .state
4741 .hired_workers
4742 .iter_mut()
4743 .find(|w| w.instance_id == pending.worker_instance_id)
4744 {
4745 w.route = pending.prev_route;
4746 w.mode = pending.prev_mode;
4747 w.step_label = pending.prev_step_label;
4748 w.last_error = pending.prev_last_error;
4749 }
4750 let reason = notice
4751 .message
4752 .strip_prefix("Can't do that:")
4753 .unwrap_or(¬ice.message)
4754 .trim();
4755 self.state.push_log(format!(
4756 "Route save failed for {}: {reason}",
4757 pending.worker_label
4758 ));
4759 }
4760 }
4761 if notice.message.starts_with("Cast failed:") {
4762 self.state.cast_progress = None;
4763 }
4764 if notice.message.contains("slain the") {
4765 self.state.combat_target = None;
4766 self.state.combat_target_label = None;
4767 }
4768 self.state.apply_interaction_notice(¬ice);
4769 self.state.push_log(notice.message.clone());
4770 }
4771 SessionEvent::ShopOpened(catalog) => {
4772 self.state.apply_shop_catalog(catalog);
4773 }
4774 SessionEvent::BankOpened(panel) => {
4775 self.state.apply_bank_panel(panel);
4776 }
4777 SessionEvent::StorageOpened(panel) => {
4778 self.state.apply_storage_panel(panel);
4779 }
4780 SessionEvent::NpcTalkOpened(opened) => {
4781 self.state.show_npc_verb_menu = false;
4782 if self.state.npc_verb_target.is_none() {
4783 self.state.npc_verb_target = Some(opened.npc_id.clone());
4784 }
4785 let label = opened.npc_label.clone();
4786 let banner = if !opened.trade_allowed {
4787 Some("Trade is unavailable right now.".to_string())
4788 } else {
4789 None
4790 };
4791 self.state.show_npc_chat = true;
4792 self.state.npc_chat = Some(NpcChatState {
4793 npc_id: opened.npc_id,
4794 npc_label: opened.npc_label,
4795 lines: if opened.greeting.is_empty() {
4796 vec![]
4797 } else {
4798 vec![format!("{label}: {}", opened.greeting)]
4799 },
4800 input: String::new(),
4801 pending: opened.greeting.is_empty(),
4802 talk_depth: opened.talk_depth,
4803 trade_allowed: opened.trade_allowed,
4804 banner,
4805 });
4806 }
4807 SessionEvent::NpcTalkPending(_) => {
4808 if let Some(chat) = self.state.npc_chat.as_mut() {
4809 chat.pending = true;
4810 }
4811 }
4812 SessionEvent::NpcTalkReply(reply) => {
4813 if let Some(chat) = self.state.npc_chat.as_mut() {
4814 if chat.npc_id == reply.npc_id {
4815 chat.pending = false;
4816 if reply.trade_disabled {
4817 chat.trade_allowed = false;
4818 chat.banner = Some("Trade is unavailable right now.".to_string());
4819 }
4820 if reply.wind_down {
4821 chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
4822 if chat.banner.is_none() {
4823 chat.banner =
4824 Some("They're wrapping up — keep it brief.".to_string());
4825 }
4826 }
4827 chat.lines
4828 .push(format!("{}: {}", chat.npc_label, reply.line));
4829 }
4830 }
4831 }
4832 SessionEvent::NpcTalkClosed(closed) => {
4833 if self
4834 .state
4835 .npc_chat
4836 .as_ref()
4837 .is_some_and(|c| c.npc_id == closed.npc_id)
4838 {
4839 self.state.show_npc_chat = false;
4840 self.state.npc_chat = None;
4841 }
4842 }
4843 SessionEvent::NpcTalkError(err) => {
4844 self.state.push_log(format!("Talk failed: {}", err.reason));
4845 if let Some(chat) = self.state.npc_chat.as_mut() {
4846 chat.pending = false;
4847 }
4848 }
4849 SessionEvent::UseResult(result) => {
4850 if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
4853 *qty = qty.saturating_sub(1);
4854 if *qty == 0 {
4855 self.state.inventory.remove(&result.template_id);
4856 }
4857 }
4858 }
4859 SessionEvent::QuestOffer(offer) => {
4860 self.state.pending_quest_offer = Some(offer.clone());
4861 self.state.show_quest_offer = true;
4862 self.state
4863 .push_log(format!("Quest offered: {}", offer.title));
4864 }
4865 SessionEvent::QuestAccepted(notice) => {
4866 self.state.show_quest_offer = false;
4867 self.state.pending_quest_offer = None;
4868 self.state.push_log(notice.message);
4869 }
4870 SessionEvent::QuestWithdrawn(notice) => {
4871 self.state.show_quest_menu = false;
4872 self.state.quest_withdraw_confirm = false;
4873 self.state.push_log(notice.message);
4874 }
4875 SessionEvent::QuestStepCompleted(notice) => {
4876 self.state.push_log(notice.message);
4877 }
4878 SessionEvent::QuestCompleted(notice) => {
4879 self.state.push_log(notice.message);
4880 }
4881 SessionEvent::Disconnected { reason } => {
4882 self.state.clear_harvest_state();
4883 self.state.connected = false;
4884 self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
4885 if let Some(r) = &self.state.disconnect_reason {
4886 self.state.push_log(format!("Disconnected: {r}"));
4887 } else {
4888 self.state.push_log("Disconnected from server");
4889 }
4890 }
4891 }
4892 Ok(())
4893 }
4894
4895 pub fn is_connected(&self) -> bool {
4896 self.state.connected
4897 }
4898
4899 pub fn close_overlays(&mut self) {
4900 self.state.show_stats = false;
4901 self.state.show_craft_menu = false;
4902 self.state.show_shop_menu = false;
4903 self.state.shop_catalog = None;
4904 self.state.show_npc_verb_menu = false;
4905 self.state.npc_verb_target = None;
4906 self.state.show_npc_chat = false;
4907 self.state.npc_chat = None;
4908 self.state.show_inventory_menu = false;
4909 self.state.show_loadout_menu = false;
4910 self.state.show_rotation_editor = false;
4911 self.state.rotation_editor.reset();
4912 self.state.show_rename_prompt = false;
4913 self.state.show_worker_rename = false;
4914 self.state.rename_buffer.clear();
4915 self.state.show_move_picker = false;
4916 self.state.move_picker = None;
4917 self.state.show_destroy_picker = false;
4918 self.state.destroy_confirm_pending = false;
4919 self.state.destroy_picker = None;
4920 self.state.show_quest_offer = false;
4921 self.state.pending_quest_offer = None;
4922 self.state.show_quest_menu = false;
4923 self.state.quest_withdraw_confirm = false;
4924 self.state.show_workers_menu = false;
4925 self.close_worker_give_picker();
4926 self.close_worker_give_target_picker();
4927 self.close_worker_take_picker();
4928 self.close_worker_teach_picker();
4929 self.state.worker_route_editor = None;
4930 }
4931
4932 pub fn back_on_esc(&mut self) -> bool {
4934 if self.state.show_rename_prompt {
4935 self.cancel_rename_prompt();
4936 return true;
4937 }
4938 if self.state.show_worker_rename {
4939 self.cancel_worker_rename();
4940 return true;
4941 }
4942 if self.state.show_destroy_picker {
4943 if self.state.destroy_confirm_pending {
4944 self.cancel_destroy_confirm();
4945 } else {
4946 self.close_destroy_picker();
4947 }
4948 return true;
4949 }
4950 if self.state.show_move_picker {
4951 self.close_move_picker();
4952 return true;
4953 }
4954 if self.state.show_rotation_editor {
4955 match self.state.rotation_editor.mode {
4956 RotationEditorMode::List => {
4957 self.state.show_rotation_editor = false;
4958 self.state.rotation_editor.reset();
4959 }
4960 RotationEditorMode::EditLabel => {
4961 self.state.rotation_editor.label_buffer.clear();
4962 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
4963 }
4964 RotationEditorMode::PickAbility => {
4965 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
4966 }
4967 RotationEditorMode::EditSequence => {
4968 self.state.rotation_editor.draft = None;
4969 self.state.rotation_editor.mode = RotationEditorMode::List;
4970 }
4971 }
4972 return true;
4973 }
4974 if self.state.show_inventory_menu {
4975 self.close_inventory_menu();
4976 return true;
4977 }
4978 if self.state.show_craft_menu {
4979 self.close_craft_menu();
4980 return true;
4981 }
4982 if self.state.show_quest_offer {
4983 self.quest_offer_decline();
4984 return true;
4985 }
4986 if self.state.show_shop_menu {
4987 return false;
4989 }
4990 if self.state.bank_panel.is_some() {
4991 return false;
4992 }
4993 if self.state.storage_panel.is_some() {
4994 return false;
4995 }
4996 if self.state.show_npc_chat {
4997 return false;
4999 }
5000 if self.state.show_npc_verb_menu {
5001 self.state.show_npc_verb_menu = false;
5002 self.state.npc_verb_target = None;
5003 return true;
5004 }
5005 if self.state.show_quest_menu {
5006 if self.state.quest_withdraw_confirm {
5007 self.state.quest_withdraw_confirm = false;
5008 } else {
5009 self.state.show_quest_menu = false;
5010 }
5011 return true;
5012 }
5013 if self.state.worker_route_editor.is_some() {
5014 if self.re_at_root_sheet() {
5016 self.close_worker_route_editor();
5017 } else {
5018 self.re_sheet_back();
5019 }
5020 return true;
5021 }
5022 if self.state.show_worker_give_picker {
5023 self.close_worker_give_picker();
5024 return true;
5025 }
5026 if self.state.show_worker_give_target_picker {
5027 self.close_worker_give_target_picker();
5028 return true;
5029 }
5030 if self.state.show_worker_take_picker {
5031 self.close_worker_take_picker();
5032 return true;
5033 }
5034 if self.state.show_worker_teach_picker {
5035 self.close_worker_teach_picker();
5036 return true;
5037 }
5038 if self.state.show_workers_menu {
5039 self.state.show_workers_menu = false;
5040 return true;
5041 }
5042 if self.state.show_loadout_menu {
5043 self.state.show_loadout_menu = false;
5044 return true;
5045 }
5046 if self.state.show_stats {
5047 self.state.show_stats = false;
5048 return true;
5049 }
5050 if self.state.show_equip_menu {
5051 self.state.show_equip_menu = false;
5052 return true;
5053 }
5054 false
5055 }
5056
5057 pub fn toggle_stats(&mut self) {
5058 self.state.show_stats = !self.state.show_stats;
5059 if self.state.show_stats {
5060 self.state.character_sheet_tab = CharacterSheetTab::Character;
5061 self.state.show_craft_menu = false;
5062 self.state.show_shop_menu = false;
5063 self.state.shop_catalog = None;
5064 self.state.show_inventory_menu = false;
5065 self.state.show_equip_menu = false;
5066 }
5067 }
5068
5069 pub fn toggle_equip_menu(&mut self) {
5070 self.state.show_equip_menu = !self.state.show_equip_menu;
5071 if self.state.show_equip_menu {
5072 self.state.show_stats = false;
5073 self.state.show_craft_menu = false;
5074 self.state.show_shop_menu = false;
5075 self.state.shop_catalog = None;
5076 self.state.show_inventory_menu = false;
5077 self.state.show_loadout_menu = false;
5078 }
5079 }
5080
5081 pub fn cycle_character_sheet_tab(&mut self) {
5082 if self.state.show_stats {
5083 self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
5084 }
5085 }
5086
5087 pub fn set_ledger_period_digit(&mut self, c: char) {
5088 if self.state.show_stats {
5089 if let Some(p) = LedgerPeriod::from_digit(c) {
5090 self.state.ledger_period = p;
5091 self.state.character_sheet_tab = CharacterSheetTab::Ledger;
5092 }
5093 }
5094 }
5095
5096 pub fn cycle_ledger_period(&mut self) {
5097 if self.state.show_stats
5098 && self.state.character_sheet_tab == CharacterSheetTab::Ledger
5099 {
5100 self.state.ledger_period = self.state.ledger_period.cycle();
5101 }
5102 }
5103
5104 pub fn open_inventory_menu(&mut self) {
5105 self.state.show_inventory_menu = true;
5106 self.state.show_craft_menu = false;
5107 self.state.show_shop_menu = false;
5108 self.state.shop_catalog = None;
5109 self.state.show_stats = false;
5110 self.state.show_move_picker = false;
5111 self.state.move_picker = None;
5112 self.state.show_destroy_picker = false;
5113 self.state.destroy_confirm_pending = false;
5114 self.state.destroy_picker = None;
5115 self.state.show_rename_prompt = false;
5116 self.state.rename_buffer.clear();
5117 self.state.inventory_filter_focused = false;
5118 self.state.clamp_inventory_indices();
5119 }
5120
5121 pub fn close_inventory_menu(&mut self) {
5122 self.state.show_inventory_menu = false;
5123 self.state.show_move_picker = false;
5124 self.state.move_picker = None;
5125 self.close_grant_picker();
5126 self.state.show_destroy_picker = false;
5127 self.state.destroy_confirm_pending = false;
5128 self.state.destroy_picker = None;
5129 self.state.show_rename_prompt = false;
5130 self.state.rename_buffer.clear();
5131 self.state.inventory_filter_focused = false;
5132 }
5133
5134 pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
5135 let Some(row) = self.state.inventory_selected_row() else {
5136 anyhow::bail!("inventory empty");
5137 };
5138 if !self.state.row_is_renameable_container(&row) {
5139 anyhow::bail!("only storage containers can be renamed");
5140 }
5141 let current = row
5142 .stack
5143 .display_name
5144 .clone()
5145 .unwrap_or_else(|| row.stack.template_id.clone());
5146 self.state.rename_buffer = current;
5147 self.state.show_rename_prompt = true;
5148 self.state.show_worker_rename = false;
5149 self.state.show_move_picker = false;
5150 self.state.show_destroy_picker = false;
5151 self.state.destroy_confirm_pending = false;
5152 Ok(())
5153 }
5154
5155 pub fn cancel_rename_prompt(&mut self) {
5156 self.state.show_rename_prompt = false;
5157 self.state.rename_buffer.clear();
5158 }
5159
5160 pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
5161 let name = self.state.rename_buffer.trim().to_string();
5162 if name.is_empty() {
5163 anyhow::bail!("name cannot be empty");
5164 }
5165 let Some(row) = self.state.inventory_selected_row() else {
5166 anyhow::bail!("inventory empty");
5167 };
5168 let Some(instance_id) = row.stack.item_instance_id else {
5169 anyhow::bail!("item has no instance id");
5170 };
5171 self.seq += 1;
5172 self.session
5173 .submit_intent(Intent::RenameContainer {
5174 entity_id: self.state.entity_id,
5175 item_instance_id: instance_id,
5176 location: row.from.clone(),
5177 name,
5178 seq: self.seq,
5179 })
5180 .await?;
5181 self.state.intents_sent += 1;
5182 self.state.show_rename_prompt = false;
5183 self.state.rename_buffer.clear();
5184 Ok(())
5185 }
5186
5187 pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
5188 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
5189 anyhow::bail!("no worker selected");
5190 };
5191 self.state.rename_buffer = worker.label.clone();
5192 self.state.show_worker_rename = true;
5193 self.state.show_rename_prompt = false;
5194 Ok(())
5195 }
5196
5197 pub fn cancel_worker_rename(&mut self) {
5198 self.state.show_worker_rename = false;
5199 self.state.rename_buffer.clear();
5200 }
5201
5202 pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
5203 let name = self.state.rename_buffer.trim().to_string();
5204 if name.is_empty() {
5205 anyhow::bail!("name cannot be empty");
5206 }
5207 if name.chars().count() > 32 {
5208 anyhow::bail!("name must be 1–32 characters");
5209 }
5210 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
5211 anyhow::bail!("no worker selected");
5212 };
5213 let worker_instance_id = worker.instance_id.clone();
5214 self.seq += 1;
5215 self.session
5216 .submit_intent(Intent::RenameHiredWorker {
5217 entity_id: self.state.entity_id,
5218 worker_instance_id: worker_instance_id.clone(),
5219 name: name.clone(),
5220 seq: self.seq,
5221 })
5222 .await?;
5223 self.state.intents_sent += 1;
5224 if let Some(w) = self
5225 .state
5226 .hired_workers
5227 .iter_mut()
5228 .find(|w| w.instance_id == worker_instance_id)
5229 {
5230 w.label = name.clone();
5231 }
5232 if let Some(ed) = self.state.worker_route_editor.as_mut() {
5233 if ed.worker_instance_id == worker_instance_id {
5234 ed.worker_label = name.clone();
5235 }
5236 }
5237 self.state.show_worker_rename = false;
5238 self.state.rename_buffer.clear();
5239 self.state.push_log(format!("Renamed worker to \"{name}\""));
5240 Ok(())
5241 }
5242
5243 pub fn toggle_inventory_menu(&mut self) {
5244 if self.state.show_inventory_menu {
5245 self.close_inventory_menu();
5246 } else {
5247 self.open_inventory_menu();
5248 }
5249 }
5250
5251 pub fn inventory_menu_move(&mut self, delta: i32) {
5253 if self.state.show_grant_picker {
5254 let Some(picker) = self.state.grant_picker.as_ref() else {
5255 return;
5256 };
5257 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
5258 let filter = picker.filter.clone();
5259 let n = labels.len();
5260 if n == 0 {
5261 return;
5262 }
5263 self.state.grant_picker_index = step_filtered_index(
5264 self.state.grant_picker_index,
5265 delta,
5266 n,
5267 |i| list_label_matches(&labels[i], &filter),
5268 );
5269 return;
5270 }
5271 if self.state.show_move_picker {
5272 let Some(picker) = self.state.move_picker.as_ref() else {
5273 return;
5274 };
5275 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
5276 let filter = picker.filter.clone();
5277 let n = labels.len();
5278 if n == 0 {
5279 return;
5280 }
5281 self.state.move_picker_index = step_filtered_index(
5282 self.state.move_picker_index,
5283 delta,
5284 n,
5285 |i| list_label_matches(&labels[i], &filter),
5286 );
5287 self.state.clamp_move_picker_quantity();
5288 return;
5289 }
5290 let n = self.state.inventory_selectable_rows().len();
5291 if n == 0 {
5292 return;
5293 }
5294 let idx = self.state.inventory_menu_index as i32;
5295 self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
5296 }
5297
5298 pub fn inventory_menu_page(&mut self, pages: i32) {
5300 if self.state.show_grant_picker {
5301 let Some(picker) = self.state.grant_picker.as_ref() else {
5302 return;
5303 };
5304 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
5305 let filter = picker.filter.clone();
5306 let n = labels.len();
5307 self.state.grant_picker_index = page_filtered_index(
5308 self.state.grant_picker_index,
5309 pages,
5310 n,
5311 |i| list_label_matches(&labels[i], &filter),
5312 );
5313 return;
5314 }
5315 if self.state.show_move_picker {
5316 let Some(picker) = self.state.move_picker.as_ref() else {
5317 return;
5318 };
5319 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
5320 let filter = picker.filter.clone();
5321 let n = labels.len();
5322 self.state.move_picker_index = page_filtered_index(
5323 self.state.move_picker_index,
5324 pages,
5325 n,
5326 |i| list_label_matches(&labels[i], &filter),
5327 );
5328 self.state.clamp_move_picker_quantity();
5329 return;
5330 }
5331 let n = self.state.inventory_selectable_rows().len();
5332 self.state.inventory_menu_index =
5333 page_list_index(self.state.inventory_menu_index, pages, n);
5334 }
5335
5336 pub fn cycle_inventory_tab(&mut self, forward: bool) {
5337 if self.state.show_move_picker
5338 || self.state.show_grant_picker
5339 || self.state.show_destroy_picker
5340 || self.state.show_rename_prompt
5341 || self.state.inventory_filter_focused
5342 {
5343 return;
5344 }
5345 self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
5346 self.state.inventory_menu_index = 0;
5347 self.state.clamp_inventory_indices();
5348 }
5349
5350 pub fn focus_inventory_filter(&mut self) {
5351 if self.state.show_grant_picker {
5352 if let Some(p) = self.state.grant_picker.as_mut() {
5353 p.filter_focused = true;
5354 }
5355 return;
5356 }
5357 if self.state.show_move_picker {
5358 if let Some(p) = self.state.move_picker.as_mut() {
5359 p.filter_focused = true;
5360 }
5361 return;
5362 }
5363 self.state.inventory_filter_focused = true;
5364 }
5365
5366 pub fn set_inventory_filter(&mut self, filter: String) {
5367 self.state.inventory_filter = filter;
5368 self.state.inventory_menu_index = 0;
5369 self.state.clamp_inventory_indices();
5370 }
5371
5372 pub fn append_inventory_filter_char(&mut self, ch: char) {
5373 if ch.is_control() {
5374 return;
5375 }
5376 if self.state.show_grant_picker {
5377 if let Some(p) = self.state.grant_picker.as_mut() {
5378 if p.filter_focused {
5379 p.filter.push(ch);
5380 self.state.grant_picker_index = 0;
5381 }
5382 }
5383 return;
5384 }
5385 if self.state.show_move_picker {
5386 if let Some(p) = self.state.move_picker.as_mut() {
5387 if p.filter_focused {
5388 p.filter.push(ch);
5389 self.state.move_picker_index = 0;
5390 self.state.clamp_move_picker_quantity();
5391 }
5392 }
5393 return;
5394 }
5395 if !self.state.inventory_filter_focused {
5396 return;
5397 }
5398 self.state.inventory_filter.push(ch);
5399 self.state.inventory_menu_index = 0;
5400 self.state.clamp_inventory_indices();
5401 }
5402
5403 pub fn inventory_filter_backspace(&mut self) {
5404 if self.state.show_grant_picker {
5405 if let Some(p) = self.state.grant_picker.as_mut() {
5406 if p.filter_focused {
5407 p.filter.pop();
5408 self.state.grant_picker_index = 0;
5409 }
5410 }
5411 return;
5412 }
5413 if self.state.show_move_picker {
5414 if let Some(p) = self.state.move_picker.as_mut() {
5415 if p.filter_focused {
5416 p.filter.pop();
5417 self.state.move_picker_index = 0;
5418 self.state.clamp_move_picker_quantity();
5419 }
5420 }
5421 return;
5422 }
5423 if !self.state.inventory_filter_focused {
5424 return;
5425 }
5426 self.state.inventory_filter.pop();
5427 self.state.inventory_menu_index = 0;
5428 self.state.clamp_inventory_indices();
5429 }
5430
5431 pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
5433 if self.state.show_grant_picker {
5434 if let Some(p) = self.state.grant_picker.as_mut() {
5435 if p.filter_focused {
5436 if !p.filter.is_empty() {
5437 p.filter.clear();
5438 self.state.grant_picker_index = 0;
5439 } else {
5440 p.filter_focused = false;
5441 }
5442 return true;
5443 }
5444 if !p.filter.is_empty() {
5445 p.filter.clear();
5446 self.state.grant_picker_index = 0;
5447 return true;
5448 }
5449 }
5450 return false;
5451 }
5452 if self.state.show_move_picker {
5453 if let Some(p) = self.state.move_picker.as_mut() {
5454 if p.filter_focused {
5455 if !p.filter.is_empty() {
5456 p.filter.clear();
5457 self.state.move_picker_index = 0;
5458 self.state.clamp_move_picker_quantity();
5459 } else {
5460 p.filter_focused = false;
5461 }
5462 return true;
5463 }
5464 if !p.filter.is_empty() {
5465 p.filter.clear();
5466 self.state.move_picker_index = 0;
5467 self.state.clamp_move_picker_quantity();
5468 return true;
5469 }
5470 }
5471 return false;
5472 }
5473 if self.state.inventory_filter_focused {
5474 if !self.state.inventory_filter.is_empty() {
5475 self.state.inventory_filter.clear();
5476 self.state.inventory_menu_index = 0;
5477 self.state.clamp_inventory_indices();
5478 } else {
5479 self.state.inventory_filter_focused = false;
5480 }
5481 return true;
5482 }
5483 if !self.state.inventory_filter.is_empty() {
5484 self.state.inventory_filter.clear();
5485 self.state.inventory_menu_index = 0;
5486 self.state.clamp_inventory_indices();
5487 return true;
5488 }
5489 false
5490 }
5491
5492 pub fn craft_menu_page(&mut self, pages: i32) {
5493 let n = self.state.blueprints.len();
5494 self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
5495 self.state.clamp_craft_batch_quantity();
5496 }
5497
5498 pub fn shop_menu_page(&mut self, pages: i32) {
5499 let n = self.state.shop_list_len();
5500 self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
5501 self.state.clamp_shop_quantity();
5502 }
5503
5504 pub fn workers_menu_page(&mut self, pages: i32) {
5505 let n = self.state.hired_workers.len();
5506 self.state.workers_menu_index =
5507 page_list_index(self.state.workers_menu_index, pages, n);
5508 }
5509
5510 pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
5515 if self.state.show_destroy_picker {
5516 if self.state.destroy_confirm_pending {
5517 return self.confirm_destroy_item().await;
5518 }
5519 return self.request_destroy_confirm();
5520 }
5521 if self.state.show_grant_picker {
5522 return self.confirm_grant_picker().await;
5523 }
5524 if self.state.show_move_picker {
5525 return self.confirm_move_picker().await;
5526 }
5527 let Some(row) = self.state.inventory_selected_row() else {
5528 anyhow::bail!("inventory empty");
5529 };
5530 if row.is_equip_shell {
5531 let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
5532 anyhow::bail!("not a worn item");
5533 };
5534 return self.equip_worn(slot, None).await;
5535 }
5536 if row.is_chest_shell {
5537 return self.open_chest_pickup_picker();
5538 }
5539 let template_id = row.stack.template_id.clone();
5540 let instance_id = row.stack.item_instance_id;
5541 let category = self.state.inventory_item_category(&template_id);
5542 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
5543
5544 if category == Some("weapon") {
5545 return self.equip_mainhand(Some(template_id)).await;
5546 }
5547 if category == Some("lodging") && on_person {
5548 if let Some(inst) = instance_id {
5549 return self.place_container(inst).await;
5550 }
5551 }
5552 if (category == Some("container") || category == Some("armor")) && on_person {
5553 if let Some(inst) = instance_id {
5554 let world_placeable = row.stack.world_placeable == Some(true)
5555 || template_id.contains("chest");
5556 if world_placeable {
5557 return self.place_container(inst).await;
5558 }
5559 if let Some(slot) = guess_body_slot(&template_id) {
5563 return self.equip_worn(slot, Some(inst)).await;
5564 }
5565 }
5566 }
5567 self.open_move_picker()
5571 }
5572
5573 pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
5575 let Some(row) = self.state.inventory_selected_row() else {
5576 anyhow::bail!("inventory empty");
5577 };
5578 if row.from != flatland_protocol::InventoryLocation::Root {
5579 anyhow::bail!("select a consumable on your person");
5580 }
5581 if GameState::stack_is_item_grant(&row.stack) {
5582 return self.open_grant_target_picker();
5583 }
5584 let category = self
5585 .state
5586 .inventory_item_category(&row.stack.template_id);
5587 if category != Some("consumable") {
5588 anyhow::bail!("selected item is not consumable");
5589 }
5590 self.use_item(&row.stack.template_id).await
5591 }
5592
5593 pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
5595 let Some(row) = self.state.inventory_selected_row() else {
5596 anyhow::bail!("inventory empty");
5597 };
5598 if row.from != flatland_protocol::InventoryLocation::Root {
5599 anyhow::bail!("select a grant item on your person");
5600 }
5601 if !GameState::stack_is_item_grant(&row.stack) {
5602 anyhow::bail!("selected item does not grant onto gear");
5603 }
5604 let Some(grant_instance_id) = row.stack.item_instance_id else {
5605 anyhow::bail!("grant has no instance id");
5606 };
5607 let effect_id = GameState::grant_effect_id(&row.stack)
5608 .unwrap_or("?")
5609 .to_string();
5610 let mode = GameState::grant_mode(&row.stack).to_string();
5611 let options = self.state.grant_target_options(&row.stack);
5612 if options.is_empty() {
5613 anyhow::bail!("no valid gear to apply {effect_id} to");
5614 }
5615 let grant_label = row
5616 .stack
5617 .display_name
5618 .clone()
5619 .unwrap_or_else(|| row.stack.template_id.clone());
5620 self.state.show_grant_picker = true;
5621 self.state.grant_picker_index = 0;
5622 self.state.grant_picker = Some(GrantTargetPicker {
5623 grant_instance_id,
5624 grant_label,
5625 effect_id,
5626 mode,
5627 options,
5628 filter: String::new(),
5629 filter_focused: false,
5630 });
5631 Ok(())
5632 }
5633
5634 pub fn close_grant_picker(&mut self) {
5635 self.state.show_grant_picker = false;
5636 self.state.grant_picker = None;
5637 self.state.grant_picker_index = 0;
5638 }
5639
5640 pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
5641 let Some(picker) = self.state.grant_picker.clone() else {
5642 self.close_grant_picker();
5643 return Ok(());
5644 };
5645 let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
5646 self.close_grant_picker();
5647 return Ok(());
5648 };
5649 self.close_grant_picker();
5650 self.use_grant(picker.grant_instance_id, opt.target_instance_id)
5651 .await?;
5652 self.state.push_log(format!(
5653 "Applying {} onto {}…",
5654 picker.effect_id, opt.label
5655 ));
5656 Ok(())
5657 }
5658
5659 pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
5663 let Some(row) = self.state.inventory_selected_row() else {
5664 anyhow::bail!("inventory empty");
5665 };
5666 if row.is_equip_shell {
5667 anyhow::bail!("this is a worn bag — press Enter to unequip it");
5668 }
5669 if row.is_chest_shell {
5670 return self.open_chest_pickup_picker();
5671 }
5672 let Some(instance_id) = row.stack.item_instance_id else {
5673 anyhow::bail!("item has no instance id");
5674 };
5675 let mut options = self.state.move_destinations_for(
5676 &row.from,
5677 row.from_parent_instance_id,
5678 row.stack.item_instance_id,
5679 &row.stack.template_id,
5680 );
5681 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
5682 let category = self.state.inventory_item_category(&row.stack.template_id);
5683 if on_person && category == Some("consumable") {
5684 if GameState::stack_is_item_grant(&row.stack) {
5685 options.insert(
5686 0,
5687 MoveOption {
5688 label: "Apply onto gear…".into(),
5689 kind: MoveOptionKind::GrantApply,
5690 },
5691 );
5692 } else {
5693 options.insert(
5694 0,
5695 MoveOption {
5696 label: "Use (eat / drink)".into(),
5697 kind: MoveOptionKind::Use,
5698 },
5699 );
5700 }
5701 }
5702 let item_label = row
5703 .stack
5704 .display_name
5705 .clone()
5706 .unwrap_or_else(|| row.stack.template_id.clone());
5707 let initial_qty = if row.stack.quantity > 1 { 1 } else { row.stack.quantity };
5710 self.state.move_picker = Some(MovePicker {
5711 item_instance_id: instance_id,
5712 from: row.from,
5713 item_label,
5714 template_id: row.stack.template_id.clone(),
5715 stack_quantity: row.stack.quantity,
5716 quantity: initial_qty.max(1),
5717 options,
5718 filter: String::new(),
5719 filter_focused: false,
5720 });
5721 self.state.move_picker_index = 0;
5722 self.state.show_move_picker = true;
5723 self.state.show_destroy_picker = false;
5724 self.state.destroy_confirm_pending = false;
5725 self.state.destroy_picker = None;
5726 self.state.clamp_move_picker_quantity();
5727 Ok(())
5728 }
5729
5730 pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
5732 let Some(row) = self.state.inventory_selected_row() else {
5733 anyhow::bail!("inventory empty");
5734 };
5735 if !row.is_chest_shell {
5736 anyhow::bail!("not a placed chest");
5737 }
5738 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
5739 anyhow::bail!("not a placed chest");
5740 };
5741 let Some(instance_id) = row.stack.item_instance_id else {
5742 anyhow::bail!("chest has no instance id");
5743 };
5744 let chest = self
5745 .state
5746 .placed_containers
5747 .iter()
5748 .find(|c| c.id == *container_id)
5749 .cloned()
5750 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
5751 let (px, py) = self.state.player_position();
5752 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
5753 anyhow::bail!("too far from {}", chest.display_name);
5754 }
5755 if chest.locked && !chest.accessible {
5756 anyhow::bail!(
5757 "need the matching key for {} before picking it up",
5758 chest.display_name
5759 );
5760 }
5761 let options = self.state.chest_pickup_destinations(container_id);
5762 let item_label = row
5763 .stack
5764 .display_name
5765 .clone()
5766 .unwrap_or_else(|| row.stack.template_id.clone());
5767 self.state.move_picker = Some(MovePicker {
5768 item_instance_id: instance_id,
5769 from: row.from.clone(),
5770 item_label,
5771 template_id: row.stack.template_id.clone(),
5772 stack_quantity: 1,
5773 quantity: 1,
5774 options,
5775 filter: String::new(),
5776 filter_focused: false,
5777 });
5778 self.state.move_picker_index = 0;
5779 self.state.show_move_picker = true;
5780 self.state.show_destroy_picker = false;
5781 self.state.destroy_confirm_pending = false;
5782 self.state.destroy_picker = None;
5783 Ok(())
5784 }
5785
5786 pub fn close_move_picker(&mut self) {
5787 self.state.show_move_picker = false;
5788 self.state.move_picker = None;
5789 }
5790
5791 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
5792 self.state.move_picker_adjust_quantity(delta);
5793 }
5794
5795 pub fn move_picker_set_quantity_max(&mut self) {
5796 self.state.move_picker_set_quantity_max();
5797 }
5798
5799 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
5800 self.state.destroy_picker_adjust_quantity(delta);
5801 }
5802
5803 pub fn destroy_picker_set_quantity_max(&mut self) {
5804 self.state.destroy_picker_set_quantity_max();
5805 }
5806
5807 async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
5808 let Some(picker) = self.state.move_picker.clone() else {
5809 self.close_move_picker();
5810 return Ok(());
5811 };
5812 let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
5813 self.close_move_picker();
5814 return Ok(());
5815 };
5816 match option.kind {
5817 MoveOptionKind::Cancel => {
5818 self.close_move_picker();
5819 }
5820 MoveOptionKind::Use => {
5821 self.close_move_picker();
5822 self.use_item(&picker.template_id).await?;
5823 }
5824 MoveOptionKind::GrantApply => {
5825 self.close_move_picker();
5826 self.open_grant_target_picker()?;
5827 }
5828 MoveOptionKind::Drop => {
5829 self.close_move_picker();
5830 if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
5831 if self.state.key_drop_blocked(&stack) {
5832 anyhow::bail!("cannot drop the key while its chest is locked");
5833 }
5834 }
5835 self.drop_item(picker.item_instance_id, picker.from).await?;
5836 self.state
5837 .push_log(format!("Dropped {}", picker.item_label));
5838 }
5839 MoveOptionKind::PickupPlaced {
5840 container_id,
5841 nest_location,
5842 nest_parent_instance_id,
5843 } => {
5844 self.close_move_picker();
5845 self.pickup_container(container_id.clone()).await?;
5846 let nest_into_bag = nest_parent_instance_id.is_some()
5847 || !matches!(
5848 nest_location,
5849 flatland_protocol::InventoryLocation::Root
5850 );
5851 if nest_into_bag {
5852 self.move_item(
5853 picker.item_instance_id,
5854 flatland_protocol::InventoryLocation::Root,
5855 nest_location,
5856 nest_parent_instance_id,
5857 None,
5858 )
5859 .await?;
5860 self.state
5861 .push_log(format!("Picked up {} into bag", picker.item_label));
5862 } else {
5863 self.state
5864 .push_log(format!("Picked up {}", picker.item_label));
5865 }
5866 }
5867 MoveOptionKind::Move {
5868 location,
5869 parent_instance_id,
5870 } => {
5871 self.close_move_picker();
5872 let qty = if picker.quantity >= picker.stack_quantity {
5873 None
5874 } else {
5875 Some(picker.quantity)
5876 };
5877 self.move_item(
5878 picker.item_instance_id,
5879 picker.from,
5880 location,
5881 parent_instance_id,
5882 qty,
5883 )
5884 .await?;
5885 let moved = qty.unwrap_or(picker.stack_quantity);
5886 if moved >= picker.stack_quantity {
5887 self.state.push_log(format!("Moved {}", picker.item_label));
5888 } else {
5889 self.state.push_log(format!(
5890 "Moved {} ×{} of {}",
5891 picker.item_label, moved, picker.stack_quantity
5892 ));
5893 }
5894 }
5895 }
5896 Ok(())
5897 }
5898
5899 pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
5901 let Some(row) = self.state.inventory_selected_row() else {
5902 anyhow::bail!("inventory empty");
5903 };
5904 if row.is_equip_shell {
5905 anyhow::bail!("unequip the bag first (Enter), then drop from your person");
5906 }
5907 if row.is_chest_shell {
5908 anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
5909 }
5910 let Some(inst) = row.stack.item_instance_id else {
5911 anyhow::bail!("item has no instance id");
5912 };
5913 if self.state.key_drop_blocked(&row.stack) {
5914 anyhow::bail!("cannot drop the key while its chest is locked");
5915 }
5916 let label = row
5917 .stack
5918 .display_name
5919 .clone()
5920 .unwrap_or_else(|| row.stack.template_id.clone());
5921 self.drop_item(inst, row.from).await?;
5922 self.state.push_log(format!("Dropped {label}"));
5923 Ok(())
5924 }
5925
5926 pub async fn drop_item(
5927 &mut self,
5928 item_instance_id: uuid::Uuid,
5929 from: flatland_protocol::InventoryLocation,
5930 ) -> anyhow::Result<()> {
5931 self.seq += 1;
5932 self.session
5933 .submit_intent(Intent::DropItem {
5934 entity_id: self.state.entity_id,
5935 item_instance_id,
5936 from,
5937 seq: self.seq,
5938 })
5939 .await?;
5940 self.state.intents_sent += 1;
5941 Ok(())
5942 }
5943
5944 pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
5946 let Some(row) = self.state.inventory_selected_row() else {
5947 anyhow::bail!("inventory empty");
5948 };
5949 if row.is_equip_shell {
5950 anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
5951 }
5952 if row.is_chest_shell {
5953 anyhow::bail!("can't destroy a placed chest from the inventory list");
5954 }
5955 let Some(instance_id) = row.stack.item_instance_id else {
5956 anyhow::bail!("item has no instance id");
5957 };
5958 if self.state.key_drop_blocked(&row.stack) {
5959 anyhow::bail!("cannot destroy the key while its chest is locked");
5960 }
5961 let item_label = row
5962 .stack
5963 .display_name
5964 .clone()
5965 .unwrap_or_else(|| row.stack.template_id.clone());
5966 self.state.destroy_picker = Some(DestroyPicker {
5967 item_instance_id: instance_id,
5968 from: row.from,
5969 item_label,
5970 stack_quantity: row.stack.quantity,
5971 quantity: row.stack.quantity,
5972 });
5973 self.state.destroy_confirm_pending = false;
5974 self.state.show_destroy_picker = true;
5975 self.state.show_move_picker = false;
5976 self.state.move_picker = None;
5977 Ok(())
5978 }
5979
5980 pub fn close_destroy_picker(&mut self) {
5981 self.state.show_destroy_picker = false;
5982 self.state.destroy_confirm_pending = false;
5983 self.state.destroy_picker = None;
5984 }
5985
5986 pub fn cancel_destroy_confirm(&mut self) {
5987 self.state.destroy_confirm_pending = false;
5988 }
5989
5990 pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
5991 if self.state.destroy_picker.is_none() {
5992 self.close_destroy_picker();
5993 return Ok(());
5994 }
5995 self.state.destroy_confirm_pending = true;
5996 Ok(())
5997 }
5998
5999 pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
6000 let Some(picker) = self.state.destroy_picker.clone() else {
6001 self.close_destroy_picker();
6002 return Ok(());
6003 };
6004 let qty = if picker.quantity >= picker.stack_quantity {
6005 None
6006 } else {
6007 Some(picker.quantity)
6008 };
6009 self.destroy_item(picker.item_instance_id, picker.from, qty)
6010 .await?;
6011 let destroyed = qty.unwrap_or(picker.stack_quantity);
6012 if destroyed >= picker.stack_quantity {
6013 self.state
6014 .push_log(format!("Destroyed {}", picker.item_label));
6015 } else {
6016 self.state.push_log(format!(
6017 "Destroyed {} ×{} of {}",
6018 picker.item_label, destroyed, picker.stack_quantity
6019 ));
6020 }
6021 self.close_destroy_picker();
6022 Ok(())
6023 }
6024
6025 pub async fn destroy_item(
6026 &mut self,
6027 item_instance_id: uuid::Uuid,
6028 from: flatland_protocol::InventoryLocation,
6029 quantity: Option<u32>,
6030 ) -> anyhow::Result<()> {
6031 self.seq += 1;
6032 self.session
6033 .submit_intent(Intent::DestroyItem {
6034 entity_id: self.state.entity_id,
6035 item_instance_id,
6036 from,
6037 quantity,
6038 seq: self.seq,
6039 })
6040 .await?;
6041 self.state.intents_sent += 1;
6042 Ok(())
6043 }
6044
6045 pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
6047 if let Some(row) = self.state.inventory_selected_row() {
6048 if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
6049 return self.toggle_placed_chest_lock(container_id).await;
6050 }
6051 }
6052 self.toggle_nearby_chest_lock().await
6053 }
6054
6055 pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
6056 let chest = self
6057 .state
6058 .placed_containers
6059 .iter()
6060 .find(|c| c.id == container_id)
6061 .cloned()
6062 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
6063 let (px, py) = self.state.player_position();
6064 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
6065 anyhow::bail!("too far from {}", chest.display_name);
6066 }
6067 if !chest.accessible && chest.locked {
6068 anyhow::bail!(
6069 "need the matching key for {} (each crafted chest has its own key)",
6070 chest.display_name
6071 );
6072 }
6073 let lock = !chest.locked;
6074 self.set_container_locked(
6075 flatland_protocol::InventoryLocation::Placed {
6076 container_id: chest.id.clone(),
6077 },
6078 lock,
6079 )
6080 .await?;
6081 self.state.push_log(if lock {
6082 format!("Locked {}", chest.display_name)
6083 } else {
6084 format!("Unlocked {}", chest.display_name)
6085 });
6086 Ok(())
6087 }
6088
6089 pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
6091 let chest = self
6092 .state
6093 .nearest_placed_container(CONTAINER_RANGE_M)
6094 .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
6095 self.toggle_placed_chest_lock(&chest.id).await
6096 }
6097
6098 pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
6099 self.equip_mainhand(None).await
6100 }
6101
6102 pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
6103 if !self.state.is_alive() {
6104 anyhow::bail!("you are dead");
6105 }
6106 self.seq += 1;
6107 self.session
6108 .submit_intent(Intent::EquipOffhand {
6109 entity_id: self.state.entity_id,
6110 template_id,
6111 instance_id: None,
6112 seq: self.seq,
6113 })
6114 .await?;
6115 self.state.intents_sent += 1;
6116 Ok(())
6117 }
6118
6119 pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
6120 self.equip_offhand(None).await
6121 }
6122
6123 pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
6124 let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
6125 for slot in slots {
6126 self.equip_worn(slot, None).await?;
6127 }
6128 Ok(())
6129 }
6130
6131 pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
6132 let (px, py) = self.state.player_position();
6133 let nearest = self
6134 .state
6135 .placed_containers
6136 .iter()
6137 .min_by(|a, b| {
6138 let da = (a.x - px).hypot(a.y - py);
6139 let db = (b.x - px).hypot(b.y - py);
6140 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
6141 })
6142 .cloned();
6143 let Some(chest) = nearest else {
6144 anyhow::bail!("no chest nearby");
6145 };
6146 if (chest.x - px).hypot(chest.y - py) > 2.0 {
6147 anyhow::bail!("too far from chest");
6148 }
6149 self.pickup_container(chest.id).await
6150 }
6151
6152 pub async fn equip_worn(
6153 &mut self,
6154 slot: BodySlot,
6155 instance_id: Option<uuid::Uuid>,
6156 ) -> anyhow::Result<()> {
6157 self.seq += 1;
6158 self.session
6159 .submit_intent(Intent::EquipWorn {
6160 entity_id: self.state.entity_id,
6161 slot,
6162 instance_id,
6163 seq: self.seq,
6164 })
6165 .await?;
6166 self.state.intents_sent += 1;
6167 Ok(())
6168 }
6169
6170 pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
6171 self.seq += 1;
6172 self.session
6173 .submit_intent(Intent::PlaceContainer {
6174 entity_id: self.state.entity_id,
6175 item_instance_id,
6176 seq: self.seq,
6177 })
6178 .await?;
6179 self.state.intents_sent += 1;
6180 Ok(())
6181 }
6182
6183 pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
6184 self.seq += 1;
6185 self.session
6186 .submit_intent(Intent::PickupContainer {
6187 entity_id: self.state.entity_id,
6188 container_id,
6189 seq: self.seq,
6190 })
6191 .await?;
6192 self.state.intents_sent += 1;
6193 Ok(())
6194 }
6195
6196 pub async fn move_item(
6197 &mut self,
6198 item_instance_id: uuid::Uuid,
6199 from: flatland_protocol::InventoryLocation,
6200 to: flatland_protocol::InventoryLocation,
6201 to_parent_instance_id: Option<uuid::Uuid>,
6202 quantity: Option<u32>,
6203 ) -> anyhow::Result<()> {
6204 self.seq += 1;
6205 self.session
6206 .submit_intent(Intent::MoveItem {
6207 entity_id: self.state.entity_id,
6208 item_instance_id,
6209 from,
6210 to,
6211 to_parent_instance_id,
6212 quantity,
6213 seq: self.seq,
6214 })
6215 .await?;
6216 self.state.intents_sent += 1;
6217 Ok(())
6218 }
6219
6220 pub async fn set_container_locked(
6221 &mut self,
6222 location: flatland_protocol::InventoryLocation,
6223 locked: bool,
6224 ) -> anyhow::Result<()> {
6225 self.seq += 1;
6226 self.session
6227 .submit_intent(Intent::SetContainerLocked {
6228 entity_id: self.state.entity_id,
6229 location,
6230 locked,
6231 seq: self.seq,
6232 })
6233 .await?;
6234 self.state.intents_sent += 1;
6235 Ok(())
6236 }
6237
6238 pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
6239 if !self.state.is_alive() {
6240 anyhow::bail!("you are dead");
6241 }
6242 self.seq += 1;
6243 self.session
6244 .submit_intent(Intent::Use {
6245 entity_id: self.state.entity_id,
6246 template_id: template_id.to_string(),
6247 seq: self.seq,
6248 })
6249 .await?;
6250 self.state.intents_sent += 1;
6251 Ok(())
6252 }
6253
6254 pub async fn use_grant(
6256 &mut self,
6257 grant_instance_id: uuid::Uuid,
6258 target_instance_id: uuid::Uuid,
6259 ) -> anyhow::Result<()> {
6260 if !self.state.is_alive() {
6261 anyhow::bail!("you are dead");
6262 }
6263 self.seq += 1;
6264 self.session
6265 .submit_intent(Intent::UseGrant {
6266 entity_id: self.state.entity_id,
6267 grant_instance_id,
6268 target_instance_id,
6269 seq: self.seq,
6270 })
6271 .await?;
6272 self.state.intents_sent += 1;
6273 Ok(())
6274 }
6275
6276 pub fn open_craft_menu(&mut self) {
6277 self.state.show_craft_menu = true;
6278 self.state.show_shop_menu = false;
6279 self.state.shop_catalog = None;
6280 self.state.show_stats = false;
6281 self.state.show_inventory_menu = false;
6282 if self.state.blueprints.is_empty() {
6283 self.state.craft_menu_index = 0;
6284 self.state.craft_batch_quantity = 1;
6285 return;
6286 }
6287 self.state.craft_menu_index = self
6288 .state
6289 .craft_menu_index
6290 .min(self.state.blueprints.len() - 1);
6291 if let Some(idx) = self
6292 .state
6293 .blueprints
6294 .iter()
6295 .position(|bp| self.state.can_craft_blueprint(bp))
6296 {
6297 self.state.craft_menu_index = idx;
6298 }
6299 self.state.clamp_craft_batch_quantity();
6300 }
6301
6302 pub fn close_craft_menu(&mut self) {
6303 self.state.show_craft_menu = false;
6304 }
6305
6306 pub fn toggle_keychain_menu(&mut self) {
6307 if self.state.show_keychain_menu {
6308 self.close_keychain_menu();
6309 } else {
6310 self.state.show_keychain_menu = true;
6311 self.state.show_craft_menu = false;
6312 self.state.show_shop_menu = false;
6313 self.state.show_inventory_menu = false;
6314 let n = self.state.keychain_entries().len();
6315 if n == 0 {
6316 self.state.keychain_menu_index = 0;
6317 } else {
6318 self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
6319 }
6320 }
6321 }
6322
6323 pub fn close_keychain_menu(&mut self) {
6324 self.state.show_keychain_menu = false;
6325 }
6326
6327 pub fn keychain_menu_move(&mut self, delta: i32) {
6328 let n = self.state.keychain_entries().len();
6329 if n == 0 {
6330 self.state.keychain_menu_index = 0;
6331 return;
6332 }
6333 let idx = self.state.keychain_menu_index as i32 + delta;
6334 self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
6335 }
6336
6337 pub fn keychain_menu_page(&mut self, pages: i32) {
6338 let n = self.state.keychain_entries().len();
6339 self.state.keychain_menu_index =
6340 page_list_index(self.state.keychain_menu_index, pages, n);
6341 }
6342
6343 pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
6344 if !self.state.is_alive() {
6345 anyhow::bail!("you are dead");
6346 }
6347 let entries = self.state.keychain_entries();
6348 let Some(entry) = entries.get(self.state.keychain_menu_index) else {
6349 anyhow::bail!("nothing selected");
6350 };
6351 let Some(instance_id) = entry.stack.item_instance_id else {
6352 anyhow::bail!("key has no instance id");
6353 };
6354 if entry.stowed {
6355 self.move_item(
6356 instance_id,
6357 flatland_protocol::InventoryLocation::Keychain,
6358 flatland_protocol::InventoryLocation::Root,
6359 None,
6360 Some(1),
6361 )
6362 .await
6363 } else {
6364 self.move_item(
6365 instance_id,
6366 flatland_protocol::InventoryLocation::Root,
6367 flatland_protocol::InventoryLocation::Keychain,
6368 None,
6369 Some(1),
6370 )
6371 .await
6372 }
6373 }
6374
6375 pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
6376 let npc_id = self
6377 .state
6378 .shop_catalog
6379 .as_ref()
6380 .map(|c| c.npc_id.clone());
6381 self.state.show_shop_menu = false;
6382 self.state.shop_catalog = None;
6383 self.state.clear_shop_trade_log();
6384 if let Some(npc_id) = npc_id {
6385 self.seq += 1;
6386 self.session
6387 .submit_intent(Intent::ShopClose {
6388 entity_id: self.state.entity_id,
6389 npc_id,
6390 seq: self.seq,
6391 })
6392 .await?;
6393 self.state.intents_sent += 1;
6394 }
6395 Ok(())
6396 }
6397
6398 pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
6399 let Some(panel) = self.state.bank_panel.clone() else {
6400 return Ok(());
6401 };
6402 self.seq += 1;
6403 self.session
6404 .submit_intent(Intent::BankDeposit {
6405 entity_id: self.state.entity_id,
6406 npc_id: panel.npc_id,
6407 amount_copper,
6408 seq: self.seq,
6409 })
6410 .await?;
6411 self.state.intents_sent += 1;
6412 Ok(())
6413 }
6414
6415 pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
6416 let Some(panel) = self.state.bank_panel.clone() else {
6417 return Ok(());
6418 };
6419 self.seq += 1;
6420 self.session
6421 .submit_intent(Intent::BankWithdraw {
6422 entity_id: self.state.entity_id,
6423 npc_id: panel.npc_id,
6424 amount_copper,
6425 seq: self.seq,
6426 })
6427 .await?;
6428 self.state.intents_sent += 1;
6429 Ok(())
6430 }
6431
6432 pub async fn bank_transfer(
6433 &mut self,
6434 to_character_id: Option<uuid::Uuid>,
6435 to_name: String,
6436 amount_copper: u64,
6437 ) -> anyhow::Result<()> {
6438 let Some(panel) = self.state.bank_panel.clone() else {
6439 return Ok(());
6440 };
6441 self.seq += 1;
6442 self.session
6443 .submit_intent(Intent::BankTransfer {
6444 entity_id: self.state.entity_id,
6445 npc_id: panel.npc_id,
6446 to_character_id,
6447 to_name,
6448 amount_copper,
6449 seq: self.seq,
6450 })
6451 .await?;
6452 self.state.intents_sent += 1;
6453 Ok(())
6454 }
6455
6456 pub fn bank_menu_move(&mut self, delta: i32) {
6457 let n = self.state.bank_menu_options().len();
6458 if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
6459 return;
6460 }
6461 let idx = self.state.bank_menu_index as i32;
6462 self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
6463 }
6464
6465 pub fn storage_menu_move(&mut self, delta: i32) {
6466 let n = self.state.storage_menu_options().len();
6467 if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
6468 return;
6469 }
6470 let idx = self.state.storage_menu_index as i32;
6471 self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
6472 }
6473
6474 pub fn storage_pick_move(&mut self, delta: i32) {
6475 let n = match &self.state.storage_ui_mode {
6476 StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
6477 StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
6478 self.state.storage_vault_options().len()
6479 }
6480 StorageUiMode::Menu
6481 | StorageUiMode::StoreAmount { .. }
6482 | StorageUiMode::TakeAmount { .. }
6483 | StorageUiMode::ShipAmount { .. } => 0,
6484 };
6485 if n == 0 {
6486 return;
6487 }
6488 match &mut self.state.storage_ui_mode {
6489 StorageUiMode::StorePick { index }
6490 | StorageUiMode::TakePick { index }
6491 | StorageUiMode::ShipPick { index, .. } => {
6492 *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
6493 }
6494 StorageUiMode::Menu
6495 | StorageUiMode::StoreAmount { .. }
6496 | StorageUiMode::TakeAmount { .. }
6497 | StorageUiMode::ShipAmount { .. } => {}
6498 }
6499 }
6500
6501 pub fn storage_ui_back(&mut self) {
6502 self.state.storage_ui_mode = match &self.state.storage_ui_mode {
6503 StorageUiMode::StoreAmount { pick_index, .. } => StorageUiMode::StorePick {
6504 index: *pick_index,
6505 },
6506 StorageUiMode::TakeAmount { pick_index, .. } => StorageUiMode::TakePick {
6507 index: *pick_index,
6508 },
6509 StorageUiMode::ShipAmount {
6510 dest_building_id,
6511 dest_label,
6512 pick_index,
6513 ..
6514 } => StorageUiMode::ShipPick {
6515 dest_building_id: dest_building_id.clone(),
6516 dest_label: dest_label.clone(),
6517 index: *pick_index,
6518 },
6519 StorageUiMode::StorePick { .. }
6520 | StorageUiMode::TakePick { .. }
6521 | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
6522 StorageUiMode::Menu => StorageUiMode::Menu,
6523 };
6524 }
6525
6526 pub fn storage_amount_append_char(&mut self, c: char) {
6527 match &mut self.state.storage_ui_mode {
6528 StorageUiMode::StoreAmount { input, .. }
6529 | StorageUiMode::TakeAmount { input, .. }
6530 | StorageUiMode::ShipAmount { input, .. } => {
6531 if c.is_ascii_digit() && input.len() < 8 {
6532 input.push(c);
6533 }
6534 }
6535 _ => {}
6536 }
6537 }
6538
6539 pub fn storage_amount_backspace(&mut self) {
6540 match &mut self.state.storage_ui_mode {
6541 StorageUiMode::StoreAmount { input, .. }
6542 | StorageUiMode::TakeAmount { input, .. }
6543 | StorageUiMode::ShipAmount { input, .. } => {
6544 input.pop();
6545 }
6546 _ => {}
6547 }
6548 }
6549
6550 pub fn storage_ui_typing(&self) -> bool {
6551 matches!(
6552 self.state.storage_ui_mode,
6553 StorageUiMode::StoreAmount { .. }
6554 | StorageUiMode::TakeAmount { .. }
6555 | StorageUiMode::ShipAmount { .. }
6556 )
6557 }
6558
6559 pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
6560 match self.state.storage_ui_mode.clone() {
6561 StorageUiMode::Menu => {
6562 let index = self.state.storage_menu_index;
6563 match index {
6564 0 => {
6565 let opts = self.state.storage_store_options();
6566 if opts.is_empty() {
6567 self.state.push_log("Nothing loose to store.");
6568 return Ok(());
6569 }
6570 self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
6571 }
6572 1 => {
6573 let opts = self.state.storage_vault_options();
6574 if opts.is_empty() {
6575 self.state.push_log("Vault is empty.");
6576 return Ok(());
6577 }
6578 self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
6579 }
6580 n => {
6581 let dest = self
6582 .state
6583 .storage_panel
6584 .as_ref()
6585 .and_then(|p| p.ship_destinations.get(n - 2))
6586 .cloned();
6587 let Some(dest) = dest else {
6588 return Ok(());
6589 };
6590 let opts = self.state.storage_vault_options();
6591 if opts.is_empty() {
6592 self.state
6593 .push_log("Vault is empty — nothing to ship.");
6594 return Ok(());
6595 }
6596 self.state.storage_ui_mode = StorageUiMode::ShipPick {
6597 dest_building_id: dest.building_id,
6598 dest_label: dest.label,
6599 index: 0,
6600 };
6601 }
6602 }
6603 }
6604 StorageUiMode::StorePick { index } => {
6605 let opts = self.state.storage_store_options();
6606 let Some(opt) = opts.get(index) else {
6607 self.state.push_log("Nothing loose to store.");
6608 self.state.storage_ui_mode = StorageUiMode::Menu;
6609 return Ok(());
6610 };
6611 self.state.storage_ui_mode = StorageUiMode::StoreAmount {
6612 pick_index: index,
6613 item_instance_id: opt.item_instance_id,
6614 label: opt.label.clone(),
6615 max_qty: opt.quantity.max(1),
6616 input: String::new(),
6617 };
6618 }
6619 StorageUiMode::TakePick { index } => {
6620 let opts = self.state.storage_vault_options();
6621 let Some(opt) = opts.get(index) else {
6622 self.state.push_log("Vault is empty.");
6623 self.state.storage_ui_mode = StorageUiMode::Menu;
6624 return Ok(());
6625 };
6626 self.state.storage_ui_mode = StorageUiMode::TakeAmount {
6627 pick_index: index,
6628 item_instance_id: opt.item_instance_id,
6629 label: opt.label.clone(),
6630 max_qty: opt.quantity.max(1),
6631 input: String::new(),
6632 };
6633 }
6634 StorageUiMode::ShipPick {
6635 dest_building_id,
6636 dest_label,
6637 index,
6638 } => {
6639 let opts = self.state.storage_vault_options();
6640 let Some(opt) = opts.get(index) else {
6641 self.state
6642 .push_log("Vault is empty — nothing to ship.");
6643 self.state.storage_ui_mode = StorageUiMode::Menu;
6644 return Ok(());
6645 };
6646 self.state.storage_ui_mode = StorageUiMode::ShipAmount {
6647 dest_building_id,
6648 dest_label,
6649 pick_index: index,
6650 item_instance_id: opt.item_instance_id,
6651 label: opt.label.clone(),
6652 max_qty: opt.quantity.max(1),
6653 input: String::new(),
6654 };
6655 }
6656 StorageUiMode::StoreAmount {
6657 item_instance_id,
6658 max_qty,
6659 input,
6660 ..
6661 } => {
6662 let Some(qty) = parse_storage_quantity(&input) else {
6663 self.state
6664 .push_log("Enter a quantity (blank or 0 = all).");
6665 return Ok(());
6666 };
6667 let qty = qty.map(|n| n.min(max_qty).max(1));
6668 self.storage_store(item_instance_id, qty).await?;
6669 self.state.storage_ui_mode = StorageUiMode::Menu;
6670 }
6671 StorageUiMode::TakeAmount {
6672 item_instance_id,
6673 max_qty,
6674 input,
6675 ..
6676 } => {
6677 let Some(qty) = parse_storage_quantity(&input) else {
6678 self.state
6679 .push_log("Enter a quantity (blank or 0 = all).");
6680 return Ok(());
6681 };
6682 let qty = qty.map(|n| n.min(max_qty).max(1));
6683 self.storage_take(item_instance_id, qty).await?;
6684 self.state.storage_ui_mode = StorageUiMode::Menu;
6685 }
6686 StorageUiMode::ShipAmount {
6687 dest_building_id,
6688 item_instance_id,
6689 max_qty,
6690 input,
6691 ..
6692 } => {
6693 let Some(qty) = parse_storage_quantity(&input) else {
6694 self.state
6695 .push_log("Enter a quantity (blank or 0 = all).");
6696 return Ok(());
6697 };
6698 let qty = qty.map(|n| n.min(max_qty).max(1));
6699 self.storage_ship(dest_building_id, item_instance_id, qty)
6700 .await?;
6701 self.state.storage_ui_mode = StorageUiMode::Menu;
6702 }
6703 }
6704 Ok(())
6705 }
6706
6707 pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
6708 match self.state.bank_ui_mode.clone() {
6709 BankUiMode::Menu => {
6710 let choice = self
6711 .state
6712 .bank_menu_options()
6713 .get(self.state.bank_menu_index)
6714 .copied()
6715 .unwrap_or("Deposit…");
6716 match choice {
6717 "Withdraw…" => {
6718 self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
6719 input: String::new(),
6720 };
6721 }
6722 "Deposit all" => self.bank_deposit(0).await?,
6723 "Withdraw all" => self.bank_withdraw(0).await?,
6724 "Transfer…" => {
6725 self.state.bank_ui_mode = BankUiMode::TransferName {
6726 input: String::new(),
6727 };
6728 }
6729 _ => {
6730 self.state.bank_ui_mode = BankUiMode::DepositAmount {
6731 input: String::new(),
6732 };
6733 }
6734 }
6735 }
6736 BankUiMode::DepositAmount { input } => {
6737 let Some(amount) = parse_bank_copper_amount(&input) else {
6738 self.state
6739 .push_log("Enter a copper amount (blank or 0 = everything on person).");
6740 return Ok(());
6741 };
6742 self.bank_deposit(amount).await?;
6743 self.state.bank_ui_mode = BankUiMode::Menu;
6744 }
6745 BankUiMode::WithdrawAmount { input } => {
6746 let Some(amount) = parse_bank_copper_amount(&input) else {
6747 self.state
6748 .push_log("Enter a copper amount (blank or 0 = full ledger).");
6749 return Ok(());
6750 };
6751 self.bank_withdraw(amount).await?;
6752 self.state.bank_ui_mode = BankUiMode::Menu;
6753 }
6754 BankUiMode::TransferName { input } => {
6755 let name = input.trim().to_string();
6756 if name.is_empty() {
6757 self.state.push_log("Enter the recipient character name.");
6758 return Ok(());
6759 }
6760 self.state.bank_ui_mode = BankUiMode::TransferAmount {
6761 to_name: name,
6762 input: String::new(),
6763 };
6764 }
6765 BankUiMode::TransferAmount { to_name, input } => {
6766 let amount: u64 = match input.trim().parse() {
6767 Ok(v) if v > 0 => v,
6768 _ => {
6769 self.state
6770 .push_log("Enter a positive copper amount to transfer.");
6771 return Ok(());
6772 }
6773 };
6774 self.bank_transfer(None, to_name, amount).await?;
6775 self.state.bank_ui_mode = BankUiMode::Menu;
6776 }
6777 }
6778 Ok(())
6779 }
6780
6781 pub fn bank_transfer_back(&mut self) {
6782 match &self.state.bank_ui_mode {
6783 BankUiMode::TransferAmount { to_name, .. } => {
6784 self.state.bank_ui_mode = BankUiMode::TransferName {
6785 input: to_name.clone(),
6786 };
6787 }
6788 BankUiMode::TransferName { .. }
6789 | BankUiMode::DepositAmount { .. }
6790 | BankUiMode::WithdrawAmount { .. } => {
6791 self.state.bank_ui_mode = BankUiMode::Menu;
6792 }
6793 BankUiMode::Menu => {}
6794 }
6795 }
6796
6797 pub fn bank_transfer_append_char(&mut self, c: char) {
6798 match &mut self.state.bank_ui_mode {
6799 BankUiMode::TransferName { input } => {
6800 if input.len() < 32 && !c.is_control() {
6801 input.push(c);
6802 }
6803 }
6804 BankUiMode::DepositAmount { input }
6805 | BankUiMode::WithdrawAmount { input }
6806 | BankUiMode::TransferAmount { input, .. } => {
6807 if c.is_ascii_digit() && input.len() < 12 {
6808 input.push(c);
6809 }
6810 }
6811 BankUiMode::Menu => {}
6812 }
6813 }
6814
6815 pub fn bank_transfer_backspace(&mut self) {
6816 match &mut self.state.bank_ui_mode {
6817 BankUiMode::TransferName { input }
6818 | BankUiMode::DepositAmount { input }
6819 | BankUiMode::WithdrawAmount { input }
6820 | BankUiMode::TransferAmount { input, .. } => {
6821 input.pop();
6822 }
6823 BankUiMode::Menu => {}
6824 }
6825 }
6826
6827 pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
6828 let npc_id = self
6829 .state
6830 .bank_panel
6831 .as_ref()
6832 .map(|p| p.npc_id.clone());
6833 self.state.clear_bank_panel();
6834 if let Some(npc_id) = npc_id {
6835 self.seq += 1;
6836 self.session
6837 .submit_intent(Intent::BankClose {
6838 entity_id: self.state.entity_id,
6839 npc_id,
6840 seq: self.seq,
6841 })
6842 .await?;
6843 self.state.intents_sent += 1;
6844 }
6845 Ok(())
6846 }
6847
6848 pub async fn storage_store(
6849 &mut self,
6850 item_instance_id: uuid::Uuid,
6851 quantity: Option<u32>,
6852 ) -> anyhow::Result<()> {
6853 let Some(panel) = self.state.storage_panel.clone() else {
6854 return Ok(());
6855 };
6856 self.seq += 1;
6857 self.session
6858 .submit_intent(Intent::StorageStore {
6859 entity_id: self.state.entity_id,
6860 npc_id: panel.npc_id,
6861 item_instance_id,
6862 quantity,
6863 seq: self.seq,
6864 })
6865 .await?;
6866 self.state.intents_sent += 1;
6867 Ok(())
6868 }
6869
6870 pub async fn storage_take(
6871 &mut self,
6872 item_instance_id: uuid::Uuid,
6873 quantity: Option<u32>,
6874 ) -> anyhow::Result<()> {
6875 let Some(panel) = self.state.storage_panel.clone() else {
6876 return Ok(());
6877 };
6878 self.seq += 1;
6879 self.session
6880 .submit_intent(Intent::StorageTake {
6881 entity_id: self.state.entity_id,
6882 npc_id: panel.npc_id,
6883 item_instance_id,
6884 quantity,
6885 seq: self.seq,
6886 })
6887 .await?;
6888 self.state.intents_sent += 1;
6889 Ok(())
6890 }
6891
6892 pub async fn storage_ship(
6893 &mut self,
6894 dest_building_id: String,
6895 item_instance_id: uuid::Uuid,
6896 quantity: Option<u32>,
6897 ) -> anyhow::Result<()> {
6898 let Some(panel) = self.state.storage_panel.clone() else {
6899 return Ok(());
6900 };
6901 self.seq += 1;
6902 self.session
6903 .submit_intent(Intent::StorageShip {
6904 entity_id: self.state.entity_id,
6905 npc_id: panel.npc_id,
6906 dest_building_id,
6907 item_instance_id,
6908 quantity,
6909 seq: self.seq,
6910 })
6911 .await?;
6912 self.state.intents_sent += 1;
6913 Ok(())
6914 }
6915
6916 pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
6917 let npc_id = self
6918 .state
6919 .storage_panel
6920 .as_ref()
6921 .map(|p| p.npc_id.clone());
6922 self.state.clear_storage_panel();
6923 if let Some(npc_id) = npc_id {
6924 self.seq += 1;
6925 self.session
6926 .submit_intent(Intent::StorageClose {
6927 entity_id: self.state.entity_id,
6928 npc_id,
6929 seq: self.seq,
6930 })
6931 .await?;
6932 self.state.intents_sent += 1;
6933 }
6934 Ok(())
6935 }
6936
6937 pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
6939 let return_to_verbs = self.state.npc_verb_target.is_some();
6940 self.close_shop_menu().await?;
6941 if return_to_verbs {
6942 self.state.show_npc_verb_menu = true;
6943 }
6944 Ok(())
6945 }
6946
6947 pub fn shop_tab_toggle(&mut self) {
6948 self.state.shop_tab = match self.state.shop_tab {
6949 ShopTab::Buy => ShopTab::Sell,
6950 ShopTab::Sell => ShopTab::Buy,
6951 };
6952 self.state.shop_menu_index = 0;
6953 if self.state.shop_tab == ShopTab::Sell {
6954 self.state.shop_quantity_set_max();
6955 }
6956 self.state.clamp_shop_selection();
6957 }
6958
6959 pub fn shop_menu_move(&mut self, delta: i32) {
6960 self.state.shop_menu_move(delta);
6961 }
6962
6963 pub fn shop_quantity_adjust(&mut self, delta: i32) {
6964 self.state.shop_quantity_adjust(delta);
6965 }
6966
6967 pub fn shop_quantity_set_max(&mut self) {
6968 self.state.shop_quantity_set_max();
6969 }
6970
6971 pub fn toggle_quest_menu(&mut self) {
6972 self.state.show_quest_menu = !self.state.show_quest_menu;
6973 if self.state.show_quest_menu {
6974 self.state.quest_menu_index = 0;
6975 self.state.quest_withdraw_confirm = false;
6976 self.state.show_workers_menu = false;
6977 }
6978 }
6979
6980 pub fn toggle_workers_menu(&mut self) {
6981 self.state.show_workers_menu = !self.state.show_workers_menu;
6982 if self.state.show_workers_menu {
6983 self.state.workers_menu_index = 0;
6984 self.state.show_quest_menu = false;
6985 self.close_worker_give_picker();
6986 self.close_worker_give_target_picker();
6987 self.close_worker_take_picker();
6988 self.close_worker_teach_picker();
6989 self.cancel_worker_rename();
6990 } else {
6991 self.close_worker_give_picker();
6992 self.close_worker_give_target_picker();
6993 self.close_worker_take_picker();
6994 self.close_worker_teach_picker();
6995 self.cancel_worker_rename();
6996 }
6997 }
6998
6999 pub fn workers_menu_move(&mut self, delta: i32) {
7000 let n = self.state.hired_workers.len();
7001 if n == 0 {
7002 return;
7003 }
7004 let idx = self.state.workers_menu_index as i32;
7005 self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
7006 }
7007
7008 pub fn toggle_workers_menu_compact(&mut self) {
7009 self.state.workers_menu_compact = !self.state.workers_menu_compact;
7010 }
7011
7012 pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
7013 let Some(worker) = self
7014 .state
7015 .hired_workers
7016 .get(self.state.workers_menu_index)
7017 .cloned()
7018 else {
7019 anyhow::bail!("no worker selected");
7020 };
7021 self.seq += 1;
7022 self.session
7023 .submit_intent(Intent::DismissWorker {
7024 entity_id: self.state.entity_id,
7025 worker_instance_id: worker.instance_id.clone(),
7026 seq: self.seq,
7027 })
7028 .await?;
7029 self.state.intents_sent += 1;
7030 self.state
7031 .hired_workers
7032 .retain(|w| w.instance_id != worker.instance_id);
7033 if self.state.workers_menu_index >= self.state.hired_workers.len() {
7034 self.state.workers_menu_index = self
7035 .state
7036 .hired_workers
7037 .len()
7038 .saturating_sub(1);
7039 }
7040 self.state.push_log(format!("Dismissed {}", worker.label));
7041 Ok(())
7042 }
7043
7044 pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
7045 let Some(worker) = self
7046 .state
7047 .hired_workers
7048 .get(self.state.workers_menu_index)
7049 .cloned()
7050 else {
7051 anyhow::bail!("no worker selected");
7052 };
7053 let mode = match worker.mode {
7054 flatland_protocol::WorkerModeView::Companion => "job_loop",
7055 flatland_protocol::WorkerModeView::JobLoop => "idle",
7056 flatland_protocol::WorkerModeView::Idle => "companion",
7057 };
7058 self.seq += 1;
7059 self.session
7060 .submit_intent(Intent::SetWorkerMode {
7061 entity_id: self.state.entity_id,
7062 worker_instance_id: worker.instance_id,
7063 mode: mode.into(),
7064 seq: self.seq,
7065 })
7066 .await?;
7067 self.state.intents_sent += 1;
7068 Ok(())
7069 }
7070
7071 pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
7072 if self.state.hired_workers.is_empty() {
7073 return self.hire_worker_laborer().await;
7074 }
7075 self.workers_toggle_mode_selected().await
7076 }
7077
7078 pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
7081 let row = self
7082 .state
7083 .inventory_selected_row()
7084 .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
7085 .clone();
7086 if row.from != flatland_protocol::InventoryLocation::Root {
7087 anyhow::bail!("select a carried item to give");
7088 }
7089 let Some(instance_id) = row.stack.item_instance_id else {
7090 anyhow::bail!("that stack can't be given");
7091 };
7092 let options = self.nearby_worker_give_targets();
7093 if options.is_empty() {
7094 anyhow::bail!(
7095 "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
7096 );
7097 }
7098 let item_label = row
7099 .stack
7100 .display_name
7101 .as_deref()
7102 .unwrap_or(&row.stack.template_id)
7103 .to_string();
7104 self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
7105 item_instance_id: instance_id,
7106 item_label,
7107 quantity: None,
7108 options,
7109 });
7110 self.state.worker_give_target_picker_index = 0;
7111 self.state.show_worker_give_target_picker = true;
7112 self.state.show_inventory_menu = false;
7114 Ok(())
7115 }
7116
7117 pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
7119 let (px, py, _) = self.state.player_position_with_z();
7120 let mut options: Vec<WorkerGiveTargetOption> = self
7121 .state
7122 .hired_workers
7123 .iter()
7124 .filter_map(|w| {
7125 let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
7126 if dist > WORKER_GIVE_RANGE_M {
7127 return None;
7128 }
7129 Some(WorkerGiveTargetOption {
7130 instance_id: w.instance_id.clone(),
7131 label: w.label.clone(),
7132 distance_m: dist,
7133 })
7134 })
7135 .collect();
7136 options.sort_by(|a, b| {
7137 a.distance_m
7138 .partial_cmp(&b.distance_m)
7139 .unwrap_or(std::cmp::Ordering::Equal)
7140 });
7141 options
7142 }
7143
7144 pub fn close_worker_give_target_picker(&mut self) {
7145 self.state.show_worker_give_target_picker = false;
7146 self.state.worker_give_target_picker = None;
7147 self.state.worker_give_target_picker_index = 0;
7148 }
7149
7150 pub fn worker_give_target_picker_move(&mut self, delta: i32) {
7151 let Some(picker) = &self.state.worker_give_target_picker else {
7152 return;
7153 };
7154 let n = picker.options.len();
7155 if n == 0 {
7156 return;
7157 }
7158 let idx = self.state.worker_give_target_picker_index as i32;
7159 self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
7160 }
7161
7162 pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
7163 let Some(picker) = self.state.worker_give_target_picker.clone() else {
7164 anyhow::bail!("give target picker not open");
7165 };
7166 let Some(opt) = picker
7167 .options
7168 .get(self.state.worker_give_target_picker_index)
7169 .cloned()
7170 else {
7171 anyhow::bail!("no worker selected");
7172 };
7173 let Some(worker) = self
7174 .state
7175 .hired_workers
7176 .iter()
7177 .find(|w| w.instance_id == opt.instance_id)
7178 .cloned()
7179 else {
7180 self.close_worker_give_target_picker();
7181 anyhow::bail!("worker no longer hired");
7182 };
7183 self.give_item_to_worker(
7184 &worker.instance_id,
7185 &worker.label,
7186 worker.x,
7187 worker.y,
7188 picker.item_instance_id,
7189 &picker.item_label,
7190 picker.quantity,
7191 )
7192 .await?;
7193 self.close_worker_give_target_picker();
7194 Ok(())
7195 }
7196
7197 pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
7199 self.open_worker_give_target_picker()
7200 }
7201
7202 pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
7204 let Some(worker) = self
7205 .state
7206 .hired_workers
7207 .get(self.state.workers_menu_index)
7208 .cloned()
7209 else {
7210 anyhow::bail!("select a hired worker first");
7211 };
7212 let (px, py, _) = self.state.player_position_with_z();
7213 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
7214 if dist > WORKER_GIVE_RANGE_M {
7215 anyhow::bail!(
7216 "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
7217 worker.label
7218 );
7219 }
7220 let options = self.state.giveable_inventory_options();
7221 if options.is_empty() {
7222 anyhow::bail!("nothing in inventory to give");
7223 }
7224 self.state.worker_give_picker = Some(WorkerGivePicker {
7225 worker_instance_id: worker.instance_id,
7226 worker_label: worker.label,
7227 options,
7228 });
7229 self.state.worker_give_picker_index = 0;
7230 self.state.show_worker_give_picker = true;
7231 Ok(())
7232 }
7233
7234 pub fn close_worker_give_picker(&mut self) {
7235 self.state.show_worker_give_picker = false;
7236 self.state.worker_give_picker = None;
7237 self.state.worker_give_picker_index = 0;
7238 }
7239
7240 pub fn worker_give_picker_move(&mut self, delta: i32) {
7241 let Some(picker) = &self.state.worker_give_picker else {
7242 return;
7243 };
7244 let n = picker.options.len();
7245 if n == 0 {
7246 return;
7247 }
7248 let idx = self.state.worker_give_picker_index as i32;
7249 self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
7250 }
7251
7252 pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
7254 let Some(picker) = self.state.worker_give_picker.clone() else {
7255 anyhow::bail!("give picker not open");
7256 };
7257 let Some(opt) = picker.options.get(self.state.worker_give_picker_index).cloned() else {
7258 anyhow::bail!("no item selected");
7259 };
7260 let Some(worker) = self
7261 .state
7262 .hired_workers
7263 .iter()
7264 .find(|w| w.instance_id == picker.worker_instance_id)
7265 .cloned()
7266 else {
7267 self.close_worker_give_picker();
7268 anyhow::bail!("worker no longer hired");
7269 };
7270 self.give_item_to_worker(
7271 &worker.instance_id,
7272 &worker.label,
7273 worker.x,
7274 worker.y,
7275 opt.item_instance_id,
7276 &opt.label,
7277 None,
7278 )
7279 .await?;
7280 let options = self.state.giveable_inventory_options();
7282 if options.is_empty() {
7283 self.close_worker_give_picker();
7284 } else {
7285 self.state.worker_give_picker = Some(WorkerGivePicker {
7286 worker_instance_id: picker.worker_instance_id,
7287 worker_label: picker.worker_label,
7288 options,
7289 });
7290 if self.state.worker_give_picker_index
7291 >= self
7292 .state
7293 .worker_give_picker
7294 .as_ref()
7295 .map(|p| p.options.len())
7296 .unwrap_or(0)
7297 {
7298 self.state.worker_give_picker_index = self
7299 .state
7300 .worker_give_picker
7301 .as_ref()
7302 .map(|p| p.options.len().saturating_sub(1))
7303 .unwrap_or(0);
7304 }
7305 }
7306 Ok(())
7307 }
7308
7309 pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
7311 let Some(worker) = self
7312 .state
7313 .hired_workers
7314 .get(self.state.workers_menu_index)
7315 .cloned()
7316 else {
7317 anyhow::bail!("select a hired worker first");
7318 };
7319 let (px, py, _) = self.state.player_position_with_z();
7320 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
7321 if dist > WORKER_GIVE_RANGE_M {
7322 anyhow::bail!(
7323 "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
7324 worker.label
7325 );
7326 }
7327 let options = self.state.teachable_blueprint_options(&worker);
7328 if options.is_empty() {
7329 anyhow::bail!("no recipes you know that {} still needs", worker.label);
7330 }
7331 self.state.worker_teach_picker = Some(WorkerTeachPicker {
7332 worker_instance_id: worker.instance_id,
7333 worker_label: worker.label,
7334 worker_level: worker.level,
7335 options,
7336 });
7337 self.state.worker_teach_picker_index = 0;
7338 self.state.show_worker_teach_picker = true;
7339 Ok(())
7340 }
7341
7342 pub fn close_worker_teach_picker(&mut self) {
7343 self.state.show_worker_teach_picker = false;
7344 self.state.worker_teach_picker = None;
7345 self.state.worker_teach_picker_index = 0;
7346 }
7347
7348 pub fn worker_teach_picker_move(&mut self, delta: i32) {
7349 let Some(picker) = &self.state.worker_teach_picker else {
7350 return;
7351 };
7352 let n = picker.options.len();
7353 if n == 0 {
7354 return;
7355 }
7356 let idx = self.state.worker_teach_picker_index as i32;
7357 self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
7358 }
7359
7360 pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
7361 let Some(picker) = self.state.worker_teach_picker.clone() else {
7362 anyhow::bail!("teach picker not open");
7363 };
7364 let Some(opt) = picker.options.get(self.state.worker_teach_picker_index).cloned() else {
7365 anyhow::bail!("nothing selected");
7366 };
7367 if !opt.level_ok {
7368 anyhow::bail!(
7369 "{} needs level {} (is level {})",
7370 picker.worker_label,
7371 opt.min_level,
7372 opt.worker_level
7373 );
7374 }
7375 if !opt.can_afford {
7376 anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
7377 }
7378 let Some(worker) = self
7379 .state
7380 .hired_workers
7381 .iter()
7382 .find(|w| w.instance_id == picker.worker_instance_id)
7383 .cloned()
7384 else {
7385 anyhow::bail!("worker gone");
7386 };
7387 let (px, py, _) = self.state.player_position_with_z();
7388 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
7389 if dist > WORKER_GIVE_RANGE_M {
7390 anyhow::bail!("worker {} too far — stand next to them", worker.label);
7391 }
7392 self.seq += 1;
7393 self.session
7394 .submit_intent(Intent::TeachWorkerBlueprint {
7395 entity_id: self.state.entity_id,
7396 worker_instance_id: picker.worker_instance_id.clone(),
7397 blueprint_id: opt.blueprint_id.clone(),
7398 seq: self.seq,
7399 })
7400 .await?;
7401 self.state.intents_sent += 1;
7402 self.state.push_log(format!(
7403 "Teaching {} to {} ({} cp)",
7404 opt.label, picker.worker_label, opt.cost_copper
7405 ));
7406 self.close_worker_teach_picker();
7407 Ok(())
7408 }
7409
7410 async fn give_item_to_worker(
7411 &mut self,
7412 worker_instance_id: &str,
7413 worker_label: &str,
7414 worker_x: f32,
7415 worker_y: f32,
7416 item_instance_id: uuid::Uuid,
7417 item_label: &str,
7418 quantity: Option<u32>,
7419 ) -> anyhow::Result<()> {
7420 let (px, py, _) = self.state.player_position_with_z();
7421 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
7422 if dist > WORKER_GIVE_RANGE_M {
7423 anyhow::bail!("worker {worker_label} too far — stand next to them");
7424 }
7425 self.seq += 1;
7426 self.session
7427 .submit_intent(Intent::GiveWorkerItem {
7428 entity_id: self.state.entity_id,
7429 worker_instance_id: worker_instance_id.to_string(),
7430 item_instance_id,
7431 quantity,
7432 seq: self.seq,
7433 })
7434 .await?;
7435 self.state.intents_sent += 1;
7436 self.state
7437 .push_log(format!("Gave {item_label} to {worker_label}"));
7438 Ok(())
7439 }
7440
7441 pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
7443 let Some(worker) = self
7444 .state
7445 .hired_workers
7446 .get(self.state.workers_menu_index)
7447 .cloned()
7448 else {
7449 anyhow::bail!("select a hired worker first");
7450 };
7451 let (px, py, _) = self.state.player_position_with_z();
7452 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
7453 if dist > WORKER_GIVE_RANGE_M {
7454 anyhow::bail!(
7455 "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
7456 worker.label
7457 );
7458 }
7459 let options = Self::worker_inventory_options(&worker);
7460 if options.is_empty() {
7461 anyhow::bail!("{} isn't carrying anything", worker.label);
7462 }
7463 self.state.worker_take_picker = Some(WorkerTakePicker {
7464 worker_instance_id: worker.instance_id,
7465 worker_label: worker.label,
7466 options,
7467 });
7468 self.state.worker_take_picker_index = 0;
7469 self.state.show_worker_take_picker = true;
7470 Ok(())
7471 }
7472
7473 fn worker_inventory_options(
7474 worker: &flatland_protocol::HiredWorkerView,
7475 ) -> Vec<WorkerGiveOption> {
7476 worker
7477 .inventory
7478 .iter()
7479 .filter_map(|stack| {
7480 let item_instance_id = stack.item_instance_id?;
7481 let label = stack
7482 .display_name
7483 .clone()
7484 .unwrap_or_else(|| stack.template_id.clone());
7485 let label = if stack.quantity > 1 {
7486 format!("{label} ×{}", stack.quantity)
7487 } else {
7488 label
7489 };
7490 Some(WorkerGiveOption {
7491 item_instance_id,
7492 label,
7493 quantity: stack.quantity,
7494 template_id: stack.template_id.clone(),
7495 })
7496 })
7497 .collect()
7498 }
7499
7500 pub fn close_worker_take_picker(&mut self) {
7501 self.state.show_worker_take_picker = false;
7502 self.state.worker_take_picker = None;
7503 self.state.worker_take_picker_index = 0;
7504 }
7505
7506 pub fn worker_take_picker_move(&mut self, delta: i32) {
7507 let Some(picker) = &self.state.worker_take_picker else {
7508 return;
7509 };
7510 let n = picker.options.len();
7511 if n == 0 {
7512 return;
7513 }
7514 let idx = self.state.worker_take_picker_index as i32;
7515 self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
7516 }
7517
7518 pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
7519 let Some(picker) = self.state.worker_take_picker.clone() else {
7520 anyhow::bail!("take picker not open");
7521 };
7522 let Some(opt) = picker.options.get(self.state.worker_take_picker_index).cloned() else {
7523 anyhow::bail!("no item selected");
7524 };
7525 let Some(worker) = self
7526 .state
7527 .hired_workers
7528 .iter()
7529 .find(|w| w.instance_id == picker.worker_instance_id)
7530 .cloned()
7531 else {
7532 self.close_worker_take_picker();
7533 anyhow::bail!("worker no longer hired");
7534 };
7535 self.take_item_from_worker(
7536 &worker.instance_id,
7537 &worker.label,
7538 worker.x,
7539 worker.y,
7540 opt.item_instance_id,
7541 &opt.label,
7542 None,
7543 )
7544 .await?;
7545 if let Some(w) = self
7549 .state
7550 .hired_workers
7551 .iter()
7552 .find(|w| w.instance_id == picker.worker_instance_id)
7553 {
7554 let options = Self::worker_inventory_options(w)
7555 .into_iter()
7556 .filter(|o| o.item_instance_id != opt.item_instance_id)
7557 .collect::<Vec<_>>();
7558 if options.is_empty() {
7559 self.close_worker_take_picker();
7560 } else {
7561 self.state.worker_take_picker = Some(WorkerTakePicker {
7562 worker_instance_id: picker.worker_instance_id,
7563 worker_label: picker.worker_label,
7564 options,
7565 });
7566 self.state.worker_take_picker_index = self
7567 .state
7568 .worker_take_picker_index
7569 .min(
7570 self.state
7571 .worker_take_picker
7572 .as_ref()
7573 .map(|p| p.options.len().saturating_sub(1))
7574 .unwrap_or(0),
7575 );
7576 }
7577 } else {
7578 self.close_worker_take_picker();
7579 }
7580 Ok(())
7581 }
7582
7583 async fn take_item_from_worker(
7584 &mut self,
7585 worker_instance_id: &str,
7586 worker_label: &str,
7587 worker_x: f32,
7588 worker_y: f32,
7589 item_instance_id: uuid::Uuid,
7590 item_label: &str,
7591 quantity: Option<u32>,
7592 ) -> anyhow::Result<()> {
7593 let (px, py, _) = self.state.player_position_with_z();
7594 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
7595 if dist > WORKER_GIVE_RANGE_M {
7596 anyhow::bail!("worker {worker_label} too far — stand next to them");
7597 }
7598 self.seq += 1;
7599 self.session
7600 .submit_intent(Intent::TakeWorkerItem {
7601 entity_id: self.state.entity_id,
7602 worker_instance_id: worker_instance_id.to_string(),
7603 item_instance_id,
7604 quantity,
7605 seq: self.seq,
7606 })
7607 .await?;
7608 self.state.intents_sent += 1;
7609 self.state
7610 .push_log(format!("Took {item_label} from {worker_label}"));
7611 Ok(())
7612 }
7613
7614 pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
7615 if !self.state.has_worker_lodging() {
7616 anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
7617 }
7618 self.seq += 1;
7619 self.session
7620 .submit_intent(Intent::HireWorker {
7621 entity_id: self.state.entity_id,
7622 def_id: "worker_laborer".into(),
7623 wage_copper_per_interval: 8,
7624 lodging_container_id: None,
7625 job_yaml: None,
7626 seq: self.seq,
7627 })
7628 .await?;
7629 self.state.intents_sent += 1;
7630 Ok(())
7631 }
7632
7633 pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
7634 let Some(worker) = self
7635 .state
7636 .hired_workers
7637 .get(self.state.workers_menu_index)
7638 .cloned()
7639 else {
7640 anyhow::bail!("select a hired worker first");
7641 };
7642 let lodging = worker.lodging_container_id.clone().or_else(|| {
7643 crate::worker_route_editor::owned_lodging_container_ids(
7644 &self.state.placed_containers,
7645 self.state.character_id,
7646 )
7647 .into_iter()
7648 .next()
7649 .map(|(id, _)| id)
7650 });
7651 let label = worker.label.clone();
7652 let editor = if let Some(route) = &worker.route {
7653 crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
7654 worker.instance_id,
7655 worker.label,
7656 route,
7657 lodging,
7658 )
7659 } else {
7660 crate::worker_route_editor::WorkerRouteEditorState::new(
7661 worker.instance_id,
7662 worker.label,
7663 lodging,
7664 )
7665 };
7666 self.state.worker_route_editor = Some(editor);
7667 self.state.show_workers_menu = false;
7668 self.state.push_log(format!(
7669 "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
7670 ));
7671 Ok(())
7672 }
7673
7674 pub fn close_worker_route_editor(&mut self) {
7675 self.state.worker_route_editor = None;
7676 }
7677
7678 pub fn worker_route_editor_toggle_panel(&mut self) {
7679 if let Some(ed) = self.state.worker_route_editor.as_mut() {
7680 ed.toggle_panel_collapsed();
7681 }
7682 }
7683
7684 pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
7685 let n = {
7686 let Some(ed) = self.state.worker_route_editor.as_mut() else {
7687 return;
7688 };
7689 ed.append_waypoint(x, y, z);
7690 ed.stop_count()
7691 };
7692 self.state
7693 .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
7694 }
7695
7696 fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
7699 let (px, py, _) = self.state.player_position_with_z();
7700 crate::worker_route_editor::owned_container_candidates(
7701 &self.state.placed_containers,
7702 self.state.character_id,
7703 px,
7704 py,
7705 )
7706 }
7707
7708 fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
7709 let (px, py, _) = self.state.player_position_with_z();
7710 crate::worker_route_editor::node_candidates(&self.state.resource_nodes, px, py)
7711 }
7712
7713 fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
7714 let (px, py, _) = self.state.player_position_with_z();
7715 crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
7716 }
7717
7718 fn re_template_candidates(&self) -> Vec<String> {
7719 let mut extra = Vec::new();
7720 if let Some(ed) = self.state.worker_route_editor.as_ref() {
7721 for stop in &ed.stops {
7722 match stop {
7723 crate::worker_route_editor::WorkerRouteStop::DepositAt {
7724 filter: Some(filter),
7725 ..
7726 } => extra.extend(filter.iter().cloned()),
7727 crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
7728 extra.push(template.clone());
7729 }
7730 crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
7731 if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint) {
7732 extra.push(bp.output.clone());
7733 for input in &bp.inputs {
7734 extra.push(input.template_id.clone());
7735 }
7736 }
7737 }
7738 crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
7739 for it in items {
7740 extra.push(it.template.clone());
7741 }
7742 }
7743 _ => {}
7744 }
7745 }
7746 if let Some(worker) = self
7748 .state
7749 .hired_workers
7750 .iter()
7751 .find(|w| w.instance_id == ed.worker_instance_id)
7752 {
7753 for recipe in &worker.known_blueprint_ids {
7754 if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
7755 extra.push(bp.output.clone());
7756 }
7757 }
7758 }
7759 }
7760 crate::worker_route_editor::route_item_template_candidates(
7761 &self.state.placed_containers,
7762 self.state.character_id,
7763 &self.state.inventory,
7764 &self.state.blueprints,
7765 &self.state.resource_nodes,
7766 &extra,
7767 )
7768 }
7769
7770 fn re_blueprint_ids(&self) -> Vec<String> {
7771 let worker_known: Option<&[String]> = self
7772 .state
7773 .worker_route_editor
7774 .as_ref()
7775 .and_then(|ed| {
7776 self.state
7777 .hired_workers
7778 .iter()
7779 .find(|w| w.instance_id == ed.worker_instance_id)
7780 })
7781 .map(|w| w.known_blueprint_ids.as_slice());
7782 crate::worker_route_editor::worker_craft_blueprint_ids(
7783 &self.state.blueprints,
7784 worker_known,
7785 )
7786 }
7787
7788 fn re_bed_candidates(&self) -> Vec<(String, String)> {
7789 crate::worker_route_editor::owned_lodging_container_ids(
7790 &self.state.placed_containers,
7791 self.state.character_id,
7792 )
7793 }
7794
7795 fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
7796 self.state
7797 .placed_containers
7798 .iter()
7799 .find(|c| c.id == container_id)
7800 .map(|c| c.contents.clone())
7801 .unwrap_or_default()
7802 }
7803
7804 pub fn re_sheet_row_count(&self) -> usize {
7808 use crate::worker_route_editor::RouteEditorSheet as S;
7809 let Some(ed) = self.state.worker_route_editor.as_ref() else {
7810 return 0;
7811 };
7812 match &ed.sheet {
7813 S::Stops => ed.stops.len(),
7814 S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
7815 S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
7816 S::WaypointMapPick => 0,
7817 S::HarvestPicker { .. } => self.re_node_candidates().len(),
7818 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
7819 self.re_container_candidates().len()
7820 }
7821 S::WithdrawItems { lines, .. } => lines.len() + 1, S::DepositFilter { rows, .. } => rows.len() + 1, S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, S::SellItem { templates, .. } => templates.len() + 1, S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
7826 S::WaitEntry { .. } => 1,
7827 S::BedPicker { .. } => self.re_bed_candidates().len(),
7828 }
7829 }
7830
7831 pub fn re_sheet_index(&self) -> usize {
7833 use crate::worker_route_editor::RouteEditorSheet as S;
7834 let Some(ed) = self.state.worker_route_editor.as_ref() else {
7835 return 0;
7836 };
7837 match &ed.sheet {
7838 S::AddMenu { index }
7839 | S::WaypointMenu { index }
7840 | S::HarvestPicker { index }
7841 | S::WithdrawContainers { index }
7842 | S::DepositContainers { index }
7843 | S::SellNpcs { index }
7844 | S::CraftBlueprint { index }
7845 | S::BedPicker { index }
7846 | S::WithdrawItems { index, .. }
7847 | S::DepositFilter { index, .. }
7848 | S::SellItem { index, .. } => *index,
7849 _ => 0,
7850 }
7851 }
7852
7853 pub fn re_sheet_move(&mut self, delta: i32) {
7855 use crate::worker_route_editor::RouteEditorSheet as S;
7856 let count = self.re_sheet_row_count();
7857 if count == 0 {
7858 return;
7859 }
7860 let cur = self.re_sheet_index() as i32;
7861 let next = (cur + delta).rem_euclid(count as i32) as usize;
7862 let Some(ed) = self.state.worker_route_editor.as_mut() else {
7863 return;
7864 };
7865 match &mut ed.sheet {
7866 S::AddMenu { index }
7867 | S::WaypointMenu { index }
7868 | S::HarvestPicker { index }
7869 | S::WithdrawContainers { index }
7870 | S::DepositContainers { index }
7871 | S::SellNpcs { index }
7872 | S::CraftBlueprint { index }
7873 | S::BedPicker { index }
7874 | S::WithdrawItems { index, .. }
7875 | S::DepositFilter { index, .. }
7876 | S::SellItem { index, .. } => *index = next,
7877 _ => {}
7878 }
7879 }
7880
7881 pub fn re_sheet_adjust(&mut self, delta: i32) {
7883 use crate::worker_route_editor::RouteEditorSheet as S;
7884 let index = self.re_sheet_index();
7885 let Some(ed) = self.state.worker_route_editor.as_mut() else {
7886 return;
7887 };
7888 match &mut ed.sheet {
7889 S::WithdrawItems { lines, .. } => {
7890 if let Some(line) = lines.get_mut(index) {
7891 line.adjust_qty(delta);
7892 }
7893 }
7894 S::WaitEntry { ticks } => {
7895 *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
7896 }
7897 _ => {}
7898 }
7899 }
7900
7901 pub fn re_sheet_back(&mut self) {
7902 let Some(ed) = self.state.worker_route_editor.as_mut() else {
7903 return;
7904 };
7905 use crate::worker_route_editor::RouteEditorSheet as S;
7906 let was_editing = ed.editing_index.is_some();
7907 let from_top_picker = matches!(
7908 ed.sheet,
7909 S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
7910 );
7911 ed.sheet_back();
7912 if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
7913 self.state
7915 .push_log("Route: left edit sheet — press s to save current stops".to_string());
7916 }
7917 }
7918
7919 pub fn re_at_root_sheet(&self) -> bool {
7921 self.state
7922 .worker_route_editor
7923 .as_ref()
7924 .is_some_and(|ed| matches!(ed.sheet, crate::worker_route_editor::RouteEditorSheet::Stops))
7925 }
7926
7927 pub fn re_open_add_menu(&mut self) {
7928 if let Some(ed) = self.state.worker_route_editor.as_mut() {
7929 ed.open_add_menu();
7930 }
7931 }
7932
7933 pub fn re_open_bed_picker(&mut self) {
7934 let beds = self.re_bed_candidates();
7935 if beds.is_empty() {
7936 self.state
7937 .push_log("Route: place a camp bed first".to_string());
7938 return;
7939 }
7940 let current = self
7941 .state
7942 .worker_route_editor
7943 .as_ref()
7944 .and_then(|ed| ed.lodging_container_id.clone());
7945 let index = current
7946 .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
7947 .unwrap_or(0);
7948 self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
7949 }
7950
7951 fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
7952 if let Some(ed) = self.state.worker_route_editor.as_mut() {
7953 ed.open_sheet(sheet);
7954 }
7955 }
7956
7957 fn re_confirm_stop(
7959 &mut self,
7960 stop: crate::worker_route_editor::WorkerRouteStop,
7961 what: String,
7962 ) {
7963 let appended = self
7964 .state
7965 .worker_route_editor
7966 .as_mut()
7967 .is_some_and(|ed| ed.confirm_stop(stop));
7968 if appended {
7969 self.state.push_log(format!("Route: + {what}"));
7970 } else {
7971 self.state
7972 .push_log(format!("Route: {what} already in route — selected it"));
7973 }
7974 }
7975
7976 fn re_open_withdraw_items(&mut self, container_id: String) {
7977 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
7978 let contents = self.re_container_contents(&container_id);
7979 let existing = self
7983 .state
7984 .worker_route_editor
7985 .as_ref()
7986 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
7987 .and_then(|stop| match stop {
7988 WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
7989 _ => None,
7990 })
7991 .unwrap_or_default();
7992 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
7993 if let Some(ed) = self.state.worker_route_editor.as_mut() {
7996 let _ = ed.retarget_withdraw_container(container_id.clone());
7997 }
7998 self.re_open_sheet(S::WithdrawItems {
7999 container_id,
8000 lines,
8001 index: 0,
8002 });
8003 }
8004
8005 fn re_withdraw_items_activate(&mut self, index: usize) {
8006 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
8007 enum Outcome {
8008 Cycled,
8009 Confirmed(String),
8010 Empty,
8011 }
8012 let outcome = {
8013 let Some(ed) = self.state.worker_route_editor.as_mut() else {
8014 return;
8015 };
8016 let S::WithdrawItems {
8017 container_id,
8018 lines,
8019 index: sheet_index,
8020 } = &mut ed.sheet
8021 else {
8022 return;
8023 };
8024 *sheet_index = index;
8025 if index < lines.len() {
8026 lines[index].cycle();
8027 Outcome::Cycled
8028 } else {
8029 let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
8030 if items.is_empty() {
8031 Outcome::Empty
8032 } else {
8033 let stop = WorkerRouteStop::WithdrawFrom {
8034 container_id: container_id.clone(),
8035 items,
8036 };
8037 let summary = stop.summary();
8038 ed.confirm_stop(stop);
8039 Outcome::Confirmed(summary)
8040 }
8041 }
8042 };
8043 match outcome {
8044 Outcome::Cycled => {}
8045 Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
8046 Outcome::Empty => self
8047 .state
8048 .push_log("Route: pick at least one item (Space/Enter toggles All/qty)".to_string()),
8049 }
8050 }
8051
8052 fn re_open_deposit_filter(&mut self, container_id: String) {
8053 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8054 let existing_filter = self
8056 .state
8057 .worker_route_editor
8058 .as_ref()
8059 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
8060 .and_then(|stop| match stop {
8061 WorkerRouteStop::DepositAt { filter, .. } => {
8062 Some(filter.clone().unwrap_or_default())
8063 }
8064 _ => None,
8065 });
8066 let mut candidates = self.re_template_candidates();
8067 if let Some(ref chosen) = existing_filter {
8068 for t in chosen {
8069 if !candidates.iter().any(|c| c == t) {
8070 candidates.push(t.clone());
8071 }
8072 }
8073 candidates.sort();
8074 candidates.dedup();
8075 }
8076 let rows: Vec<(String, bool)> = match existing_filter {
8077 Some(chosen) => candidates
8078 .iter()
8079 .map(|t| (t.clone(), chosen.contains(t)))
8080 .collect(),
8081 None => candidates.into_iter().map(|t| (t, false)).collect(),
8082 };
8083 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8084 let _ = ed.retarget_deposit_container(container_id.clone());
8085 }
8086 self.re_open_sheet(S::DepositFilter {
8087 container_id,
8088 rows,
8089 index: 0,
8090 });
8091 }
8092
8093 fn re_deposit_filter_activate(&mut self, index: usize) {
8094 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8095 let mut confirmed: Option<String> = None;
8096 {
8097 let Some(ed) = self.state.worker_route_editor.as_mut() else {
8098 return;
8099 };
8100 let S::DepositFilter {
8101 container_id,
8102 rows,
8103 index: sheet_index,
8104 } = &mut ed.sheet
8105 else {
8106 return;
8107 };
8108 *sheet_index = index;
8109 if index < rows.len() {
8110 rows[index].1 = !rows[index].1;
8111 } else {
8112 let chosen: Vec<String> = rows
8114 .iter()
8115 .filter(|(_, on)| *on)
8116 .map(|(t, _)| t.clone())
8117 .collect();
8118 let filter = if chosen.is_empty() { None } else { Some(chosen) };
8119 let stop = WorkerRouteStop::DepositAt {
8120 container_id: container_id.clone(),
8121 filter,
8122 };
8123 confirmed = Some(stop.summary());
8124 ed.confirm_stop(stop);
8125 }
8126 }
8127 if let Some(what) = confirmed {
8128 self.state.push_log(format!("Route: + {what}"));
8129 }
8130 }
8131
8132 fn re_open_sell_item(&mut self, npc_id: Option<String>) {
8133 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8134 let templates = self.re_template_candidates();
8135 if templates.is_empty() {
8136 self.state.push_log(
8137 "Route: no item templates available — learn a craft recipe or place a harvest node first"
8138 .to_string(),
8139 );
8140 return;
8141 }
8142 let (pre_npc, pre_template, pre_all) = self
8144 .state
8145 .worker_route_editor
8146 .as_ref()
8147 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
8148 .and_then(|stop| match stop {
8149 WorkerRouteStop::TradeWith {
8150 npc_id,
8151 template,
8152 sell_all,
8153 } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
8154 _ => None,
8155 })
8156 .unwrap_or((None, None, true));
8157 let npc_id = npc_id.or(pre_npc);
8158 let index = pre_template
8159 .and_then(|t| templates.iter().position(|x| x == &t))
8160 .map(|i| i + 1) .unwrap_or(1);
8162 self.re_open_sheet(S::SellItem {
8163 npc_id,
8164 templates,
8165 index,
8166 sell_all: pre_all,
8167 });
8168 }
8169
8170 fn re_sell_item_activate(&mut self, index: usize) {
8171 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8172 let mut confirmed: Option<String> = None;
8173 {
8174 let Some(ed) = self.state.worker_route_editor.as_mut() else {
8175 return;
8176 };
8177 let S::SellItem {
8178 npc_id,
8179 templates,
8180 index: sheet_index,
8181 sell_all,
8182 } = &mut ed.sheet
8183 else {
8184 return;
8185 };
8186 *sheet_index = index;
8187 if index == 0 {
8188 *sell_all = !*sell_all;
8189 } else if let Some(template) = templates.get(index - 1).cloned() {
8190 let stop = WorkerRouteStop::TradeWith {
8191 npc_id: npc_id.clone(),
8192 template,
8193 sell_all: *sell_all,
8194 };
8195 confirmed = Some(stop.summary());
8196 ed.confirm_stop(stop);
8197 }
8198 }
8199 if let Some(what) = confirmed {
8200 self.state.push_log(format!("Route: + {what}"));
8201 }
8202 }
8203
8204 pub fn re_edit_selected_stop(&mut self) {
8206 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8207 let Some(stop) = self
8208 .state
8209 .worker_route_editor
8210 .as_ref()
8211 .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
8212 else {
8213 self.state
8214 .push_log("Route: no stop selected — press a to add one".to_string());
8215 return;
8216 };
8217 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8218 ed.begin_edit_selected();
8219 }
8220 match stop {
8221 WorkerRouteStop::Waypoint { .. } => {
8222 self.re_open_sheet(S::WaypointMenu { index: 0 });
8223 }
8224 WorkerRouteStop::HarvestNode { node_id } => {
8225 let nodes = self.re_node_candidates();
8226 let index = nodes.iter().position(|n| n.id == node_id).unwrap_or(0);
8227 if nodes.is_empty() {
8228 self.re_cancel_edit();
8229 self.state
8230 .push_log("Route: no harvestable nodes visible to retarget".to_string());
8231 } else {
8232 self.re_open_sheet(S::HarvestPicker { index });
8233 }
8234 }
8235 WorkerRouteStop::WithdrawFrom { container_id, .. } => {
8236 let containers = self.re_container_candidates();
8239 if containers.is_empty() {
8240 self.re_cancel_edit();
8241 self.state
8242 .push_log("Route: place a storage chest first".to_string());
8243 } else {
8244 let index = containers
8245 .iter()
8246 .position(|c| c.id == container_id)
8247 .unwrap_or(0);
8248 self.re_open_sheet(S::WithdrawContainers { index });
8249 }
8250 }
8251 WorkerRouteStop::DepositAt { container_id, .. } => {
8252 let containers = self.re_container_candidates();
8253 if containers.is_empty() {
8254 self.re_cancel_edit();
8255 self.state
8256 .push_log("Route: place a storage chest first".to_string());
8257 } else {
8258 let index = containers
8259 .iter()
8260 .position(|c| c.id == container_id)
8261 .unwrap_or(0);
8262 self.re_open_sheet(S::DepositContainers { index });
8263 }
8264 }
8265 WorkerRouteStop::TradeWith { npc_id, .. } => {
8266 let npcs = self.re_npc_candidates();
8267 let index = npc_id
8269 .as_ref()
8270 .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
8271 .unwrap_or(0);
8272 self.re_open_sheet(S::SellNpcs { index });
8273 }
8274 WorkerRouteStop::CraftAt { blueprint, .. } => {
8275 let bps = self.re_blueprint_ids();
8276 let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
8277 if bps.is_empty() {
8278 self.re_cancel_edit();
8279 self.state
8280 .push_log("Route: no known blueprints to retarget".to_string());
8281 } else {
8282 self.re_open_sheet(S::CraftBlueprint { index });
8283 }
8284 }
8285 WorkerRouteStop::RestIfNeeded => {
8286 self.re_cancel_edit();
8287 self.state
8288 .push_log("Route: rest has no settings (change the bed with l)".to_string());
8289 }
8290 WorkerRouteStop::Wait { wait_ticks } => {
8291 self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
8292 }
8293 }
8294 }
8295
8296 fn re_cancel_edit(&mut self) {
8297 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8298 ed.editing_index = None;
8299 }
8300 }
8301
8302 pub fn worker_route_editor_ui_click(
8305 &mut self,
8306 click: crate::worker_route_editor::RouteEditorClick,
8307 ) {
8308 use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
8309 match click {
8310 RouteEditorClick::SelectStop(i) => {
8311 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8312 ed.sheet = S::Stops;
8313 ed.select_stop(i);
8314 }
8315 }
8316 RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
8317 RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
8318 RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
8319 }
8320 }
8321
8322 pub fn re_sheet_row_activate(&mut self, row: usize) {
8324 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
8325 let Some(sheet) = self
8326 .state
8327 .worker_route_editor
8328 .as_ref()
8329 .map(|ed| ed.sheet.clone())
8330 else {
8331 return;
8332 };
8333 match sheet {
8334 S::Stops => {
8335 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8336 ed.select_stop(row);
8337 }
8338 }
8339 S::AddMenu { .. } => match row {
8340 0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
8341 1 => {
8342 if self.re_node_candidates().is_empty() {
8343 self.state
8344 .push_log("Route: no harvestable nodes visible in this region".to_string());
8345 } else {
8346 self.re_open_sheet(S::HarvestPicker { index: 0 });
8347 }
8348 }
8349 2 | 3 => {
8350 if self.re_container_candidates().is_empty() {
8351 self.state
8352 .push_log("Route: place a storage chest first".to_string());
8353 } else if row == 2 {
8354 self.re_open_sheet(S::WithdrawContainers { index: 0 });
8355 } else {
8356 self.re_open_sheet(S::DepositContainers { index: 0 });
8357 }
8358 }
8359 4 => {
8360 if self.re_template_candidates().is_empty() {
8361 self.state.push_log(
8362 "Route: no item templates available — learn a craft recipe or place a harvest node first"
8363 .to_string(),
8364 );
8365 } else {
8366 self.re_open_sheet(S::SellNpcs { index: 0 });
8367 }
8368 }
8369 5 => {
8370 if self.re_blueprint_ids().is_empty() {
8371 self.state.push_log(
8372 "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
8373 .to_string(),
8374 );
8375 } else {
8376 self.re_open_sheet(S::CraftBlueprint { index: 0 });
8377 }
8378 }
8379 6 => self.re_confirm_stop(
8380 WorkerRouteStop::RestIfNeeded,
8381 "rest if needed".into(),
8382 ),
8383 7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
8384 _ => {}
8385 },
8386 S::WaypointMenu { .. } => match row {
8387 0 => {
8388 let (x, y, z) = self.state.player_position_with_z();
8389 let stop = WorkerRouteStop::Waypoint { x, y, z };
8390 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
8391 }
8392 1 => {
8393 self.re_open_sheet(S::WaypointMapPick);
8394 self.state.push_log("Route: click the map to place the waypoint (Esc to finish)".to_string());
8395 }
8396 _ => {}
8397 },
8398 S::HarvestPicker { .. } => {
8399 let nodes = self.re_node_candidates();
8400 if let Some(n) = nodes.get(row) {
8401 let stop = WorkerRouteStop::HarvestNode {
8402 node_id: n.id.clone(),
8403 };
8404 let label = n.label.clone();
8405 self.re_confirm_stop(stop, format!("harvest {label}"));
8406 }
8407 }
8408 S::WithdrawContainers { .. } => {
8409 let containers = self.re_container_candidates();
8410 if let Some(c) = containers.get(row) {
8411 let id = c.id.clone();
8412 self.re_open_withdraw_items(id);
8413 }
8414 }
8415 S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
8416 S::DepositContainers { .. } => {
8417 let containers = self.re_container_candidates();
8418 if let Some(c) = containers.get(row) {
8419 let id = c.id.clone();
8420 self.re_open_deposit_filter(id);
8421 }
8422 }
8423 S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
8424 S::SellNpcs { .. } => {
8425 let npcs = self.re_npc_candidates();
8426 let npc_id = if row == 0 {
8427 None
8428 } else {
8429 npcs.get(row - 1).map(|n| n.id.clone())
8430 };
8431 if row == 0 || npc_id.is_some() {
8432 self.re_open_sell_item(npc_id);
8433 }
8434 }
8435 S::SellItem { .. } => self.re_sell_item_activate(row),
8436 S::CraftBlueprint { .. } => {
8437 let bps = self.re_blueprint_ids();
8438 if let Some(bp) = bps.get(row) {
8439 let stop = WorkerRouteStop::CraftAt {
8440 device: "hand".into(),
8441 blueprint: bp.clone(),
8442 qty: None,
8443 };
8444 self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
8445 }
8446 }
8447 S::WaitEntry { ticks } => {
8448 let stop = WorkerRouteStop::Wait {
8449 wait_ticks: ticks,
8450 };
8451 self.re_confirm_stop(stop, format!("wait {ticks}t"));
8452 }
8453 S::BedPicker { .. } => {
8454 let beds = self.re_bed_candidates();
8455 if let Some((id, name)) = beds.get(row) {
8456 let (id, name) = (id.clone(), name.clone());
8457 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8458 ed.lodging_container_id = Some(id.clone());
8459 ed.sheet = S::Stops;
8460 }
8461 self.state
8462 .push_log(format!("Route: rest bed set to {name}"));
8463 }
8464 }
8465 S::WaypointMapPick => {}
8466 }
8467 }
8468
8469 pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
8476 use crate::worker_route_editor as wre;
8477 use wre::RouteEditorSheet as S;
8478 if self.state.worker_route_editor.is_none() {
8479 return;
8480 }
8481 let sheet = self
8482 .state
8483 .worker_route_editor
8484 .as_ref()
8485 .map(|ed| ed.sheet.clone())
8486 .unwrap_or(S::Stops);
8487 match sheet {
8488 S::WaypointMapPick => {
8489 let (_, _, z) = self.state.player_position_with_z();
8490 let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
8491 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
8492 let editing = self
8494 .state
8495 .worker_route_editor
8496 .as_ref()
8497 .is_some_and(|ed| ed.editing_index.is_some());
8498 if !editing {
8499 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8500 ed.sheet = S::WaypointMapPick;
8501 }
8502 }
8503 }
8504 S::HarvestPicker { .. } => {
8505 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
8506 let stop = wre::WorkerRouteStop::HarvestNode {
8507 node_id: node.id.clone(),
8508 };
8509 let label = node.label.clone();
8510 self.re_confirm_stop(stop, format!("harvest {label}"));
8511 }
8512 }
8513 S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
8514 if let Some(cid) = wre::pick_storage_container_at(
8516 &self.state.placed_containers,
8517 self.state.character_id,
8518 x,
8519 y,
8520 ) {
8521 self.re_open_withdraw_items(cid);
8522 }
8523 }
8524 S::DepositContainers { .. } | S::DepositFilter { .. } => {
8525 if let Some(cid) = wre::pick_storage_container_at(
8526 &self.state.placed_containers,
8527 self.state.character_id,
8528 x,
8529 y,
8530 ) {
8531 self.re_open_deposit_filter(cid);
8532 }
8533 }
8534 S::SellNpcs { .. } => {
8535 if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
8536 self.re_open_sell_item(Some(npc_id));
8537 }
8538 }
8539 S::SellItem { .. } => {
8540 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
8541 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8542 if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
8543 *slot = Some(npc_id.clone());
8544 }
8545 }
8546 self.state
8547 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
8548 }
8549 }
8550 _ => self.worker_route_editor_quick_add_click(x, y),
8552 }
8553 }
8554
8555 fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
8559 use crate::worker_route_editor as wre;
8560 let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
8561 let dx = ax - bx;
8562 let dy = ay - by;
8563 (dx * dx + dy * dy).sqrt()
8564 };
8565
8566 let selected_stop_kind = self
8569 .state
8570 .worker_route_editor
8571 .as_ref()
8572 .and_then(|ed| ed.stops.get(ed.selected_stop_index))
8573 .map(|s| match s {
8574 wre::WorkerRouteStop::TradeWith { .. } => 1,
8575 wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
8576 _ => 0,
8577 })
8578 .unwrap_or(0);
8579 if selected_stop_kind == 1 {
8580 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
8581 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8582 ed.set_selected_trade_npc(npc_id.clone());
8583 }
8584 self.state
8585 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
8586 return;
8587 }
8588 }
8589 if selected_stop_kind == 2 {
8590 if let Some(cid) = wre::pick_storage_container_at(
8591 &self.state.placed_containers,
8592 self.state.character_id,
8593 x,
8594 y,
8595 ) {
8596 let name = self
8597 .state
8598 .placed_containers
8599 .iter()
8600 .find(|c| c.id == cid)
8601 .map(|c| c.display_name.clone())
8602 .unwrap_or_else(|| "container".into());
8603 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8604 ed.set_selected_withdraw_container(cid.clone());
8605 }
8606 self.state
8607 .push_log(format!("Route: withdraw source → {name}"));
8608 return;
8609 }
8610 }
8611
8612 enum Target {
8615 Bed(String),
8616 Container(String),
8617 Npc(String, String),
8618 Node(String, String),
8619 }
8620 let mut best: Option<(f32, u8, Target)> = None;
8621 let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
8622 let better = match best {
8623 None => true,
8624 Some((bd, brank, _)) => d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank),
8625 };
8626 if better {
8627 *best = Some((d, rank, t));
8628 }
8629 };
8630 if let Some(bed_id) = wre::pick_lodging_container_at(
8631 &self.state.placed_containers,
8632 self.state.character_id,
8633 x,
8634 y,
8635 ) {
8636 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
8637 let already_bed = self
8640 .state
8641 .worker_route_editor
8642 .as_ref()
8643 .is_some_and(|ed| ed.lodging_container_id.as_deref() == Some(bed_id.as_str()));
8644 if already_bed {
8645 consider(dist(x, y, c.x, c.y), 1, Target::Container(bed_id), &mut best);
8646 } else {
8647 consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
8648 }
8649 }
8650 }
8651 if let Some(cid) = wre::pick_storage_container_at(
8652 &self.state.placed_containers,
8653 self.state.character_id,
8654 x,
8655 y,
8656 ) {
8657 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
8658 consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
8659 }
8660 }
8661 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
8662 if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
8663 consider(
8664 dist(x, y, n.x, n.y),
8665 2,
8666 Target::Npc(npc_id, label),
8667 &mut best,
8668 );
8669 }
8670 }
8671 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
8672 let d = dist(x, y, node.x, node.y);
8673 consider(
8674 d,
8675 3,
8676 Target::Node(node.id.clone(), node.label.clone()),
8677 &mut best,
8678 );
8679 }
8680
8681 match best.map(|(_, _, t)| t) {
8682 Some(Target::Bed(bed_id)) => {
8683 let name = self
8684 .state
8685 .placed_containers
8686 .iter()
8687 .find(|c| c.id == bed_id)
8688 .map(|c| c.display_name.clone())
8689 .unwrap_or_else(|| "camp bed".into());
8690 if let Some(ed) = self.state.worker_route_editor.as_mut() {
8691 ed.lodging_container_id = Some(bed_id.clone());
8692 }
8693 self.state
8694 .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
8695 }
8696 Some(Target::Container(cid)) => {
8697 let name = self
8698 .state
8699 .placed_containers
8700 .iter()
8701 .find(|c| c.id == cid)
8702 .map(|c| c.display_name.clone())
8703 .unwrap_or_else(|| "container".into());
8704 let added = self
8705 .state
8706 .worker_route_editor
8707 .as_mut()
8708 .is_some_and(|ed| ed.append_deposit_at(&cid));
8709 if added {
8710 self.state
8711 .push_log(format!("Route: + deposit at {name} ({cid})"));
8712 } else {
8713 self.state.push_log(format!(
8714 "Route: {name} already in route — selected it (d to remove)"
8715 ));
8716 }
8717 }
8718 Some(Target::Npc(npc_id, label)) => {
8719 let template = self.re_template_candidates().into_iter().next();
8722 let Some(template) = template else {
8723 self.state.push_log("Route: no items in your storage to sell — stock a chest first".to_string());
8724 return;
8725 };
8726 let added = self
8727 .state
8728 .worker_route_editor
8729 .as_mut()
8730 .is_some_and(|ed| ed.append_trade_with(template.clone(), Some(npc_id.clone()), true));
8731 if added {
8732 self.state
8733 .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
8734 } else {
8735 self.state.push_log(format!(
8736 "Route: {label} already sells {template} — selected it (d to remove)"
8737 ));
8738 }
8739 }
8740 Some(Target::Node(id, label)) => {
8741 let added = self
8742 .state
8743 .worker_route_editor
8744 .as_mut()
8745 .is_some_and(|ed| ed.append_harvest_node(&id));
8746 if added {
8747 self.state
8748 .push_log(format!("Route: + harvest node {label} ({id})"));
8749 } else {
8750 self.state.push_log(format!(
8751 "Route: {label} already in route — selected it (d to remove)"
8752 ));
8753 }
8754 }
8755 None => {}
8756 }
8757 }
8758
8759 pub fn worker_route_editor_select(&mut self, delta: i32) {
8760 let Some(ed) = self.state.worker_route_editor.as_mut() else {
8761 return;
8762 };
8763 if ed.stops.is_empty() {
8764 return;
8765 }
8766 let n = ed.stops.len() as i32;
8767 let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
8768 ed.selected_stop_index = next;
8769 }
8770
8771 pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
8772 let Some(ed) = self.state.worker_route_editor.as_mut() else {
8773 return;
8774 };
8775 if delta < 0 {
8776 ed.move_selected_up();
8777 } else if delta > 0 {
8778 ed.move_selected_down();
8779 }
8780 }
8781
8782 pub fn worker_route_editor_delete_selected(&mut self) {
8783 let removed = self
8784 .state
8785 .worker_route_editor
8786 .as_mut()
8787 .is_some_and(|ed| {
8788 let before = ed.stop_count();
8789 ed.remove_selected_stop();
8790 ed.stop_count() < before
8791 });
8792 if removed {
8793 self.state.push_log("Route: removed selected stop");
8794 }
8795 }
8796
8797 pub fn worker_route_editor_clear_stops(&mut self) {
8800 let Some(ed) = self.state.worker_route_editor.as_mut() else {
8801 return;
8802 };
8803 if ed.stops.is_empty() {
8804 self.state.push_log("Route: already empty — s saves an idle worker".to_string());
8805 return;
8806 }
8807 ed.stops.clear();
8808 ed.selected_stop_index = 0;
8809 self.state
8810 .push_log("Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string());
8811 }
8812
8813 pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
8814 if self.state.pending_worker_job_ack.is_some() {
8815 anyhow::bail!("route save still pending — wait for server ack");
8816 }
8817 let Some(ed) = self.state.worker_route_editor.clone() else {
8818 anyhow::bail!("route editor not open");
8819 };
8820 let (job_yaml, idle) = if ed.stops.is_empty() {
8823 (ed.build_idle_job_yaml(), true)
8824 } else {
8825 (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
8826 };
8827 let worker_id = ed.worker_instance_id.clone();
8828 let route_view = if idle {
8829 None
8830 } else {
8831 Some(ed.to_route_view())
8832 };
8833 let mode = if idle {
8834 flatland_protocol::WorkerModeView::Idle
8835 } else {
8836 flatland_protocol::WorkerModeView::JobLoop
8837 };
8838 let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
8839 .state
8840 .hired_workers
8841 .iter()
8842 .find(|w| w.instance_id == worker_id)
8843 .map(|w| {
8844 (
8845 w.route.clone(),
8846 w.mode,
8847 w.step_label.clone(),
8848 w.last_error.clone(),
8849 )
8850 })
8851 .unwrap_or((
8852 None,
8853 flatland_protocol::WorkerModeView::Idle,
8854 String::new(),
8855 None,
8856 ));
8857 self.seq += 1;
8858 let seq = self.seq;
8859 self.session
8860 .submit_intent(Intent::SetWorkerJob {
8861 entity_id: self.state.entity_id,
8862 worker_instance_id: worker_id.clone(),
8863 job_yaml,
8864 seq,
8865 })
8866 .await?;
8867 self.state.intents_sent += 1;
8868 if let Some(w) = self
8869 .state
8870 .hired_workers
8871 .iter_mut()
8872 .find(|w| w.instance_id == worker_id)
8873 {
8874 w.route = route_view;
8875 w.mode = mode;
8876 w.last_error = None;
8877 if idle {
8878 w.step_label.clear();
8879 }
8880 }
8881 self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
8882 seq,
8883 worker_instance_id: worker_id,
8884 worker_label: ed.worker_label.clone(),
8885 idle,
8886 stop_count: ed.stops.len(),
8887 prev_route,
8888 prev_mode,
8889 prev_step_label,
8890 prev_last_error,
8891 });
8892 self.state.push_log(format!(
8893 "Route: saving for {}… (waiting for server)",
8894 ed.worker_label
8895 ));
8896 Ok(())
8898 }
8899 pub fn quest_menu_move(&mut self, delta: i32) {
8900 let n = self.state.active_quest_entries().len();
8901 if n == 0 {
8902 return;
8903 }
8904 let idx = self.state.quest_menu_index as i32;
8905 self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8906 }
8907
8908 pub fn quest_menu_page(&mut self, pages: i32) {
8909 let n = self.state.active_quest_entries().len();
8910 self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
8911 }
8912
8913 pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
8914 let Some(offer) = self.state.pending_quest_offer.clone() else {
8915 anyhow::bail!("no quest offer");
8916 };
8917 self.seq += 1;
8918 let seq = self.seq;
8919 self.session
8920 .submit_intent(Intent::AcceptQuest {
8921 entity_id: self.state.entity_id,
8922 quest_id: offer.quest_id,
8923 seq,
8924 })
8925 .await?;
8926 self.state.intents_sent += 1;
8927 Ok(())
8928 }
8929
8930 pub fn quest_offer_decline(&mut self) {
8931 self.state.show_quest_offer = false;
8932 self.state.pending_quest_offer = None;
8933 if !self.state.show_npc_chat
8934 && !self.state.show_shop_menu
8935 && self.state.npc_verb_target.is_some()
8936 {
8937 self.state.show_npc_verb_menu = true;
8938 }
8939 }
8940
8941 pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
8942 if !self.state.show_quest_menu {
8943 return Ok(());
8944 }
8945 let active: Vec<_> = self
8946 .state
8947 .active_quest_entries()
8948 .into_iter()
8949 .cloned()
8950 .collect();
8951 let Some(entry) = active.get(self.state.quest_menu_index) else {
8952 return Ok(());
8953 };
8954 if self.state.quest_withdraw_confirm {
8955 if !entry.can_withdraw {
8956 anyhow::bail!("quest cannot be withdrawn");
8957 }
8958 self.seq += 1;
8959 let seq = self.seq;
8960 self.session
8961 .submit_intent(Intent::WithdrawQuest {
8962 entity_id: self.state.entity_id,
8963 quest_id: entry.quest_id.clone(),
8964 seq,
8965 })
8966 .await?;
8967 self.state.intents_sent += 1;
8968 self.state.quest_withdraw_confirm = false;
8969 return Ok(());
8970 }
8971 self.seq += 1;
8972 let seq = self.seq;
8973 self.session
8974 .submit_intent(Intent::TrackQuest {
8975 entity_id: self.state.entity_id,
8976 quest_id: entry.quest_id.clone(),
8977 seq,
8978 })
8979 .await?;
8980 self.state.intents_sent += 1;
8981 Ok(())
8982 }
8983
8984 pub fn quest_request_withdraw(&mut self) {
8985 if self.state.show_quest_menu {
8986 self.state.quest_withdraw_confirm = true;
8987 }
8988 }
8989
8990 pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
8991 if !self.state.is_alive() {
8992 anyhow::bail!("you are dead");
8993 }
8994 let Some(catalog) = self.state.shop_catalog.clone() else {
8995 anyhow::bail!("no shop open");
8996 };
8997 self.seq += 1;
8998 let seq = self.seq;
8999 match self.state.shop_tab {
9000 ShopTab::Buy => {
9001 let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
9002 anyhow::bail!("nothing selected");
9003 };
9004 if offer.already_owned {
9005 anyhow::bail!("already owned");
9006 }
9007 self.session
9008 .submit_intent(Intent::ShopBuy {
9009 entity_id: self.state.entity_id,
9010 npc_id: catalog.npc_id.clone(),
9011 offer_id: offer.offer_id.clone(),
9012 quantity: self.state.shop_quantity,
9013 seq,
9014 })
9015 .await?;
9016 }
9017 ShopTab::Sell => {
9018 let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
9019 anyhow::bail!("nothing to sell");
9020 };
9021 if line.quantity == 0 {
9022 anyhow::bail!("you have no {}", line.label);
9023 }
9024 let quantity = self.state.shop_quantity.min(line.quantity).max(1);
9025 self.session
9026 .submit_intent(Intent::ShopSell {
9027 entity_id: self.state.entity_id,
9028 npc_id: catalog.npc_id.clone(),
9029 template_id: line.template_id.clone(),
9030 quantity,
9031 seq,
9032 })
9033 .await?;
9034 }
9035 }
9036 self.state.intents_sent += 1;
9037 Ok(())
9038 }
9039
9040 pub fn craft_menu_move(&mut self, delta: i32) {
9041 let n = self.state.blueprints.len();
9042 if n == 0 {
9043 return;
9044 }
9045 let idx = self.state.craft_menu_index as i32;
9046 let next = (idx + delta).rem_euclid(n as i32);
9047 self.state.craft_menu_index = next as usize;
9048 self.state.clamp_craft_batch_quantity();
9049 }
9050
9051 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
9052 self.state.craft_batch_adjust_quantity(delta);
9053 }
9054
9055 pub fn craft_batch_set_max(&mut self) {
9056 self.state.craft_batch_set_max();
9057 }
9058
9059 pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
9060 let Some(blueprint) = self
9061 .state
9062 .blueprints
9063 .get(self.state.craft_menu_index)
9064 .cloned()
9065 else {
9066 anyhow::bail!("no blueprints known");
9067 };
9068 if !self.state.can_craft_blueprint(&blueprint) {
9069 let hint = self
9070 .state
9071 .craft_missing_hint(&blueprint)
9072 .unwrap_or_else(|| "missing materials".into());
9073 anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
9074 }
9075 let count = self.state.craft_batch_quantity;
9076 let max = self.state.max_craft_batches(&blueprint);
9077 if max == 0 {
9078 anyhow::bail!("cannot craft {}", blueprint.label);
9079 }
9080 let batches = count.min(max);
9081 self.craft(&blueprint.id, Some(batches)).await?;
9082 self.state.show_craft_menu = false;
9083 Ok(())
9084 }
9085
9086 pub async fn move_by(
9087 &mut self,
9088 forward: f32,
9089 strafe: f32,
9090 vertical: f32,
9091 sprint: bool,
9092 ) -> anyhow::Result<()> {
9093 if !self.state.is_alive() {
9094 anyhow::bail!("you are dead");
9095 }
9096 if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
9097 self.last_move_forward = forward;
9098 self.last_move_strafe = strafe;
9099 }
9100 self.seq += 1;
9101 self.session
9102 .submit_intent(Intent::Move {
9103 entity_id: self.state.entity_id,
9104 forward,
9105 strafe,
9106 vertical,
9107 sprint,
9108 seq: self.seq,
9109 })
9110 .await?;
9111 self.state.intents_sent += 1;
9112 Ok(())
9113 }
9114
9115 pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
9116 if !self.state.connected {
9117 crate::harvest_trace!("harvest_nearest rejected: not connected");
9118 anyhow::bail!("not connected");
9119 }
9120 if !self.state.is_alive() {
9121 crate::harvest_trace!("harvest_nearest rejected: player dead");
9122 anyhow::bail!("you are dead");
9123 }
9124 if self.state.harvest_in_progress {
9125 if self.state.harvest_state_stale() {
9126 self.state.clear_harvest_state();
9127 } else {
9128 anyhow::bail!("already harvesting");
9129 }
9130 }
9131 let (px, py) = self
9132 .state
9133 .player
9134 .as_ref()
9135 .map(|p| (p.transform.position.x, p.transform.position.y))
9136 .unwrap_or((0.0, 0.0));
9137
9138 let available = self
9139 .state
9140 .resource_nodes
9141 .iter()
9142 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
9143 .count();
9144 let node_id = self
9145 .state
9146 .resource_nodes
9147 .iter()
9148 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
9149 .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
9150 .min_by(|a, b| {
9151 let da = distance(px, py, a.x, a.y);
9152 let db = distance(px, py, b.x, b.y);
9153 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
9154 })
9155 .map(|n| n.id.clone());
9156
9157 let Some(node_id) = node_id else {
9158 let has_loot = self
9159 .state
9160 .ground_drops
9161 .iter()
9162 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
9163 if has_loot {
9164 return self.pickup_nearest().await;
9165 }
9166 anyhow::bail!(
9167 "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
9168 );
9169 };
9170
9171 self.seq += 1;
9172 let seq = self.seq;
9173 crate::harvest_trace!(
9174 entity_id = self.state.entity_id,
9175 node_id = %node_id,
9176 seq,
9177 px,
9178 py,
9179 available_nodes = available,
9180 "submitting harvest intent"
9181 );
9182 self.session
9183 .submit_intent(Intent::Harvest {
9184 entity_id: self.state.entity_id,
9185 node_id,
9186 seq,
9187 })
9188 .await?;
9189 self.state.intents_sent += 1;
9190 self.state.harvest_in_progress = true;
9191 self.state.harvest_started_at = Some(Instant::now());
9192 self.state.push_log("Harvesting…");
9193 crate::harvest_trace!(
9194 entity_id = self.state.entity_id,
9195 seq,
9196 "harvest intent queued to session"
9197 );
9198 Ok(())
9199 }
9200
9201 pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
9202 if !self.state.is_alive() {
9203 anyhow::bail!("you are dead");
9204 }
9205 let blueprint_id = self
9206 .state
9207 .blueprints
9208 .iter()
9209 .find(|bp| self.state.can_craft_blueprint(bp))
9210 .map(|bp| bp.id.clone())
9211 .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
9212 self.craft(&blueprint_id, None).await
9213 }
9214
9215 pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
9216 if !self.state.is_alive() {
9217 anyhow::bail!("you are dead");
9218 }
9219 self.seq += 1;
9220 self.session
9221 .submit_intent(Intent::Craft {
9222 entity_id: self.state.entity_id,
9223 blueprint_id: blueprint_id.to_string(),
9224 count,
9225 seq: self.seq,
9226 })
9227 .await?;
9228 self.state.intents_sent += 1;
9229 let (label, batches) = self
9230 .state
9231 .blueprints
9232 .iter()
9233 .find(|b| b.id == blueprint_id)
9234 .map(|b| {
9235 let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
9236 (b.label.as_str(), n)
9237 })
9238 .unwrap_or((blueprint_id, count.unwrap_or(1)));
9239 self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
9240 Ok(())
9241 }
9242
9243 pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
9244 if !self.state.is_alive() {
9245 anyhow::bail!("you are dead");
9246 }
9247 let target_id = match self.state.nearest_interact_target() {
9248 Some(id) => id,
9249 None => {
9250 anyhow::bail!("nothing to interact with nearby");
9251 }
9252 };
9253 if self.state.npcs.iter().any(|n| n.id == target_id) {
9254 self.state.show_npc_verb_menu = true;
9255 self.state.npc_verb_target = Some(target_id);
9256 self.state.npc_verb_index = 0;
9257 return Ok(());
9258 }
9259 self.seq += 1;
9260 self.session
9261 .submit_intent(Intent::Interact {
9262 entity_id: self.state.entity_id,
9263 target_id: target_id.clone(),
9264 seq: self.seq,
9265 })
9266 .await?;
9267 self.state.intents_sent += 1;
9268 Ok(())
9269 }
9270
9271 pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
9273 if !self.state.is_alive() {
9274 anyhow::bail!("you are dead");
9275 }
9276 if self.state.nearest_interact_target().is_some() {
9277 return self.interact_nearest().await;
9278 }
9279 if let Some((label, dist)) = self.state.nearest_quest_board() {
9282 if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
9283 anyhow::bail!(
9284 "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
9285 );
9286 }
9287 }
9288 let (px, py) = self.state.player_position();
9289 let has_loot = self
9290 .state
9291 .ground_drops
9292 .iter()
9293 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
9294 if has_loot {
9295 return self.pickup_nearest().await;
9296 }
9297 if self
9298 .state
9299 .placed_containers
9300 .iter()
9301 .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
9302 {
9303 return self.pickup_nearest_container().await;
9304 }
9305 match self.harvest_nearest().await {
9306 Ok(()) => Ok(()),
9307 Err(err) => {
9308 let msg = err.to_string();
9309 if msg.contains("no harvestable")
9310 || msg.contains("press p")
9311 || msg.contains("press f")
9312 {
9313 anyhow::bail!(
9314 "nothing to use nearby — stand by an NPC/door, loot (*), chest, or resource"
9315 );
9316 }
9317 Err(err)
9318 }
9319 }
9320 }
9321
9322 pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
9324 if !self.state.is_alive() {
9325 anyhow::bail!("you are dead");
9326 }
9327 let ability_id = self
9328 .state
9329 .hotbar_ability(slot)
9330 .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
9331 .to_string();
9332 let target = if ability_id == "heal_touch" {
9333 Some(
9334 self.state
9335 .target_for_slot(2)
9336 .unwrap_or(self.state.entity_id),
9337 )
9338 } else {
9339 self.state
9340 .target_for_slot(1)
9341 .or_else(|| self.state.target_for_slot(2))
9342 };
9343 let Some(target_id) = target else {
9344 anyhow::bail!("no target — Tab to select, then press the hotbar key");
9345 };
9346 self.cast_ability(&ability_id, Some(target_id)).await
9347 }
9348
9349 pub async fn set_hotbar_slot(
9351 &mut self,
9352 slot: u8,
9353 ability_id: Option<&str>,
9354 ) -> anyhow::Result<()> {
9355 if !self.state.is_alive() {
9356 anyhow::bail!("you are dead");
9357 }
9358 if !(1..=9).contains(&slot) {
9359 anyhow::bail!("hotbar slot must be 1–9");
9360 }
9361 let ability_id = ability_id
9362 .map(str::trim)
9363 .filter(|id| !id.is_empty())
9364 .map(str::to_string);
9365 self.seq += 1;
9366 self.session
9367 .submit_intent(Intent::SetHotbarSlot {
9368 entity_id: self.state.entity_id,
9369 slot,
9370 ability_id: ability_id.clone(),
9371 seq: self.seq,
9372 })
9373 .await?;
9374 self.state.intents_sent += 1;
9375 let idx = (slot - 1) as usize;
9376 if self.state.hotbar.len() < 9 {
9377 self.state.hotbar.resize(9, None);
9378 }
9379 if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
9380 *slot_mut = ability_id.clone();
9381 }
9382 match ability_id {
9383 Some(id) => self.state.push_log(format!("Hotbar {slot} → {id}")),
9384 None => self.state.push_log(format!("Hotbar {slot} cleared")),
9385 }
9386 Ok(())
9387 }
9388
9389 pub fn npc_verb_options(&self) -> Vec<&'static str> {
9390 self.state.npc_verb_options()
9391 }
9392
9393 pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
9394 let Some(npc_id) = self.state.npc_verb_target.clone() else {
9395 return Ok(());
9396 };
9397 let options = self.npc_verb_options();
9398 let choice = options
9399 .get(self.state.npc_verb_index)
9400 .copied()
9401 .unwrap_or("Talk");
9402 self.seq += 1;
9403 match choice {
9404 "Trade" | "Bank" | "Storage" => {
9405 self.session
9406 .submit_intent(Intent::Interact {
9407 entity_id: self.state.entity_id,
9408 target_id: npc_id,
9409 seq: self.seq,
9410 })
9411 .await?;
9412 }
9413 _ => {
9414 self.session
9415 .submit_intent(Intent::NpcTalkOpen {
9416 entity_id: self.state.entity_id,
9417 npc_id,
9418 seq: self.seq,
9419 })
9420 .await?;
9421 }
9422 }
9423 self.state.intents_sent += 1;
9424 Ok(())
9425 }
9426
9427 pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
9428 let Some(chat) = self.state.npc_chat.clone() else {
9429 return Ok(());
9430 };
9431 let message = chat.input.trim().to_string();
9432 if message.is_empty() || chat.pending {
9433 return Ok(());
9434 }
9435 if let Some(c) = self.state.npc_chat.as_mut() {
9436 c.lines.push(format!("You: {message}"));
9437 c.input.clear();
9438 c.pending = true;
9439 }
9440 self.seq += 1;
9441 self.session
9442 .submit_intent(Intent::NpcTalkSay {
9443 entity_id: self.state.entity_id,
9444 npc_id: chat.npc_id,
9445 message,
9446 seq: self.seq,
9447 })
9448 .await?;
9449 self.state.intents_sent += 1;
9450 Ok(())
9451 }
9452
9453 pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
9454 let return_to_verbs = self.state.npc_verb_target.is_some();
9455 let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
9456 self.state.show_npc_chat = false;
9457 if return_to_verbs {
9458 self.state.show_npc_verb_menu = true;
9459 }
9460 return Ok(());
9461 };
9462 self.seq += 1;
9463 self.session
9464 .submit_intent(Intent::NpcTalkClose {
9465 entity_id: self.state.entity_id,
9466 npc_id,
9467 seq: self.seq,
9468 })
9469 .await?;
9470 self.state.intents_sent += 1;
9471 self.state.show_npc_chat = false;
9472 self.state.npc_chat = None;
9473 if return_to_verbs {
9474 self.state.show_npc_verb_menu = true;
9475 }
9476 Ok(())
9477 }
9478
9479 pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
9481 if self.state.show_quest_offer
9482 && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
9483 {
9484 self.quest_offer_decline();
9485 return Ok(());
9486 }
9487 if self.state.show_npc_chat {
9488 return self.npc_talk_close().await;
9489 }
9490 if self.state.show_shop_menu {
9491 return self.back_from_shop_menu().await;
9492 }
9493 if self.state.bank_panel.is_some() {
9494 if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
9495 self.bank_transfer_back();
9496 return Ok(());
9497 }
9498 return self.close_bank_panel().await;
9499 }
9500 if self.state.storage_panel.is_some() {
9501 if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
9502 self.storage_ui_back();
9503 return Ok(());
9504 }
9505 return self.close_storage_panel().await;
9506 }
9507 if self.state.show_npc_verb_menu {
9508 self.state.show_npc_verb_menu = false;
9509 self.state.npc_verb_target = None;
9510 }
9511 Ok(())
9512 }
9513
9514 pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
9515 self.seq += 1;
9516 self.session
9517 .submit_intent(Intent::TestDamage {
9518 entity_id: self.state.entity_id,
9519 amount,
9520 seq: self.seq,
9521 })
9522 .await?;
9523 self.state.intents_sent += 1;
9524 Ok(())
9525 }
9526
9527 pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
9528 self.cycle_combat_target_slot(1, reverse).await
9529 }
9530
9531 pub async fn cycle_combat_target_slot(
9532 &mut self,
9533 slot_index: u8,
9534 reverse: bool,
9535 ) -> anyhow::Result<()> {
9536 if !self.state.is_alive() {
9537 anyhow::bail!("you are dead");
9538 }
9539 let candidates = self.state.candidates_for_slot(slot_index);
9540 if candidates.is_empty() {
9541 anyhow::bail!("no targets nearby");
9542 }
9543 let current = self.state.target_for_slot(slot_index);
9544 let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
9545 let next_idx = match idx {
9546 None => 0,
9547 Some(i) if reverse => {
9548 if i == 0 {
9549 candidates.len() - 1
9550 } else {
9551 i - 1
9552 }
9553 }
9554 Some(i) => (i + 1) % candidates.len(),
9555 };
9556 if idx == Some(next_idx) && candidates.len() == 1 {
9557 self.clear_combat_target_slot(slot_index).await?;
9558 return Ok(());
9559 }
9560 let (target_id, label) = candidates[next_idx].clone();
9561 self.set_combat_target_slot(slot_index, target_id, &label)
9562 .await
9563 }
9564
9565 pub async fn set_combat_target_slot(
9566 &mut self,
9567 slot_index: u8,
9568 target_id: EntityId,
9569 label: &str,
9570 ) -> anyhow::Result<()> {
9571 if !self.state.is_alive() {
9572 anyhow::bail!("you are dead");
9573 }
9574 self.seq += 1;
9575 self.session
9576 .submit_intent(Intent::SetTargetSlot {
9577 entity_id: self.state.entity_id,
9578 slot_index,
9579 target_id,
9580 seq: self.seq,
9581 })
9582 .await?;
9583 self.state.intents_sent += 1;
9584 if slot_index == 1 {
9585 self.state.combat_target = Some(target_id);
9586 self.state.combat_target_label = Some(label.to_string());
9587 }
9588 self.state
9589 .push_log(format!("Slot {slot_index} target: {label}"));
9590 Ok(())
9591 }
9592
9593 pub async fn set_combat_target(
9594 &mut self,
9595 target_id: EntityId,
9596 label: &str,
9597 ) -> anyhow::Result<()> {
9598 self.set_combat_target_slot(1, target_id, label).await
9599 }
9600
9601 pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
9602 if slot_index == 1 && self.state.combat_target.is_none() {
9603 return Ok(());
9604 }
9605 self.seq += 1;
9606 self.session
9607 .submit_intent(Intent::ClearTargetSlot {
9608 entity_id: self.state.entity_id,
9609 slot_index,
9610 seq: self.seq,
9611 })
9612 .await?;
9613 if slot_index == 1 {
9614 self.state.combat_target = None;
9615 self.state.combat_target_label = None;
9616 }
9617 self.state.intents_sent += 1;
9618 self.state
9619 .push_log(format!("Slot {slot_index} target cleared"));
9620 Ok(())
9621 }
9622
9623 pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
9624 self.clear_combat_target_slot(1).await
9625 }
9626
9627 pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
9628 if !self.state.is_alive() {
9629 anyhow::bail!("you are dead");
9630 }
9631 self.seq += 1;
9632 self.session
9633 .submit_intent(Intent::AdvanceRotation {
9634 entity_id: self.state.entity_id,
9635 slot_index,
9636 seq: self.seq,
9637 })
9638 .await?;
9639 self.state.intents_sent += 1;
9640 Ok(())
9641 }
9642
9643 pub async fn assign_slot_preset(
9644 &mut self,
9645 slot_index: u8,
9646 preset_id: &str,
9647 ) -> anyhow::Result<()> {
9648 if !self.state.is_alive() {
9649 anyhow::bail!("you are dead");
9650 }
9651 self.seq += 1;
9652 self.session
9653 .submit_intent(Intent::AssignSlotPreset {
9654 entity_id: self.state.entity_id,
9655 slot_index,
9656 preset_id: preset_id.to_string(),
9657 seq: self.seq,
9658 })
9659 .await?;
9660 self.state.intents_sent += 1;
9661 if let Some(slot) = self
9662 .state
9663 .combat_slots
9664 .iter_mut()
9665 .find(|s| s.slot_index == slot_index)
9666 {
9667 slot.preset_id = Some(preset_id.to_string());
9668 if let Some(preset) = self
9669 .state
9670 .rotation_presets
9671 .iter()
9672 .find(|p| p.id == preset_id)
9673 {
9674 slot.preset_label = Some(preset.label.clone());
9675 slot.rotation = preset.abilities.clone();
9676 slot.rotation_index = 0;
9677 }
9678 }
9679 self.state
9680 .push_log(format!("T{slot_index} loadout → {preset_id}"));
9681 Ok(())
9682 }
9683
9684 pub async fn cast_ability(
9685 &mut self,
9686 ability_id: &str,
9687 target_id: Option<EntityId>,
9688 ) -> anyhow::Result<()> {
9689 if !self.state.is_alive() {
9690 anyhow::bail!("you are dead");
9691 }
9692 let target_id = target_id
9693 .or_else(|| self.state.target_for_slot(2))
9694 .or_else(|| self.state.target_for_slot(1))
9695 .unwrap_or(self.state.entity_id);
9696 self.seq += 1;
9697 self.session
9698 .submit_intent(Intent::Cast {
9699 entity_id: self.state.entity_id,
9700 ability_id: ability_id.to_string(),
9701 target_id,
9702 seq: self.seq,
9703 })
9704 .await?;
9705 self.state.intents_sent += 1;
9706 self.state
9707 .push_log(format!("Cast {ability_id} → {target_id}"));
9708 Ok(())
9709 }
9710
9711 pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
9712 self.seq += 1;
9713 self.session
9714 .submit_intent(Intent::UpsertRotationPreset {
9715 entity_id: self.state.entity_id,
9716 preset: preset.clone(),
9717 seq: self.seq,
9718 })
9719 .await?;
9720 self.state.intents_sent += 1;
9721 if let Some(existing) = self
9722 .state
9723 .rotation_presets
9724 .iter_mut()
9725 .find(|p| p.id == preset.id)
9726 {
9727 *existing = preset.clone();
9728 } else {
9729 self.state.rotation_presets.push(preset.clone());
9730 }
9731 for slot in &mut self.state.combat_slots {
9732 if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
9733 slot.preset_label = Some(preset.label.clone());
9734 slot.rotation = preset.abilities.clone();
9735 }
9736 }
9737 self.state
9738 .push_log(format!("Saved rotation: {}", preset.label));
9739 Ok(())
9740 }
9741
9742 pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
9743 self.seq += 1;
9744 self.session
9745 .submit_intent(Intent::DeleteRotationPreset {
9746 entity_id: self.state.entity_id,
9747 preset_id: preset_id.to_string(),
9748 seq: self.seq,
9749 })
9750 .await?;
9751 self.state.intents_sent += 1;
9752 self.state.rotation_presets.retain(|p| p.id != preset_id);
9753 for slot in &mut self.state.combat_slots {
9754 if slot.preset_id.as_deref() == Some(preset_id) {
9755 slot.preset_id = None;
9756 slot.preset_label = None;
9757 slot.rotation.clear();
9758 slot.rotation_index = 0;
9759 }
9760 }
9761 self.state
9762 .push_log(format!("Deleted rotation: {preset_id}"));
9763 Ok(())
9764 }
9765
9766 pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
9767 if !self.state.is_alive() {
9768 anyhow::bail!("you are dead");
9769 }
9770 let enabled = !self
9771 .state
9772 .combat_slots
9773 .iter()
9774 .find(|s| s.slot_index == slot_index)
9775 .map(|s| s.auto_enabled)
9776 .unwrap_or(false);
9777 self.seq += 1;
9778 self.session
9779 .submit_intent(Intent::SetAutoAttack {
9780 entity_id: self.state.entity_id,
9781 slot_index,
9782 enabled,
9783 seq: self.seq,
9784 })
9785 .await?;
9786 if slot_index == 1 {
9787 self.state.auto_attack = enabled;
9788 }
9789 self.state.intents_sent += 1;
9790 self.state.push_log(format!(
9791 "T{slot_index} auto {}",
9792 if enabled { "ON" } else { "OFF" }
9793 ));
9794 Ok(())
9795 }
9796
9797 pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
9798 if !self.state.connected {
9799 anyhow::bail!("not connected");
9800 }
9801 if !self.state.is_alive() {
9802 anyhow::bail!("you are dead");
9803 }
9804 let (px, py) = self.state.player_position();
9805 if self
9806 .state
9807 .ground_drops
9808 .iter()
9809 .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
9810 {
9811 anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
9812 }
9813 self.seq += 1;
9814 self.session
9815 .submit_intent(Intent::Pickup {
9816 entity_id: self.state.entity_id,
9817 drop_id: None,
9818 seq: self.seq,
9819 })
9820 .await?;
9821 self.state.intents_sent += 1;
9822 Ok(())
9823 }
9824
9825 pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
9826 self.toggle_auto_attack_slot(1).await
9827 }
9828
9829 pub async fn dodge(&mut self) -> anyhow::Result<()> {
9830 if !self.state.is_alive() {
9831 anyhow::bail!("you are dead");
9832 }
9833 self.seq += 1;
9834 self.session
9835 .submit_intent(Intent::Dodge {
9836 entity_id: self.state.entity_id,
9837 seq: self.seq,
9838 })
9839 .await?;
9840 self.state.intents_sent += 1;
9841 self.state.push_log("Dodge!");
9842 Ok(())
9843 }
9844
9845 pub async fn lunge(&mut self) -> anyhow::Result<()> {
9846 if !self.state.is_alive() {
9847 anyhow::bail!("you are dead");
9848 }
9849 let (forward, strafe) = self.last_move_axes();
9850 self.seq += 1;
9851 self.session
9852 .submit_intent(Intent::Lunge {
9853 entity_id: self.state.entity_id,
9854 forward,
9855 strafe,
9856 seq: self.seq,
9857 })
9858 .await?;
9859 self.state.intents_sent += 1;
9860 self.state.push_log("Lunge!");
9861 Ok(())
9862 }
9863
9864 pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
9865 if !self.state.is_alive() {
9866 anyhow::bail!("you are dead");
9867 }
9868 self.seq += 1;
9869 self.session
9870 .submit_intent(Intent::DirectionalJump {
9871 entity_id: self.state.entity_id,
9872 forward,
9873 strafe,
9874 seq: self.seq,
9875 })
9876 .await?;
9877 self.state.intents_sent += 1;
9878 self.state.push_log("Jump!");
9879 Ok(())
9880 }
9881
9882 pub fn last_move_axes(&self) -> (f32, f32) {
9884 (self.last_move_forward, self.last_move_strafe)
9885 }
9886
9887 pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
9888 if !self.state.is_alive() {
9889 anyhow::bail!("you are dead");
9890 }
9891 self.seq += 1;
9892 self.session
9893 .submit_intent(Intent::Block {
9894 entity_id: self.state.entity_id,
9895 enabled,
9896 seq: self.seq,
9897 })
9898 .await?;
9899 self.state.intents_sent += 1;
9900 if enabled {
9901 self.state.push_log("Blocking");
9902 }
9903 Ok(())
9904 }
9905
9906 pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
9907 if !self.state.is_alive() {
9908 anyhow::bail!("you are dead");
9909 }
9910 self.seq += 1;
9911 self.session
9912 .submit_intent(Intent::EquipMainhand {
9913 entity_id: self.state.entity_id,
9914 template_id,
9915 instance_id: None,
9916 seq: self.seq,
9917 })
9918 .await?;
9919 self.state.intents_sent += 1;
9920 Ok(())
9921 }
9922
9923 pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
9925 let idx = self.state.equip_menu_index;
9926 let slots = equip_paperdoll_rows(&self.state);
9927 let Some(row) = slots.get(idx) else {
9928 return Ok(());
9929 };
9930 match row {
9931 EquipPaperdollRow::Body { slot, filled } => {
9932 if *filled {
9933 self.equip_worn(*slot, None).await
9934 } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
9935 self.equip_worn(*slot, Some(inst)).await
9936 } else {
9937 self.state.push_log(format!("No item for {}", body_slot_label(*slot)));
9938 Ok(())
9939 }
9940 }
9941 EquipPaperdollRow::Mainhand { filled } => {
9942 if *filled {
9943 self.unequip_mainhand().await
9944 } else if let Some(tid) = first_inventory_weapon(&self.state) {
9945 self.equip_mainhand(Some(tid)).await
9946 } else {
9947 self.state.push_log("No weapon in inventory".to_string());
9948 Ok(())
9949 }
9950 }
9951 EquipPaperdollRow::Offhand { filled, locked } => {
9952 if *locked {
9953 self.state
9954 .push_log("Offhand locked — two-handed weapon equipped".to_string());
9955 Ok(())
9956 } else if *filled {
9957 self.unequip_offhand().await
9958 } else if let Some(tid) = first_inventory_offhand(&self.state) {
9959 self.equip_offhand(Some(tid)).await
9960 } else {
9961 self.state
9962 .push_log("No offhand item in inventory".to_string());
9963 Ok(())
9964 }
9965 }
9966 }
9967 }
9968
9969 pub async fn say(
9970 &mut self,
9971 channel: flatland_protocol::ChatChannel,
9972 text: &str,
9973 ) -> anyhow::Result<()> {
9974 self.seq += 1;
9975 self.session
9976 .submit_intent(Intent::Say {
9977 entity_id: self.state.entity_id,
9978 channel,
9979 text: text.to_string(),
9980 seq: self.seq,
9981 })
9982 .await?;
9983 self.state.intents_sent += 1;
9984 Ok(())
9985 }
9986
9987 pub async fn stop(&mut self) -> anyhow::Result<()> {
9988 self.seq += 1;
9989 self.session
9990 .submit_intent(Intent::Stop {
9991 entity_id: self.state.entity_id,
9992 seq: self.seq,
9993 })
9994 .await?;
9995 self.state.intents_sent += 1;
9996 Ok(())
9997 }
9998
9999 pub fn disconnect(&self) {
10000 self.session.disconnect();
10001 }
10002}
10003
10004fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
10005 let dx = ax - bx;
10006 let dy = ay - by;
10007 (dx * dx + dy * dy).sqrt()
10008}
10009
10010#[cfg(test)]
10011mod tests {
10012 use std::collections::BTreeMap;
10013
10014 use super::*;
10015 use flatland_protocol::{
10016 BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
10017 };
10018
10019 fn sample_state() -> GameState {
10020 let mut state = GameState {
10021 session_id: 1,
10022 entity_id: 1,
10023 character_id: None,
10024 tick: 0,
10025 chunk_rev: 0,
10026 content_rev: 0,
10027 publish_rev: 0,
10028 entities: vec![EntityState {
10029 id: 1,
10030 label: "You".into(),
10031 transform: Transform {
10032 position: WorldCoord::surface(128.0, 128.0),
10033 yaw: 0.0,
10034 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
10035 },
10036 vitals: None,
10037 attributes: None,
10038 skills: None,
10039 inside_building: None,
10040 tile_id: None,
10041 paperdoll_ref: None,
10042 presentation_state: None,
10043 sprite_mode: None,
10044 progression_xp: None,
10045 }],
10046 player: None,
10047 resource_nodes: vec![ResourceNodeView {
10048 id: "oak-1".into(),
10049 label: "Oak".into(),
10050 x: 130.0,
10051 y: 128.0,
10052 z: 0.0,
10053 item_template: "oak_log".into(),
10054 state: ResourceNodeState::Available,
10055 blocking: true,
10056 blocking_radius_m: 0.8,
10057 tile_id: None,
10058 paperdoll_ref: None,
10059 yaw: 0.0,
10060 pitch: 0.0,
10061 roll: 0.0,
10062 draw_scale: 1.0,
10063 sprite_mode: None,
10064 presentation_state: None,
10065 }],
10066 ground_drops: vec![],
10067 placed_containers: vec![],
10068 buildings: vec![BuildingView {
10069 id: "broker-hut".into(),
10070 label: "Broker".into(),
10071 x: 148.0,
10072 y: 118.0,
10073 width_m: 8.0,
10074 depth_m: 6.0,
10075 interior_blueprint: Some("broker_hut".into()),
10076 tags: vec![],
10077 }],
10078 doors: vec![flatland_protocol::DoorView {
10079 id: "door-1".into(),
10080 building_id: "broker-hut".into(),
10081 x: 148.0,
10082 y: 118.0,
10083 open: false,
10084 portal: Some("front".into()),
10085 }],
10086 interior_map: None,
10087 npcs: vec![],
10088 blueprints: vec![],
10089 world_width_m: 256.0,
10090 world_height_m: 256.0,
10091 terrain_zones: Vec::new(),
10092 z_platforms: Vec::new(),
10093 z_transitions: Vec::new(),
10094 world_clock: flatland_protocol::WorldClock::default(),
10095 inventory: std::collections::HashMap::new(),
10096 inventory_hints: std::collections::HashMap::new(),
10097 logs: VecDeque::new(),
10098 intents_sent: 0,
10099 ticks_received: 0,
10100 connected: true,
10101 disconnect_reason: None,
10102 show_stats: false,
10103 show_equip_menu: false,
10104 equip_menu_index: 0,
10105 show_craft_menu: false,
10106 craft_menu_index: 0,
10107 craft_batch_quantity: 1,
10108 show_shop_menu: false,
10109 shop_catalog: None,
10110 bank_panel: None,
10111 bank_menu_index: 0,
10112 bank_ui_mode: BankUiMode::Menu,
10113 storage_panel: None,
10114 storage_menu_index: 0,
10115 storage_ui_mode: StorageUiMode::Menu,
10116 shop_tab: ShopTab::default(),
10117 shop_menu_index: 0,
10118 shop_quantity: 1,
10119 shop_trade_log: VecDeque::new(),
10120 show_npc_verb_menu: false,
10121 npc_verb_target: None,
10122 npc_verb_index: 0,
10123 show_npc_chat: false,
10124 npc_chat: None,
10125 show_inventory_menu: false,
10126 inventory_menu_index: 0,
10127 inventory_tab: InventoryTab::OnPerson,
10128 inventory_filter: String::new(),
10129 inventory_filter_focused: false,
10130 show_move_picker: false,
10131 show_rename_prompt: false,
10132 show_worker_rename: false,
10133 rename_buffer: String::new(),
10134 move_picker_index: 0,
10135 move_picker: None,
10136 show_grant_picker: false,
10137 grant_picker_index: 0,
10138 grant_picker: None,
10139 show_destroy_picker: false,
10140 destroy_confirm_pending: false,
10141 destroy_picker: None,
10142 combat_target: None,
10143 combat_target_label: None,
10144 combat_fx: Vec::new(),
10145 in_combat: false,
10146 auto_attack: true,
10147 combat_has_los: false,
10148 attack_cd_ticks: 0,
10149 gcd_ticks: 0,
10150 weapon_ability_id: "unarmed".into(),
10151 mainhand_template_id: None,
10152 mainhand_label: None,
10153 offhand_template_id: None,
10154 offhand_label: None,
10155 mainhand_hand_slots: 1,
10156 defense: None,
10157 worn: BTreeMap::new(),
10158 carry_mass: 0.0,
10159 carry_mass_max: 0.0,
10160 encumbrance: flatland_protocol::EncumbranceState::Light,
10161 inventory_stacks: Vec::new(),
10162 keychain_stacks: Vec::new(),
10163 combat_target_detail: None,
10164 statuses: Vec::new(),
10165 cast_progress: None,
10166 ability_cooldowns: Vec::new(),
10167 blocking_active: false,
10168 max_target_slots: 1,
10169 combat_slots: Vec::new(),
10170 rotation_presets: Vec::new(),
10171 known_abilities: Vec::new(),
10172 hotbar: vec![None; 9],
10173 max_abilities_per_rotation: 0,
10174 show_loadout_menu: false,
10175 show_keychain_menu: false,
10176 keychain_menu_index: 0,
10177 show_rotation_editor: false,
10178 loadout_menu_index: 0,
10179 loadout_hotbar_slot: 1,
10180 loadout_ability_index: 0,
10181 loadout_focus_presets: false,
10182 rotation_editor: RotationEditorState::default(),
10183 harvest_in_progress: false,
10184 harvest_started_at: None,
10185 pending_craft_ack: None,
10186 pending_worker_job_ack: None,
10187 quest_log: Vec::new(),
10188 interactables: Vec::new(),
10189 ledger: None,
10190 career: None,
10191 character_sheet_tab: CharacterSheetTab::Character,
10192 ledger_period: LedgerPeriod::Day,
10193 show_quest_offer: false,
10194 pending_quest_offer: None,
10195 show_quest_menu: false,
10196 quest_menu_index: 0,
10197 quest_withdraw_confirm: false,
10198 hired_workers: Vec::new(),
10199 show_workers_menu: false,
10200 workers_menu_index: 0,
10201 workers_menu_compact: false,
10202 worker_step_display: BTreeMap::new(),
10203 worker_error_display: BTreeMap::new(),
10204 show_worker_give_picker: false,
10205 worker_give_picker_index: 0,
10206 worker_give_picker: None,
10207 show_worker_give_target_picker: false,
10208 worker_give_target_picker_index: 0,
10209 worker_give_target_picker: None,
10210 show_worker_take_picker: false,
10211 worker_take_picker_index: 0,
10212 worker_take_picker: None,
10213 show_worker_teach_picker: false,
10214 worker_teach_picker_index: 0,
10215 worker_teach_picker: None,
10216 worker_route_editor: None,
10217 progression_curve: None,
10218 };
10219 state.player = state.entities.first().cloned();
10220 state
10221 }
10222
10223 #[test]
10224 fn probe_use_world_npc_beats_nearby_loot() {
10225 let mut state = sample_state();
10226 state.npcs.push(flatland_protocol::NpcView {
10227 id: "ada".into(),
10228 label: "Ada".into(),
10229 role: "broker".into(),
10230 x: 129.0,
10231 y: 128.0,
10232 building_id: None,
10233 entity_id: None,
10234 life_state: None,
10235 hp_pct: None,
10236 can_trade: true,
10237 tile_id: None,
10238 behavior_state: None,
10239 presentation_state: None,
10240 sprite_mode: None,
10241 paperdoll_ref: None,
10242 });
10243 state.ground_drops.push(flatland_protocol::GroundDropView {
10244 id: "d1".into(),
10245 template_id: "lumber".into(),
10246 quantity: 1,
10247 x: 128.5,
10248 y: 128.0,
10249 z: 0.0,
10250 tile_id: None,
10251 paperdoll_ref: None,
10252 yaw: 0.0,
10253 pitch: 0.0,
10254 roll: 0.0,
10255 draw_scale: 1.0,
10256 });
10257 let probe = state.probe_use_world();
10258 let primary = probe.primary.expect("primary");
10259 assert_eq!(primary.kind, crate::UseWorldKind::Npc);
10260 assert_eq!(primary.id, "ada");
10261 }
10262
10263 #[test]
10264 fn probe_use_world_harvest_when_in_range() {
10265 let state = sample_state(); let probe = state.probe_use_world();
10267 assert!(
10268 probe.primary.is_none(),
10269 "oak is 2m away, out of harvest range"
10270 );
10271 assert!(probe
10272 .candidates
10273 .iter()
10274 .any(|c| c.kind == crate::UseWorldKind::Harvest));
10275
10276 let mut state = sample_state();
10277 state.resource_nodes[0].x = 129.0;
10278 let probe = state.probe_use_world();
10279 let primary = probe.primary.expect("primary");
10280 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
10281 }
10282
10283 #[test]
10284 fn probe_use_world_door_uses_building_label() {
10285 let mut state = sample_state();
10286 state.doors[0].x = 129.0;
10287 state.doors[0].y = 128.0;
10288 let probe = state.probe_use_world();
10289 let primary = probe.primary.expect("primary");
10290 assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
10291 assert_eq!(primary.label, "Broker");
10292 assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
10293 }
10294
10295 #[test]
10296 fn empty_entity_tick_preserves_welcome_snapshot() {
10297 let mut state = sample_state();
10298 state.inventory.insert("carrot".into(), 3);
10299 let delta = TickDelta {
10300 tick: 1,
10301 entities: vec![],
10302 resource_nodes: vec![],
10303 ground_drops: vec![],
10304 placed_containers: vec![],
10305 buildings: vec![],
10306 doors: vec![],
10307 interior_map: None,
10308 npcs: vec![],
10309 inventory: vec![],
10310 blueprints: vec![],
10311 world_clock: flatland_protocol::WorldClock::default(),
10312 combat: None,
10313 quest_log: vec![],
10314 hired_workers: Vec::new(),
10315 interactables: vec![],
10316 ledger: None,
10317 career: None,
10318 combat_fx: Vec::new(),
10319 };
10320
10321 state.apply_tick_fields(&delta, 1);
10322
10323 assert_eq!(state.entities.len(), 1);
10324 assert!(state.player.is_some());
10325 assert_eq!(state.inventory.get("carrot"), Some(&3));
10326 assert_eq!(state.resource_nodes.len(), 1);
10327 }
10328
10329 #[test]
10330 fn tick_preserves_world_layers_when_delta_omits_them() {
10331 let mut state = sample_state();
10332 let delta = TickDelta {
10333 tick: 1,
10334 entities: state.entities.clone(),
10335 resource_nodes: vec![],
10336 ground_drops: vec![],
10337 placed_containers: vec![],
10338 buildings: vec![],
10339 doors: vec![],
10340 interior_map: None,
10341 npcs: vec![],
10342 inventory: vec![],
10343 blueprints: vec![],
10344 world_clock: flatland_protocol::WorldClock::default(),
10345 combat: None,
10346 quest_log: vec![],
10347 hired_workers: Vec::new(),
10348 interactables: vec![],
10349 ledger: None,
10350 career: None,
10351 combat_fx: Vec::new(),
10352 };
10353
10354 state.apply_tick_fields(&delta, 1);
10355
10356 assert_eq!(state.resource_nodes.len(), 1);
10357 assert_eq!(state.buildings.len(), 1);
10358 assert_eq!(state.doors.len(), 1);
10359 }
10360
10361 #[test]
10362 fn tick_updates_resource_nodes_when_server_sends_them() {
10363 let mut state = sample_state();
10364 let delta = TickDelta {
10365 tick: 1,
10366 entities: state.entities.clone(),
10367 resource_nodes: vec![ResourceNodeView {
10368 id: "oak-1".into(),
10369 label: "Oak".into(),
10370 x: 130.0,
10371 y: 128.0,
10372 z: 0.0,
10373 item_template: "oak_log".into(),
10374 state: ResourceNodeState::Cooldown,
10375 blocking: true,
10376 blocking_radius_m: 0.8,
10377 tile_id: None,
10378 paperdoll_ref: None,
10379 yaw: 0.0,
10380 pitch: 0.0,
10381 roll: 0.0,
10382 draw_scale: 1.0,
10383 sprite_mode: None,
10384 presentation_state: None,
10385 }],
10386 buildings: vec![],
10387 doors: vec![],
10388 interior_map: None,
10389 npcs: vec![],
10390 inventory: vec![],
10391 blueprints: vec![],
10392 world_clock: flatland_protocol::WorldClock::default(),
10393 ground_drops: vec![],
10394 placed_containers: vec![],
10395 combat: None,
10396 quest_log: vec![],
10397 hired_workers: Vec::new(),
10398 interactables: vec![],
10399 ledger: None,
10400 career: None,
10401 combat_fx: Vec::new(),
10402 };
10403
10404 state.apply_tick_fields(&delta, 1);
10405
10406 assert!(matches!(
10407 state.resource_nodes[0].state,
10408 ResourceNodeState::Cooldown
10409 ));
10410 }
10411
10412 #[test]
10413 fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
10414 let mut state = GameState {
10415 session_id: 1,
10416 entity_id: 1,
10417 character_id: None,
10418 tick: 0,
10419 chunk_rev: 0,
10420 content_rev: 0,
10421 publish_rev: 0,
10422 entities: vec![EntityState {
10423 id: 1,
10424 label: "You".into(),
10425 transform: Transform {
10426 position: WorldCoord::surface(4.5, 2.0),
10427 yaw: 0.0,
10428 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
10429 },
10430 vitals: None,
10431 attributes: None,
10432 skills: None,
10433 inside_building: Some("broker_hut".into()),
10434 tile_id: None,
10435 paperdoll_ref: None,
10436 presentation_state: None,
10437 sprite_mode: None,
10438 progression_xp: None,
10439 }],
10440 player: None,
10441 resource_nodes: vec![],
10442 ground_drops: vec![],
10443 placed_containers: vec![],
10444 buildings: vec![BuildingView {
10445 id: "broker_hut".into(),
10446 label: "Broker".into(),
10447 x: 158.0,
10448 y: 124.0,
10449 width_m: 8.0,
10450 depth_m: 6.0,
10451 interior_blueprint: Some("broker_hut".into()),
10452 tags: vec![],
10453 }],
10454 doors: vec![flatland_protocol::DoorView {
10455 id: "broker_hut_exit".into(),
10456 building_id: "broker_hut".into(),
10457 x: 4.3,
10458 y: 0.9,
10459 open: true,
10460 portal: Some("front".into()),
10461 }],
10462 interior_map: None,
10463 npcs: vec![flatland_protocol::NpcView {
10464 id: "ada_broker".into(),
10465 label: "Ada".into(),
10466 x: 4.5,
10467 y: 2.0,
10468 building_id: Some("broker_hut".into()),
10469 role: "broker".into(),
10470 entity_id: None,
10471 life_state: None,
10472 hp_pct: None,
10473 can_trade: true,
10474 tile_id: None,
10475 behavior_state: None,
10476 presentation_state: None,
10477 sprite_mode: None,
10478 paperdoll_ref: None,
10479 }],
10480 blueprints: vec![],
10481 world_width_m: 256.0,
10482 world_height_m: 256.0,
10483 terrain_zones: Vec::new(),
10484 z_platforms: Vec::new(),
10485 z_transitions: Vec::new(),
10486 world_clock: flatland_protocol::WorldClock::default(),
10487 inventory: std::collections::HashMap::new(),
10488 inventory_hints: std::collections::HashMap::new(),
10489 logs: VecDeque::new(),
10490 intents_sent: 0,
10491 ticks_received: 0,
10492 connected: true,
10493 disconnect_reason: None,
10494 show_stats: false,
10495 show_equip_menu: false,
10496 equip_menu_index: 0,
10497 show_craft_menu: false,
10498 craft_menu_index: 0,
10499 craft_batch_quantity: 1,
10500 show_shop_menu: false,
10501 shop_catalog: None,
10502 bank_panel: None,
10503 bank_menu_index: 0,
10504 bank_ui_mode: BankUiMode::Menu,
10505 storage_panel: None,
10506 storage_menu_index: 0,
10507 storage_ui_mode: StorageUiMode::Menu,
10508 shop_tab: ShopTab::default(),
10509 shop_menu_index: 0,
10510 shop_quantity: 1,
10511 shop_trade_log: VecDeque::new(),
10512 show_npc_verb_menu: false,
10513 npc_verb_target: None,
10514 npc_verb_index: 0,
10515 show_npc_chat: false,
10516 npc_chat: None,
10517 show_inventory_menu: false,
10518 inventory_menu_index: 0,
10519 inventory_tab: InventoryTab::OnPerson,
10520 inventory_filter: String::new(),
10521 inventory_filter_focused: false,
10522 show_move_picker: false,
10523 show_rename_prompt: false,
10524 show_worker_rename: false,
10525 rename_buffer: String::new(),
10526 move_picker_index: 0,
10527 move_picker: None,
10528 show_grant_picker: false,
10529 grant_picker_index: 0,
10530 grant_picker: None,
10531 show_destroy_picker: false,
10532 destroy_confirm_pending: false,
10533 destroy_picker: None,
10534 combat_target: None,
10535 combat_target_label: None,
10536 combat_fx: Vec::new(),
10537 in_combat: false,
10538 auto_attack: true,
10539 combat_has_los: false,
10540 attack_cd_ticks: 0,
10541 gcd_ticks: 0,
10542 weapon_ability_id: "unarmed".into(),
10543 mainhand_template_id: None,
10544 mainhand_label: None,
10545 offhand_template_id: None,
10546 offhand_label: None,
10547 mainhand_hand_slots: 1,
10548 defense: None,
10549 worn: BTreeMap::new(),
10550 carry_mass: 0.0,
10551 carry_mass_max: 0.0,
10552 encumbrance: flatland_protocol::EncumbranceState::Light,
10553 inventory_stacks: Vec::new(),
10554 keychain_stacks: Vec::new(),
10555 combat_target_detail: None,
10556 statuses: Vec::new(),
10557 cast_progress: None,
10558 ability_cooldowns: Vec::new(),
10559 blocking_active: false,
10560 max_target_slots: 1,
10561 combat_slots: Vec::new(),
10562 rotation_presets: Vec::new(),
10563 known_abilities: Vec::new(),
10564 hotbar: vec![None; 9],
10565 max_abilities_per_rotation: 0,
10566 show_loadout_menu: false,
10567 show_keychain_menu: false,
10568 keychain_menu_index: 0,
10569 show_rotation_editor: false,
10570 loadout_menu_index: 0,
10571 loadout_hotbar_slot: 1,
10572 loadout_ability_index: 0,
10573 loadout_focus_presets: false,
10574 rotation_editor: RotationEditorState::default(),
10575 harvest_in_progress: false,
10576 harvest_started_at: None,
10577 pending_craft_ack: None,
10578 pending_worker_job_ack: None,
10579 quest_log: Vec::new(),
10580 interactables: Vec::new(),
10581 ledger: None,
10582 career: None,
10583 character_sheet_tab: CharacterSheetTab::Character,
10584 ledger_period: LedgerPeriod::Day,
10585 show_quest_offer: false,
10586 pending_quest_offer: None,
10587 show_quest_menu: false,
10588 quest_menu_index: 0,
10589 quest_withdraw_confirm: false,
10590 hired_workers: Vec::new(),
10591 show_workers_menu: false,
10592 workers_menu_index: 0,
10593 workers_menu_compact: false,
10594 worker_step_display: BTreeMap::new(),
10595 worker_error_display: BTreeMap::new(),
10596 show_worker_give_picker: false,
10597 worker_give_picker_index: 0,
10598 worker_give_picker: None,
10599 show_worker_give_target_picker: false,
10600 worker_give_target_picker_index: 0,
10601 worker_give_target_picker: None,
10602 show_worker_take_picker: false,
10603 worker_take_picker_index: 0,
10604 worker_take_picker: None,
10605 show_worker_teach_picker: false,
10606 worker_teach_picker_index: 0,
10607 worker_teach_picker: None,
10608 worker_route_editor: None,
10609 progression_curve: None,
10610 };
10611 state.player = state.entities.first().cloned();
10612 assert_eq!(
10613 state.nearest_interact_target().as_deref(),
10614 Some("ada_broker")
10615 );
10616 }
10617
10618 #[test]
10619 fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
10620 let mut state = sample_state();
10621 state.placed_containers = vec![
10624 flatland_protocol::PlacedContainerView {
10625 id: "near".into(),
10626 template_id: "wooden_chest_small".into(),
10627 display_name: "Wooden Chest".into(),
10628 x: 130.0,
10629 y: 128.0,
10630 z: 0.0,
10631 locked: true,
10632 accessible: true,
10633 owner_character_id: None,
10634 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
10635 lock_id: None,
10636 capacity_volume: None,
10637 item_instance_id: Some(uuid::Uuid::from_u128(1)),
10638 tile_id: None,
10639 paperdoll_ref: None,
10640 worker_lodging_capacity: None,
10641 },
10642 flatland_protocol::PlacedContainerView {
10643 id: "far".into(),
10644 template_id: "wooden_chest_small".into(),
10645 display_name: "Distant Chest".into(),
10646 x: 128.0 + CONTAINER_RANGE_M + 5.0,
10647 y: 128.0,
10648 z: 0.0,
10649 locked: false,
10650 accessible: true,
10651 owner_character_id: None,
10652 contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
10653 lock_id: None,
10654 capacity_volume: None,
10655 item_instance_id: Some(uuid::Uuid::from_u128(2)),
10656 tile_id: None,
10657 paperdoll_ref: None,
10658 worker_lodging_capacity: None,
10659 },
10660 ];
10661
10662 let nearby = state.nearby_containers();
10663 assert_eq!(
10664 nearby.len(),
10665 1,
10666 "far chest must not appear once out of range"
10667 );
10668 assert_eq!(nearby[0].view.id, "near");
10669 assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
10670 assert!(nearby[0].rows[0].is_chest_shell);
10671
10672 state.placed_containers[0].accessible = false;
10675 let nearby = state.nearby_containers();
10676 assert_eq!(nearby.len(), 1);
10677 assert_eq!(nearby[0].rows.len(), 1);
10678 assert!(nearby[0].rows[0].is_chest_shell);
10679 }
10680
10681 #[test]
10682 fn chest_pickup_destinations_offer_person_and_worn_bag() {
10683 let mut state = sample_state();
10684 let back_id = uuid::Uuid::from_u128(42);
10685 state.worn.insert(
10686 BodySlot::Back,
10687 flatland_protocol::ItemStack {
10688 template_id: "travel_backpack".into(),
10689 quantity: 1,
10690 item_instance_id: Some(back_id),
10691 props: Default::default(),
10692 status_bindings: Vec::new(),
10693 contents: Vec::new(),
10694 display_name: Some("Travel Backpack".into()),
10695 category: Some("container".into()),
10696 base_mass: Some(2.5),
10697 base_volume: Some(12.0),
10698 capacity_volume: Some(80.0),
10699 stackable: Some(false),
10700 world_placeable: Some(false),
10701 worker_lodging_capacity: None,
10702 equip_slot: None,
10703 armor_physical: None,
10704 resists: vec![],
10705 hand_slots: None,
10706 },
10707 );
10708 let opts = state.chest_pickup_destinations("chest-1");
10709 assert!(opts.iter().any(|o| matches!(
10710 &o.kind,
10711 MoveOptionKind::PickupPlaced {
10712 nest_parent_instance_id: None,
10713 ..
10714 }
10715 )));
10716 assert!(opts.iter().any(|o| matches!(
10717 &o.kind,
10718 MoveOptionKind::PickupPlaced {
10719 nest_parent_instance_id: Some(id),
10720 ..
10721 } if *id == back_id
10722 )));
10723 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
10724 }
10725
10726 #[test]
10727 fn placed_container_public_label_hides_owner_custom_name() {
10728 let owner = uuid::Uuid::from_u128(99);
10729 let mut state = sample_state();
10730 state.character_id = Some(uuid::Uuid::from_u128(1));
10731 state.inventory_hints.insert(
10732 "wooden_chest_medium".into(),
10733 InventoryHint {
10734 display_name: "Medium Wooden Chest".into(),
10735 category: "container".into(),
10736 base_mass: None,
10737 base_volume: None,
10738 capacity_volume: None,
10739 stackable: false,
10740 },
10741 );
10742 let chest = flatland_protocol::PlacedContainerView {
10743 id: "c1".into(),
10744 template_id: "wooden_chest_medium".into(),
10745 display_name: "Barry's Loot #a3f2".into(),
10746 x: 128.0,
10747 y: 128.0,
10748 z: 0.0,
10749 locked: false,
10750 accessible: true,
10751 owner_character_id: Some(owner),
10752 contents: vec![],
10753 lock_id: None,
10754 capacity_volume: None,
10755 item_instance_id: None,
10756 tile_id: None,
10757 paperdoll_ref: None,
10758 worker_lodging_capacity: None,
10759 };
10760 assert_eq!(
10761 state.placed_container_public_label(&chest),
10762 "Medium Wooden Chest"
10763 );
10764 state.character_id = Some(owner);
10765 assert_eq!(
10766 state.placed_container_public_label(&chest),
10767 "Barry's Loot #a3f2"
10768 );
10769 }
10770
10771 #[test]
10772 fn location_context_lists_nearby_resource_node() {
10773 let mut state = sample_state();
10774 state.player = state.entities.first().cloned();
10775 state.resource_nodes[0].x = 128.2;
10776 state.resource_nodes[0].y = 128.0;
10777 let lines = state.location_context_lines();
10778 assert!(
10779 lines
10780 .iter()
10781 .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
10782 "expected resource node in context: {:?}",
10783 lines
10784 );
10785 }
10786
10787 #[test]
10788 fn quest_board_usable_within_board_radius() {
10789 let mut state = sample_state();
10790 state.player = state.entities.first().cloned();
10791 state.interactables = vec![flatland_protocol::InteractableView {
10792 id: "board-1".into(),
10793 kind: "quest_board".into(),
10794 label: "Town Quest Board".into(),
10795 x: 130.5,
10796 y: 128.0,
10797 z: 0.0,
10798 board_id: Some("starter_town_board".into()),
10799 }];
10800 assert_eq!(
10802 state.nearest_interact_target().as_deref(),
10803 Some("board-1"),
10804 "quest board should be selectable at ~2.5m"
10805 );
10806 let lines = state.location_context_lines();
10807 assert!(
10808 lines
10809 .iter()
10810 .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
10811 "HUD should advertise f when board is in range: {:?}",
10812 lines
10813 );
10814 }
10815
10816 #[test]
10817 fn inventory_selectable_rows_orders_worn_before_person_on_person_tab() {
10818 let mut state = sample_state();
10819 state.worn.insert(
10820 BodySlot::Back,
10821 flatland_protocol::ItemStack {
10822 template_id: "travel_backpack".into(),
10823 quantity: 1,
10824 item_instance_id: Some(uuid::Uuid::from_u128(3)),
10825 props: Default::default(),
10826 status_bindings: Vec::new(),
10827 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
10828 display_name: None,
10829 category: None,
10830 base_mass: None,
10831 base_volume: None,
10832 capacity_volume: None,
10833 stackable: None,
10834 world_placeable: None,
10835 worker_lodging_capacity: None,
10836 equip_slot: None,
10837 armor_physical: None,
10838 resists: vec![],
10839 hand_slots: None,
10840 },
10841 );
10842 state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
10843 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
10844 id: "chest-1".into(),
10845 template_id: "wooden_chest_small".into(),
10846 display_name: "Wooden Chest".into(),
10847 x: 129.0,
10848 y: 128.0,
10849 z: 0.0,
10850 locked: false,
10851 accessible: true,
10852 owner_character_id: None,
10853 contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
10854 lock_id: None,
10855 capacity_volume: None,
10856 item_instance_id: Some(uuid::Uuid::from_u128(4)),
10857 tile_id: None,
10858 paperdoll_ref: None,
10859 worker_lodging_capacity: None,
10860 }];
10861
10862 state.inventory_tab = InventoryTab::OnPerson;
10863 let rows = state.inventory_selectable_rows();
10864 let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
10865 assert_eq!(
10866 sections,
10867 vec![
10868 InventorySection::Worn, InventorySection::Worn, InventorySection::Person, ]
10872 );
10873 assert_eq!(rows[0].stack.template_id, "travel_backpack");
10874 assert!(rows[0].is_equip_shell);
10875 assert_eq!(rows[1].stack.template_id, "iron_ore");
10876 assert_eq!(rows[1].depth, 1);
10877 assert_eq!(rows[2].stack.template_id, "lumber");
10878
10879 let lines = state.inventory_browser_lines();
10880 assert!(lines.iter().any(|l| matches!(
10881 l,
10882 InventoryBrowserLine::Section(s) if s.contains("Worn")
10883 )));
10884 assert!(lines.iter().any(|l| matches!(
10885 l,
10886 InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
10887 || text.contains("backpack")
10888 )));
10889 assert!(!lines.iter().any(|l| matches!(
10890 l,
10891 InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
10892 )));
10893
10894 state.inventory_tab = InventoryTab::Nearby;
10895 let nearby_rows = state.inventory_selectable_rows();
10896 assert_eq!(nearby_rows.len(), 2);
10897 assert!(nearby_rows[0].is_chest_shell);
10898 assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
10899 let nearby_lines = state.inventory_browser_lines();
10900 assert!(nearby_lines.iter().any(|l| matches!(
10901 l,
10902 InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
10903 )));
10904 }
10905
10906 #[test]
10907 fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
10908 let mut state = sample_state();
10909 let back_id = uuid::Uuid::from_u128(5);
10910 state.worn.insert(
10911 BodySlot::Back,
10912 flatland_protocol::ItemStack {
10913 template_id: "travel_backpack".into(),
10914 quantity: 1,
10915 item_instance_id: Some(back_id),
10916 props: Default::default(),
10917 status_bindings: Vec::new(),
10918 contents: Vec::new(),
10919 display_name: None,
10920 category: Some("container".into()),
10921 base_mass: None,
10922 base_volume: None,
10923 capacity_volume: Some(80.0),
10924 stackable: None,
10925 world_placeable: None,
10926 worker_lodging_capacity: None,
10927 equip_slot: None,
10928 armor_physical: None,
10929 resists: vec![],
10930 hand_slots: None,
10931 },
10932 );
10933 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
10934 id: "chest-1".into(),
10935 template_id: "wooden_chest_small".into(),
10936 display_name: "Wooden Chest".into(),
10937 x: 129.0,
10938 y: 128.0,
10939 z: 0.0,
10940 locked: false,
10941 accessible: true,
10942 owner_character_id: None,
10943 contents: Vec::new(),
10944 lock_id: None,
10945 capacity_volume: None,
10946 item_instance_id: Some(uuid::Uuid::from_u128(6)),
10947 tile_id: None,
10948 paperdoll_ref: None,
10949 worker_lodging_capacity: None,
10950 }];
10951
10952 let opts = state.move_destinations_for(
10955 &flatland_protocol::InventoryLocation::Root,
10956 None,
10957 None,
10958 "lumber",
10959 );
10960 assert!(!opts.iter().any(|o| matches!(
10961 &o.kind,
10962 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
10963 )));
10964 assert!(opts.iter().any(|o| matches!(
10965 &o.kind,
10966 MoveOptionKind::Move { location, parent_instance_id, .. }
10967 if *location == flatland_protocol::InventoryLocation::Worn {
10968 slot: BodySlot::Back,
10969 } && *parent_instance_id == Some(back_id)
10970 )));
10971 assert!(opts.iter().any(|o| matches!(
10972 &o.kind,
10973 MoveOptionKind::Move { location, .. }
10974 if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
10975 )));
10976 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
10977 assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
10978
10979 let from_backpack = flatland_protocol::InventoryLocation::Worn {
10983 slot: BodySlot::Back,
10984 };
10985 let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
10986 assert!(!opts.iter().any(|o| matches!(
10987 &o.kind,
10988 MoveOptionKind::Move { location, parent_instance_id, .. }
10989 if *location == from_backpack && *parent_instance_id == Some(back_id)
10990 )));
10991 assert!(opts.iter().any(|o| matches!(
10992 &o.kind,
10993 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
10994 )));
10995 }
10996
10997 #[test]
10998 fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
10999 let mut state = sample_state();
11000 state.worn.insert(
11003 BodySlot::Waist,
11004 flatland_protocol::ItemStack {
11005 template_id: "simple_belt".into(),
11006 quantity: 1,
11007 item_instance_id: Some(uuid::Uuid::from_u128(10)),
11008 props: Default::default(),
11009 status_bindings: Vec::new(),
11010 contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
11011 display_name: None,
11012 category: Some("container".into()),
11013 base_mass: None,
11014 base_volume: None,
11015 capacity_volume: None,
11016 stackable: None,
11017 world_placeable: None,
11018 worker_lodging_capacity: None,
11019 equip_slot: None,
11020 armor_physical: None,
11021 resists: vec![],
11022 hand_slots: None,
11023 },
11024 );
11025 state.worn.insert(
11026 BodySlot::Head,
11027 flatland_protocol::ItemStack {
11028 template_id: "cloth_cap".into(),
11029 quantity: 1,
11030 item_instance_id: Some(uuid::Uuid::from_u128(11)),
11031 props: Default::default(),
11032 status_bindings: Vec::new(),
11033 contents: Vec::new(),
11034 display_name: None,
11035 category: Some("armor".into()),
11036 base_mass: None,
11037 base_volume: None,
11038 capacity_volume: None,
11039 stackable: None,
11040 world_placeable: None,
11041 worker_lodging_capacity: None,
11042 equip_slot: None,
11043 armor_physical: None,
11044 resists: vec![],
11045 hand_slots: None,
11046 },
11047 );
11048 state.worn.insert(
11049 BodySlot::Back,
11050 flatland_protocol::ItemStack {
11051 template_id: "travel_backpack".into(),
11052 quantity: 1,
11053 item_instance_id: Some(uuid::Uuid::from_u128(12)),
11054 props: Default::default(),
11055 status_bindings: Vec::new(),
11056 contents: Vec::new(),
11057 display_name: None,
11058 category: Some("container".into()),
11059 base_mass: None,
11060 base_volume: None,
11061 capacity_volume: None,
11062 stackable: None,
11063 world_placeable: None,
11064 worker_lodging_capacity: None,
11065 equip_slot: None,
11066 armor_physical: None,
11067 resists: vec![],
11068 hand_slots: None,
11069 },
11070 );
11071
11072 let rows = state.worn_rows();
11073 assert_eq!(rows.len(), 4);
11075 assert_eq!(rows[0].stack.template_id, "cloth_cap");
11076 assert!(rows[0].is_equip_shell);
11077 assert_eq!(rows[1].stack.template_id, "travel_backpack");
11078 assert!(rows[1].is_equip_shell);
11079 assert_eq!(rows[2].stack.template_id, "simple_belt");
11080 assert!(rows[2].is_equip_shell);
11081 assert_eq!(rows[3].stack.template_id, "leather_pouch");
11082 assert_eq!(rows[3].depth, 1);
11083 assert!(!rows[3].is_equip_shell);
11084 }
11085
11086 #[test]
11087 fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
11088 let mut state = sample_state();
11089 state.worn.insert(
11090 BodySlot::Waist,
11091 flatland_protocol::ItemStack {
11092 template_id: "simple_belt".into(),
11093 quantity: 1,
11094 item_instance_id: Some(uuid::Uuid::from_u128(20)),
11095 props: Default::default(),
11096 status_bindings: Vec::new(),
11097 contents: Vec::new(),
11098 display_name: Some("Simple Belt".into()),
11099 category: Some("container".into()),
11100 base_mass: None,
11101 base_volume: None,
11102 capacity_volume: None,
11103 stackable: None,
11104 world_placeable: None,
11105 worker_lodging_capacity: None,
11106 equip_slot: None,
11107 armor_physical: None,
11108 resists: vec![],
11109 hand_slots: None,
11110 },
11111 );
11112 state.worn.insert(
11113 BodySlot::Head,
11114 flatland_protocol::ItemStack {
11115 template_id: "cloth_cap".into(),
11116 quantity: 1,
11117 item_instance_id: Some(uuid::Uuid::from_u128(21)),
11118 props: Default::default(),
11119 status_bindings: Vec::new(),
11120 contents: Vec::new(),
11121 display_name: Some("Cloth Cap".into()),
11122 category: Some("armor".into()),
11123 base_mass: None,
11124 base_volume: None,
11125 capacity_volume: None,
11126 stackable: None,
11127 world_placeable: None,
11128 worker_lodging_capacity: None,
11129 equip_slot: None,
11130 armor_physical: None,
11131 resists: vec![],
11132 hand_slots: None,
11133 },
11134 );
11135
11136 let opts = state.move_destinations_for(
11137 &flatland_protocol::InventoryLocation::Root,
11138 None,
11139 None,
11140 "leather_pouch",
11141 );
11142 assert!(
11143 opts.iter().any(|o| matches!(
11144 &o.kind,
11145 MoveOptionKind::Move { location, .. }
11146 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
11147 )),
11148 "belt loop must be offered when moving a pouch"
11149 );
11150 assert!(
11151 !opts.iter().any(|o| matches!(
11152 &o.kind,
11153 MoveOptionKind::Move { location, .. }
11154 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
11155 )),
11156 "armor slots can't hold other items and must not appear as move destinations"
11157 );
11158 let belt_opt = opts
11159 .iter()
11160 .find(|o| matches!(
11161 &o.kind,
11162 MoveOptionKind::Move { location, .. }
11163 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
11164 ))
11165 .unwrap();
11166 assert!(belt_opt.label.contains("belt loop"));
11167
11168 let opts = state.move_destinations_for(
11169 &flatland_protocol::InventoryLocation::Root,
11170 None,
11171 None,
11172 "lumber",
11173 );
11174 assert!(
11175 !opts.iter().any(|o| o.label.contains("belt loop")),
11176 "loose materials must not target the belt shell — only nested pouches"
11177 );
11178 }
11179
11180 #[test]
11181 fn move_destinations_for_offers_dimensional_pouch_on_belt() {
11182 let mut state = sample_state();
11183 let belt_id = uuid::Uuid::from_u128(30);
11184 let pouch_id = uuid::Uuid::from_u128(31);
11185 state.worn.insert(
11186 BodySlot::Waist,
11187 flatland_protocol::ItemStack {
11188 template_id: "simple_belt".into(),
11189 quantity: 1,
11190 item_instance_id: Some(belt_id),
11191 props: Default::default(),
11192 status_bindings: Vec::new(),
11193 world_placeable: None,
11194 worker_lodging_capacity: None,
11195 equip_slot: None,
11196 armor_physical: None,
11197 resists: vec![],
11198 hand_slots: None,
11199 contents: vec![flatland_protocol::ItemStack {
11200 template_id: "dimensional_pouch".into(),
11201 quantity: 1,
11202 item_instance_id: Some(pouch_id),
11203 props: Default::default(),
11204 status_bindings: Vec::new(),
11205 contents: Vec::new(),
11206 display_name: Some("Dimensional Pouch".into()),
11207 category: Some("container".into()),
11208 base_mass: None,
11209 base_volume: None,
11210 capacity_volume: Some(200.0),
11211 stackable: None,
11212 world_placeable: None,
11213 worker_lodging_capacity: None,
11214 equip_slot: None,
11215 armor_physical: None,
11216 resists: vec![],
11217 hand_slots: None,
11218 }],
11219 display_name: Some("Simple Belt".into()),
11220 category: Some("container".into()),
11221 base_mass: None,
11222 base_volume: None,
11223 capacity_volume: None,
11224 stackable: None,
11225 },
11226 );
11227
11228 let opts = state.move_destinations_for(
11229 &flatland_protocol::InventoryLocation::Root,
11230 None,
11231 None,
11232 "iron_ore",
11233 );
11234 assert!(
11235 opts.iter().any(|o| matches!(
11236 &o.kind,
11237 MoveOptionKind::Move {
11238 location,
11239 parent_instance_id,
11240 ..
11241 } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
11242 && *parent_instance_id == Some(pouch_id)
11243 )),
11244 "dimensional pouch clipped on belt must accept loose items"
11245 );
11246 assert!(
11247 opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
11248 "destination label should name the pouch"
11249 );
11250 }
11251
11252 #[test]
11253 fn container_volume_label_on_placed_chest_shell() {
11254 let mut state = sample_state();
11255 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
11256 id: "chest-1".into(),
11257 template_id: "wooden_chest_small".into(),
11258 display_name: "Camp Chest".into(),
11259 worker_lodging_capacity: None,
11260 x: 129.0,
11261 y: 128.0,
11262 z: 0.0,
11263 locked: false,
11264 accessible: true,
11265 owner_character_id: None,
11266 contents: vec![flatland_protocol::ItemStack {
11267 template_id: "iron_ore".into(),
11268 quantity: 2,
11269 item_instance_id: None,
11270 props: Default::default(),
11271 status_bindings: Vec::new(),
11272 contents: Vec::new(),
11273 display_name: None,
11274 category: None,
11275 base_mass: None,
11276 base_volume: Some(2.0),
11277 capacity_volume: None,
11278 stackable: None,
11279 world_placeable: None,
11280 worker_lodging_capacity: None,
11281 equip_slot: None,
11282 armor_physical: None,
11283 resists: vec![],
11284 hand_slots: None,
11285 }],
11286 lock_id: None,
11287 capacity_volume: Some(60.0),
11288 item_instance_id: Some(uuid::Uuid::from_u128(4)),
11289 tile_id: None,
11290 paperdoll_ref: None,
11291 }];
11292 let nearby = state.nearby_containers();
11293 let label = state.container_volume_label(&nearby[0].rows[0]);
11294 assert!(
11295 label.contains("vol 4/60"),
11296 "expected used/cap in label, got {label}"
11297 );
11298 assert!(
11299 label.contains("56 free"),
11300 "expected free space, got {label}"
11301 );
11302 }
11303
11304 #[test]
11305 fn key_pair_chest_label_from_placed_lock_id() {
11306 let mut state = sample_state();
11307 let owner = uuid::Uuid::from_u128(77);
11308 state.character_id = Some(owner);
11309 let lock = uuid::Uuid::from_u128(99).to_string();
11310 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
11311 id: "chest-1".into(),
11312 template_id: "wooden_chest_small".into(),
11313 display_name: "Barry's Loot #a3f2".into(),
11314 x: 129.0,
11315 y: 128.0,
11316 z: 0.0,
11317 locked: true,
11318 accessible: true,
11319 owner_character_id: Some(owner),
11320 contents: Vec::new(),
11321 lock_id: Some(lock.clone()),
11322 capacity_volume: None,
11323 item_instance_id: Some(uuid::Uuid::from_u128(4)),
11324 tile_id: None,
11325 paperdoll_ref: None,
11326 worker_lodging_capacity: None,
11327 }];
11328 let key_id = uuid::Uuid::from_u128(5);
11329 let key = flatland_protocol::ItemStack {
11330 template_id: KEY_TEMPLATE.into(),
11331 quantity: 1,
11332 item_instance_id: Some(key_id),
11333 props: BTreeMap::from([
11334 (PROP_OPENS_LOCK_ID.into(), lock),
11335 (
11336 PROP_OPENS_CONTAINER_NAME.into(),
11337 "Barry's Loot #a3f2".into(),
11338 ),
11339 ]),
11340 status_bindings: Vec::new(),
11341 contents: Vec::new(),
11342 display_name: Some("Container Key".into()),
11343 category: Some("key".into()),
11344 base_mass: None,
11345 base_volume: None,
11346 capacity_volume: None,
11347 stackable: None,
11348 world_placeable: None,
11349 worker_lodging_capacity: None,
11350 equip_slot: None,
11351 armor_physical: None,
11352 resists: vec![],
11353 hand_slots: None,
11354 };
11355 state.inventory_stacks = vec![key.clone()];
11356 assert_eq!(
11357 state.key_pair_chest_label(&key).as_deref(),
11358 Some("Barry's Loot #a3f2")
11359 );
11360 assert!(state.key_drop_blocked(&key));
11361 }
11362
11363 #[test]
11364 fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
11365 let mut state = sample_state();
11366 let lock = uuid::Uuid::from_u128(101).to_string();
11367 let key = flatland_protocol::ItemStack {
11368 template_id: KEY_TEMPLATE.into(),
11369 quantity: 1,
11370 item_instance_id: Some(uuid::Uuid::from_u128(7)),
11371 props: BTreeMap::from([
11372 (PROP_OPENS_LOCK_ID.into(), lock),
11373 (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
11374 ]),
11375 status_bindings: Vec::new(),
11376 contents: Vec::new(),
11377 display_name: None,
11378 category: Some("key".into()),
11379 base_mass: None,
11380 base_volume: None,
11381 capacity_volume: None,
11382 stackable: None,
11383 world_placeable: None,
11384 worker_lodging_capacity: None,
11385 equip_slot: None,
11386 armor_physical: None,
11387 resists: vec![],
11388 hand_slots: None,
11389 };
11390 state.placed_containers.clear();
11391 assert_eq!(
11392 state.key_pair_chest_label(&key).as_deref(),
11393 Some("Camp Stash")
11394 );
11395 }
11396
11397 #[test]
11398 fn key_drop_allowed_when_paired_chest_unlocked() {
11399 let mut state = sample_state();
11400 let lock = uuid::Uuid::from_u128(100).to_string();
11401 let key_id = uuid::Uuid::from_u128(6);
11402 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
11403 id: "chest-1".into(),
11404 template_id: "wooden_chest_small".into(),
11405 display_name: "Camp Chest".into(),
11406 x: 129.0,
11407 y: 128.0,
11408 z: 0.0,
11409 locked: false,
11410 accessible: true,
11411 owner_character_id: None,
11412 contents: Vec::new(),
11413 lock_id: Some(lock.clone()),
11414 capacity_volume: None,
11415 item_instance_id: None,
11416 tile_id: None,
11417 paperdoll_ref: None,
11418 worker_lodging_capacity: None,
11419 }];
11420 let key = flatland_protocol::ItemStack {
11421 template_id: KEY_TEMPLATE.into(),
11422 quantity: 1,
11423 item_instance_id: Some(key_id),
11424 props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
11425 status_bindings: Vec::new(),
11426 contents: Vec::new(),
11427 display_name: None,
11428 category: Some("key".into()),
11429 base_mass: None,
11430 base_volume: None,
11431 capacity_volume: None,
11432 stackable: None,
11433 world_placeable: None,
11434 worker_lodging_capacity: None,
11435 equip_slot: None,
11436 armor_physical: None,
11437 resists: vec![],
11438 hand_slots: None,
11439 };
11440 state.inventory_stacks = vec![key.clone()];
11441 assert!(!state.key_drop_blocked(&key));
11442 let opts = state.move_destinations_for(
11443 &flatland_protocol::InventoryLocation::Root,
11444 None,
11445 Some(key_id),
11446 KEY_TEMPLATE,
11447 );
11448 assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
11449 }
11450
11451 #[test]
11452 fn combat_hud_refreshes_progression_xp_when_entity_stale() {
11453 use flatland_protocol::{CombatHud, ProgressionXp, ProgressionCurve};
11454
11455 let mut state = sample_state();
11456 let curve = ProgressionCurve::default();
11457 let bootstrap = ProgressionXp::bootstrap_new(
11458 curve.baseline_display,
11459 curve.xp_base,
11460 curve.xp_growth,
11461 );
11462 let mut fresh = bootstrap.clone();
11463 fresh.strength += 0.08;
11464 if let Some(player) = state.player.as_mut() {
11465 player.progression_xp = Some(bootstrap);
11466 }
11467
11468 let combat = CombatHud {
11469 progression_xp: Some(fresh.clone()),
11470 progression_baseline: curve.baseline_display,
11471 progression_xp_base: curve.xp_base,
11472 progression_xp_growth: curve.xp_growth,
11473 attributes: state.player.as_ref().and_then(|p| p.attributes),
11474 skills: state.player.as_ref().and_then(|p| p.skills.clone()),
11475 ..CombatHud::default()
11476 };
11477 state.apply_combat_hud(&combat);
11478
11479 let xp = state
11480 .player
11481 .as_ref()
11482 .and_then(|p| p.progression_xp.as_ref())
11483 .expect("xp");
11484 assert!((xp.strength - fresh.strength).abs() < 0.001);
11485 assert!(state.progression_curve.is_some());
11486 }
11487
11488 #[test]
11489 fn combat_hud_syncs_known_abilities_and_hotbar() {
11490 use flatland_protocol::CombatHud;
11491
11492 let mut state = sample_state();
11493 let combat = CombatHud {
11494 known_abilities: vec!["unarmed".into(), "fireball".into()],
11495 hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
11496 max_abilities_per_rotation: 4,
11497 ability_id: "short_sword_slash".into(),
11498 ..CombatHud::default()
11499 };
11500 state.apply_combat_hud(&combat);
11501
11502 assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
11503 assert_eq!(state.hotbar_ability(1), Some("fireball"));
11504 assert_eq!(state.hotbar_ability(2), None);
11505 assert_eq!(state.hotbar_ability(3), Some("unarmed"));
11506 assert_eq!(state.max_abilities_per_rotation, 4);
11507 let choices = state.loadout_ability_choices();
11508 assert!(choices.iter().any(|a| a == "short_sword_slash"));
11509 assert!(choices.iter().any(|a| a == "fireball"));
11510 }
11511
11512 #[test]
11513 fn loose_consumable_move_picker_offers_use_and_storage() {
11514 let mut state = sample_state();
11515 let inst = uuid::Uuid::from_u128(77);
11516 state.inventory_stacks = vec![flatland_protocol::ItemStack {
11517 template_id: "carrot".into(),
11518 quantity: 2,
11519 item_instance_id: Some(inst),
11520 props: Default::default(),
11521 status_bindings: Vec::new(),
11522 contents: Vec::new(),
11523 display_name: Some("Wild Carrot".into()),
11524 category: Some("consumable".into()),
11525 base_mass: None,
11526 base_volume: None,
11527 capacity_volume: None,
11528 stackable: Some(true),
11529 world_placeable: None,
11530 worker_lodging_capacity: None,
11531 equip_slot: None,
11532 armor_physical: None,
11533 resists: vec![],
11534 hand_slots: None,
11535 }];
11536 state.inventory_hints.insert(
11537 "carrot".into(),
11538 InventoryHint {
11539 display_name: "Wild Carrot".into(),
11540 category: "consumable".into(),
11541 base_mass: Some(0.15),
11542 base_volume: Some(0.3),
11543 capacity_volume: None,
11544 stackable: true,
11545 },
11546 );
11547 state.show_inventory_menu = true;
11548 state.inventory_menu_index = 0;
11549
11550 let row = state.inventory_selected_row().expect("carrot row");
11551 let mut options = state.move_destinations_for(
11552 &row.from,
11553 row.from_parent_instance_id,
11554 row.stack.item_instance_id,
11555 &row.stack.template_id,
11556 );
11557 if row.from == flatland_protocol::InventoryLocation::Root
11558 && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
11559 {
11560 options.insert(
11561 0,
11562 MoveOption {
11563 label: "Use (eat / drink)".into(),
11564 kind: MoveOptionKind::Use,
11565 },
11566 );
11567 }
11568
11569 assert_eq!(options.first().map(|o| &o.label), Some(&"Use (eat / drink)".into()));
11570 assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
11571 assert!(options.iter().any(|o| matches!(o.kind, MoveOptionKind::Drop)));
11572 }
11573
11574 #[test]
11575 fn inventory_category_group_order_is_stable() {
11576 assert_eq!(inventory_category_group("weapon").0, "Weapons");
11577 assert_eq!(inventory_category_group("armor").0, "Armor");
11578 assert_eq!(inventory_category_group("consumable").0, "Consumables");
11579 assert_eq!(inventory_category_group("resource").0, "Resources");
11580 assert_eq!(inventory_category_group("container").0, "Containers");
11581 assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
11582 assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
11583 }
11584
11585 #[test]
11586 fn page_list_index_clamps_without_wrap() {
11587 assert_eq!(page_list_index(0, -1, 25), 0);
11588 assert_eq!(page_list_index(0, 1, 25), 10);
11589 assert_eq!(page_list_index(12, 1, 25), 22);
11590 assert_eq!(page_list_index(22, 1, 25), 24);
11591 assert_eq!(page_list_index(5, 1, 0), 0);
11592 assert_eq!(page_list_index(3, -1, 8), 0);
11593 }
11594
11595 #[test]
11596 fn inventory_filter_hides_non_matching_person_items() {
11597 let mut state = sample_state();
11598 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
11599 sword.display_name = Some("Iron Sword".into());
11600 sword.category = Some("weapon".into());
11601 let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
11602 herb.display_name = Some("Wild Herb".into());
11603 herb.category = Some("consumable".into());
11604 state.inventory_stacks = vec![sword, herb];
11605 state.inventory_tab = InventoryTab::OnPerson;
11606 state.inventory_filter = "sword".into();
11607
11608 let rows = state.inventory_selectable_rows();
11609 assert_eq!(rows.len(), 1);
11610 assert_eq!(rows[0].stack.template_id, "iron_sword");
11611
11612 let lines = state.inventory_browser_lines();
11613 assert!(lines.iter().any(|l| matches!(
11614 l,
11615 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
11616 )));
11617 assert!(!lines.iter().any(|l| matches!(
11618 l,
11619 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
11620 )));
11621 }
11622
11623 #[test]
11624 fn inventory_person_rows_group_by_category() {
11625 let mut state = sample_state();
11626 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
11627 sword.category = Some("weapon".into());
11628 sword.display_name = Some("Iron Sword".into());
11629 let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
11630 ore.category = Some("resource".into());
11631 ore.display_name = Some("Iron Ore".into());
11632 let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
11633 potion.category = Some("consumable".into());
11634 potion.display_name = Some("Health Potion".into());
11635 state.inventory_stacks = vec![ore, potion, sword];
11636 state.inventory_tab = InventoryTab::OnPerson;
11637
11638 let lines = state.inventory_browser_lines();
11639 let labels: Vec<&str> = lines
11640 .iter()
11641 .filter_map(|l| match l {
11642 InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
11643 _ => None,
11644 })
11645 .collect();
11646 assert!(
11647 labels.iter().any(|s| s.contains("Weapons")),
11648 "expected Weapons group: {labels:?}"
11649 );
11650 assert!(labels.iter().any(|s| s.contains("Consumables")));
11651 assert!(labels.iter().any(|s| s.contains("Resources")));
11652
11653 let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
11654 let consumable_pos = labels.iter().position(|s| s.contains("Consumables")).unwrap();
11655 let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
11656 assert!(weapon_pos < consumable_pos);
11657 assert!(consumable_pos < resource_pos);
11658 }
11659
11660 #[test]
11661 fn inventory_tab_cycle_resets_selection() {
11662 let mut state = sample_state();
11663 state.inventory_tab = InventoryTab::OnPerson;
11664 state.inventory_menu_index = 3;
11665 state.inventory_tab = state.inventory_tab.cycle(true);
11666 assert_eq!(state.inventory_tab, InventoryTab::Nearby);
11667 assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
11669 assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
11670 assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
11671 assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
11672 }
11673
11674 #[test]
11675 fn parse_bank_copper_amount_blank_and_zero_mean_all() {
11676 assert_eq!(parse_bank_copper_amount(""), Some(0));
11677 assert_eq!(parse_bank_copper_amount(" "), Some(0));
11678 assert_eq!(parse_bank_copper_amount("0"), Some(0));
11679 assert_eq!(parse_bank_copper_amount("250"), Some(250));
11680 assert_eq!(parse_bank_copper_amount("nope"), None);
11681 }
11682
11683 #[test]
11684 fn parse_storage_quantity_blank_and_zero_mean_all() {
11685 assert_eq!(parse_storage_quantity(""), Some(None));
11686 assert_eq!(parse_storage_quantity(" "), Some(None));
11687 assert_eq!(parse_storage_quantity("0"), Some(None));
11688 assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
11689 assert_eq!(parse_storage_quantity("nope"), None);
11690 }
11691}