1use std::collections::{BTreeMap, HashMap, HashSet, 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 PROPERTY_DEED_TEMPLATE: &str = "property_deed";
73const PROP_LOCK_ID: &str = "lock_id";
74const PROP_OPENS_LOCK_ID: &str = "opens_lock_id";
75const PROP_OPENS_CONTAINER_NAME: &str = "opens_container_name";
76const PROP_CUSTOM_NAME: &str = "custom_name";
77const PROP_LOCKED: &str = "locked";
78
79#[derive(Debug, Clone, PartialEq)]
81pub struct ClaimModeState {
82 pub zone_id: String,
83 pub width_m: u32,
84 pub height_m: u32,
85 pub anchor_x: f32,
86 pub anchor_y: f32,
87}
88
89#[derive(Debug, Clone, PartialEq)]
91pub struct RelocateModeState {
92 pub container_id: String,
93 pub label: String,
94 pub cursor_x: f32,
95 pub cursor_y: f32,
96}
97
98fn stack_is_locked(stack: &flatland_protocol::ItemStack) -> bool {
99 stack
100 .props
101 .get(PROP_LOCKED)
102 .is_some_and(|v| v == "true" || v == "1")
103}
104
105const MAX_LOG_LINES: usize = 200;
106const MAX_SHOP_TRADE_LOG_LINES: usize = 40;
107const INTERACTION_RADIUS_M: f32 = 1.5;
108const DOOR_INTERACTION_RADIUS_M: f32 = 3.5;
109const QUEST_BOARD_INTERACTION_RADIUS_M: f32 = 3.0;
110const HARVEST_CLIENT_TIMEOUT: Duration = Duration::from_secs(12);
111const CRAFT_STAMINA_COST: f32 = 3.0;
113const WORKER_STEP_HOLD: Duration = Duration::from_millis(1200);
115const WORKER_ERROR_HOLD: Duration = Duration::from_secs(45);
117
118#[derive(Debug, Clone, Default)]
120pub struct InventoryHint {
121 pub display_name: String,
122 pub category: String,
123 pub base_mass: Option<f32>,
124 pub base_volume: Option<f32>,
125 pub capacity_volume: Option<f32>,
126 pub stackable: bool,
127 pub listable: bool,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct LoadoutHotbarChoice {
134 pub binding: String,
136 pub label: String,
138 pub meta: Option<String>,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
144pub enum RotationEditorMode {
145 #[default]
146 List,
147 EditSequence,
148 PickAbility,
149 EditLabel,
150}
151
152#[derive(Debug, Clone, Default)]
154pub struct RotationEditorState {
155 pub mode: RotationEditorMode,
156 pub list_index: usize,
157 pub ability_index: usize,
158 pub picker_index: usize,
159 pub draft: Option<RotationPreset>,
160 pub label_buffer: String,
161}
162
163impl RotationEditorState {
164 pub fn reset(&mut self) {
165 *self = Self::default();
166 }
167}
168
169pub const CONTAINER_RANGE_M: f32 = 3.0;
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum InventorySection {
179 Worn,
181 Person,
183 Nearby,
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
189pub enum InventoryTab {
190 #[default]
191 OnPerson,
192 Nearby,
193}
194
195impl InventoryTab {
196 pub fn label(self) -> &'static str {
197 match self {
198 Self::OnPerson => "On person",
199 Self::Nearby => "Nearby storage",
200 }
201 }
202
203 pub fn cycle(self, forward: bool) -> Self {
204 match (self, forward) {
205 (Self::OnPerson, true) | (Self::OnPerson, false) => Self::Nearby,
206 (Self::Nearby, true) | (Self::Nearby, false) => Self::OnPerson,
207 }
208 }
209}
210
211pub const LIST_PAGE_SIZE: usize = 10;
213
214pub fn list_label_matches(haystack: &str, filter: &str) -> bool {
216 if filter.is_empty() {
217 return true;
218 }
219 haystack
220 .to_ascii_lowercase()
221 .contains(&filter.to_ascii_lowercase())
222}
223
224pub fn page_list_index(index: usize, pages: i32, len: usize) -> usize {
226 if len == 0 {
227 return 0;
228 }
229 let page = LIST_PAGE_SIZE as i32;
230 let next = index as i32 + pages * page;
231 next.clamp(0, (len as i32) - 1) as usize
232}
233
234pub fn step_filtered_index(index: usize, delta: i32, len: usize, pred: impl Fn(usize) -> bool) -> usize {
236 if len == 0 {
237 return 0;
238 }
239 let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
240 if matching.is_empty() {
241 return index.min(len - 1);
242 }
243 let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
244 let next = (pos as i32 + delta).rem_euclid(matching.len() as i32) as usize;
245 matching[next]
246}
247
248pub fn page_filtered_index(
250 index: usize,
251 pages: i32,
252 len: usize,
253 pred: impl Fn(usize) -> bool,
254) -> usize {
255 if len == 0 {
256 return 0;
257 }
258 let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
259 if matching.is_empty() {
260 return index.min(len - 1);
261 }
262 let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
263 let next = page_list_index(pos, pages, matching.len());
264 matching[next]
265}
266
267pub fn inventory_category_group(category: &str) -> (&'static str, u8) {
269 match category {
270 "weapon" | "ammo" => ("Weapons", 0),
271 "armor" | "shield" | "offhand" => ("Armor", 1),
272 "consumable" => ("Consumables", 2),
273 "resource" | "harvest_node" | "seed" => ("Resources", 3),
274 "container" | "lodging" => ("Containers", 4),
275 "currency" | "key" => ("Currency & keys", 5),
276 "tool" | "misc" | "furniture" | "quest" | "document" => ("Gear & misc", 6),
277 _ => ("Other", 7),
278 }
279}
280
281pub fn category_default_listable(category: &str) -> bool {
283 !matches!(
284 category,
285 "currency" | "harvest_node" | "key" | "quest" | "document" | "lodging"
286 )
287}
288
289fn parse_bank_copper_amount(input: &str) -> Option<u64> {
291 let s = input.trim();
292 if s.is_empty() {
293 return Some(0);
294 }
295 s.parse::<u64>().ok()
296}
297
298fn parse_storage_quantity(input: &str) -> Option<Option<u32>> {
300 let s = input.trim();
301 if s.is_empty() || s == "0" {
302 return Some(None);
303 }
304 let n = s.parse::<u32>().ok()?;
305 if n == 0 {
306 return Some(None);
307 }
308 Some(Some(n))
309}
310
311fn storage_stack_label(stack: &flatland_protocol::ItemStack) -> String {
312 let name = stack
313 .display_name
314 .as_deref()
315 .unwrap_or(stack.template_id.as_str());
316 if stack.quantity > 1 {
317 format!("{name} ×{}", stack.quantity)
318 } else {
319 name.to_string()
320 }
321}
322
323pub fn body_slot_label(slot: BodySlot) -> &'static str {
326 match slot {
327 BodySlot::Head => "Head",
328 BodySlot::Chest => "Chest",
329 BodySlot::Forearms => "Forearms",
330 BodySlot::Legs => "Legs",
331 BodySlot::Feet => "Feet",
332 BodySlot::Cloak => "Cloak",
333 BodySlot::Back => "Back",
334 BodySlot::Waist => "Waist",
335 BodySlot::Earrings => "Earrings",
336 BodySlot::Necklace => "Necklace",
337 BodySlot::Eyeglasses => "Eyeglasses",
338 BodySlot::RingLeft1 => "Ring L1",
339 BodySlot::RingLeft2 => "Ring L2",
340 BodySlot::RingRight1 => "Ring R1",
341 BodySlot::RingRight2 => "Ring R2",
342 }
343}
344
345fn grant_target_matches_mode(stack: &flatland_protocol::ItemStack, mode: &str) -> bool {
346 let cat = stack.category.as_deref().unwrap_or("");
347 match mode {
348 "while_equipped" => {
349 stack.equip_slot.is_some()
350 || cat == "weapon"
351 || cat == "shield"
352 || cat == "offhand"
353 || cat == "armor"
354 }
355 _ => cat == "weapon" || cat == "ammo" || stack.props.contains_key("weapon_ability_id"),
356 }
357}
358
359fn grant_tags_match(stack: &flatland_protocol::ItemStack, grant_tags: &[&str]) -> bool {
360 if grant_tags.is_empty() {
361 return true;
362 }
363 let target_tags: Vec<&str> = stack
364 .props
365 .get("allowed_enchant_tags")
366 .map(|s| {
367 s.split(',')
368 .map(str::trim)
369 .filter(|t| !t.is_empty())
370 .collect()
371 })
372 .unwrap_or_default();
373 if target_tags.is_empty() {
374 return true;
375 }
376 grant_tags.iter().any(|t| target_tags.contains(t))
377}
378
379pub const DEFAULT_TICK_HZ: u32 = 30;
381
382pub fn format_binding_ttl(
384 binding: &flatland_protocol::ItemStatusBinding,
385 tick: u64,
386 tick_hz: u32,
387) -> String {
388 let Some(expires) = binding.expires_at_tick else {
389 return "permanent".into();
390 };
391 let hz = tick_hz.max(1) as f32;
392 let remaining = expires.saturating_sub(tick) as f32 / hz;
393 if remaining <= 0.0 {
394 return "expired".into();
395 }
396 if remaining >= 120.0 {
397 format!("{:.0}m left", remaining / 60.0)
398 } else if remaining >= 10.0 {
399 format!("{remaining:.0}s left")
400 } else {
401 format!("{remaining:.1}s left")
402 }
403}
404
405pub fn format_binding_mode(mode: flatland_protocol::ItemStatusBindingMode) -> &'static str {
406 match mode {
407 flatland_protocol::ItemStatusBindingMode::OnHit => "on hit",
408 flatland_protocol::ItemStatusBindingMode::WhileEquipped => "while equipped",
409 }
410}
411
412pub fn format_status_bindings_suffix(
414 bindings: &[flatland_protocol::ItemStatusBinding],
415 tick: u64,
416 tick_hz: u32,
417) -> String {
418 if bindings.is_empty() {
419 return String::new();
420 }
421 let parts: Vec<String> = bindings
422 .iter()
423 .map(|b| {
424 format!(
425 "{} ({}, {})",
426 b.effect_id,
427 format_binding_mode(b.mode),
428 format_binding_ttl(b, tick, tick_hz)
429 )
430 })
431 .collect();
432 format!(" · {}", parts.join("; "))
433}
434
435#[derive(Debug, Clone, Copy, PartialEq, Eq)]
436pub enum EquipPaperdollRow {
437 Body { slot: BodySlot, filled: bool },
438 Mainhand { filled: bool },
439 Offhand { filled: bool, locked: bool },
440}
441
442pub fn equip_paperdoll_rows(state: &GameState) -> Vec<EquipPaperdollRow> {
443 let mut rows: Vec<EquipPaperdollRow> = BodySlot::ALL
444 .iter()
445 .map(|slot| EquipPaperdollRow::Body {
446 slot: *slot,
447 filled: state.worn.contains_key(slot),
448 })
449 .collect();
450 let two_hand = state.mainhand_hand_slots >= 2;
451 rows.push(EquipPaperdollRow::Mainhand {
452 filled: state.mainhand_template_id.is_some(),
453 });
454 rows.push(EquipPaperdollRow::Offhand {
455 filled: state.offhand_template_id.is_some(),
456 locked: two_hand,
457 });
458 rows
459}
460
461fn first_inventory_for_slot(state: &GameState, slot: BodySlot) -> Option<uuid::Uuid> {
462 for stack in &state.inventory_stacks {
463 let matches = stack
464 .equip_slot
465 .map(|s| s == slot || (is_client_ring(s) && is_client_ring(slot)))
466 .unwrap_or(false)
467 || guess_body_slot(&stack.template_id) == Some(slot);
468 if matches {
469 return stack.item_instance_id;
470 }
471 }
472 None
473}
474
475fn is_client_ring(slot: BodySlot) -> bool {
476 matches!(
477 slot,
478 BodySlot::RingLeft1
479 | BodySlot::RingLeft2
480 | BodySlot::RingRight1
481 | BodySlot::RingRight2
482 )
483}
484
485fn first_inventory_weapon(state: &GameState) -> Option<String> {
486 for stack in &state.inventory_stacks {
487 if stack.category.as_deref() == Some("weapon") {
488 return Some(stack.template_id.clone());
489 }
490 }
491 None
492}
493
494fn first_inventory_offhand(state: &GameState) -> Option<String> {
495 for stack in &state.inventory_stacks {
496 let cat = stack.category.as_deref().unwrap_or("");
497 if matches!(cat, "shield" | "offhand") {
498 return Some(stack.template_id.clone());
499 }
500 }
501 None
502}
503
504fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
507 if template_id.contains("backpack") {
508 Some(BodySlot::Back)
509 } else if template_id.contains("belt") {
510 Some(BodySlot::Waist)
511 } else if template_id.contains("cloak") || template_id.contains("cape") {
512 Some(BodySlot::Cloak)
513 } else if template_id.contains("cap")
514 || template_id.contains("hat")
515 || template_id.contains("helm")
516 {
517 Some(BodySlot::Head)
518 } else if template_id.contains("shirt")
519 || template_id.contains("robe")
520 || template_id.contains("vest")
521 || template_id.contains("chest")
522 || template_id.contains("jerkin")
523 {
524 Some(BodySlot::Chest)
525 } else if template_id.contains("sleeves")
526 || template_id.contains("gloves")
527 || template_id.contains("gauntlets")
528 {
529 Some(BodySlot::Forearms)
530 } else if template_id.contains("pants") || template_id.contains("leggings") {
531 Some(BodySlot::Legs)
532 } else if template_id.contains("boots") || template_id.contains("shoes") {
533 Some(BodySlot::Feet)
534 } else if template_id.contains("earring") {
535 Some(BodySlot::Earrings)
536 } else if template_id.contains("necklace") || template_id.contains("amulet") {
537 Some(BodySlot::Necklace)
538 } else if template_id.contains("glass")
539 || template_id.contains("spectacles")
540 || template_id.contains("goggles")
541 {
542 Some(BodySlot::Eyeglasses)
543 } else if template_id.contains("ring") {
544 Some(BodySlot::RingLeft1)
545 } else {
546 None
547 }
548}
549
550#[derive(Debug, Clone)]
552pub struct InventoryRow {
553 pub depth: usize,
554 pub stack: flatland_protocol::ItemStack,
555 pub from: flatland_protocol::InventoryLocation,
557 pub from_parent_instance_id: Option<uuid::Uuid>,
559 pub is_equip_shell: bool,
561 pub is_chest_shell: bool,
563 pub section: InventorySection,
564}
565
566#[derive(Debug, Clone)]
568pub struct InventoryRowView {
569 pub depth: usize,
570 pub text: String,
572 pub title: String,
574 pub mass_kg: Option<f32>,
575 pub volume: Option<(f32, f32)>,
576 pub instance_tooltip: Option<String>,
578}
579
580#[derive(Debug, Clone)]
582pub enum InventoryBrowserLine {
583 Section(String),
584 SlotLabel(String),
585 Hint(String),
586 Blank,
587 Item {
588 selectable_index: usize,
589 selected: bool,
590 depth: usize,
591 text: String,
592 title: String,
593 mass_kg: Option<f32>,
594 volume: Option<(f32, f32)>,
595 instance_tooltip: Option<String>,
596 },
597}
598
599#[derive(Debug, Clone, PartialEq, Eq, Default)]
601pub enum BankUiMode {
602 #[default]
603 Menu,
604 DepositAmount {
605 input: String,
606 },
607 WithdrawAmount {
608 input: String,
609 },
610 TransferName {
611 input: String,
612 },
613 TransferAmount {
614 to_name: String,
615 input: String,
616 },
617}
618
619#[derive(Debug, Clone, PartialEq, Eq, Default)]
621pub enum StorageUiMode {
622 #[default]
623 Menu,
624 StorePick {
626 index: usize,
627 },
628 StoreAmount {
630 pick_index: usize,
631 item_instance_id: uuid::Uuid,
632 label: String,
633 max_qty: u32,
634 input: String,
635 },
636 TakePick {
638 index: usize,
639 },
640 TakeAmount {
642 pick_index: usize,
643 item_instance_id: uuid::Uuid,
644 label: String,
645 max_qty: u32,
646 input: String,
647 },
648 ShipPick {
650 dest_building_id: String,
651 dest_label: String,
652 index: usize,
653 },
654 ShipAmount {
656 dest_building_id: String,
657 dest_label: String,
658 pick_index: usize,
659 item_instance_id: uuid::Uuid,
660 label: String,
661 max_qty: u32,
662 input: String,
663 },
664}
665
666#[derive(Debug, Clone, PartialEq, Eq)]
668pub enum MarketListSourceKind {
669 Person,
670 TownStorage { building_id: String },
671}
672
673#[derive(Debug, Clone, PartialEq, Eq, Default)]
675pub enum MarketUiMode {
676 #[default]
677 Browse,
678 ListSource {
680 index: usize,
681 },
682 ListPick {
684 source: MarketListSourceKind,
685 index: usize,
686 },
687 ListAmount {
689 source: MarketListSourceKind,
690 pick_index: usize,
691 item_instance_id: uuid::Uuid,
692 label: String,
693 max_qty: u32,
694 input: String,
695 },
696 ListPricingMode {
698 source: MarketListSourceKind,
699 item_instance_id: uuid::Uuid,
700 label: String,
701 quantity: Option<u32>,
702 max_qty: u32,
703 index: usize,
705 },
706 ListPrice {
708 source: MarketListSourceKind,
709 item_instance_id: uuid::Uuid,
710 label: String,
711 quantity: Option<u32>,
713 max_qty: u32,
714 input: String,
715 },
716}
717
718#[derive(Debug, Clone)]
720pub struct StoragePickOption {
721 pub item_instance_id: uuid::Uuid,
722 pub label: String,
723 pub quantity: u32,
724 pub category: String,
726}
727
728#[derive(Debug, Clone)]
731pub struct NearbyContainer {
732 pub view: flatland_protocol::PlacedContainerView,
733 pub distance_m: f32,
734 pub rows: Vec<InventoryRow>,
735}
736
737#[derive(Debug, Clone)]
739pub struct KeychainEntry {
740 pub stack: flatland_protocol::ItemStack,
741 pub stowed: bool,
742}
743
744#[derive(Debug, Clone)]
746pub struct MoveOption {
747 pub label: String,
748 pub kind: MoveOptionKind,
749}
750
751#[derive(Debug, Clone, PartialEq)]
752pub enum MoveOptionKind {
753 Move {
754 location: flatland_protocol::InventoryLocation,
755 parent_instance_id: Option<uuid::Uuid>,
756 },
757 PickupPlaced {
759 container_id: String,
760 nest_location: flatland_protocol::InventoryLocation,
761 nest_parent_instance_id: Option<uuid::Uuid>,
762 },
763 RelocatePlaced {
765 container_id: String,
766 },
767 Use,
769 GrantApply,
771 Drop,
772 SellPlotToCrown {
774 plot_id: uuid::Uuid,
775 },
776 Cancel,
777}
778
779#[derive(Debug, Clone, PartialEq)]
781pub enum FarmAccessRow {
782 PublicToggle,
783 PublicDiscount,
784 AllowRemove {
785 character_id: uuid::Uuid,
786 label: String,
787 tax_discount_bps: u32,
788 },
789 NearbyAdd {
790 name: String,
791 },
792}
793
794#[derive(Debug, Clone)]
796pub struct GrantTargetPicker {
797 pub grant_instance_id: uuid::Uuid,
798 pub grant_label: String,
799 pub effect_id: String,
800 pub mode: String,
801 pub options: Vec<GrantTargetOption>,
802 pub filter: String,
803 pub filter_focused: bool,
804}
805
806#[derive(Debug, Clone)]
807pub struct GrantTargetOption {
808 pub label: String,
809 pub target_instance_id: uuid::Uuid,
810}
811
812#[derive(Debug, Clone)]
814pub struct MovePicker {
815 pub item_instance_id: uuid::Uuid,
816 pub from: flatland_protocol::InventoryLocation,
817 pub item_label: String,
818 pub template_id: String,
819 pub stack_quantity: u32,
820 pub quantity: u32,
821 pub options: Vec<MoveOption>,
822 pub filter: String,
823 pub filter_focused: bool,
824}
825
826#[derive(Debug, Clone)]
828pub struct DestroyPicker {
829 pub item_instance_id: uuid::Uuid,
830 pub from: flatland_protocol::InventoryLocation,
831 pub item_label: String,
832 pub stack_quantity: u32,
833 pub quantity: u32,
834}
835
836#[derive(Debug, Clone)]
838pub struct WorkerGiveOption {
839 pub item_instance_id: uuid::Uuid,
840 pub label: String,
841 pub quantity: u32,
842 pub template_id: String,
843}
844
845#[derive(Debug, Clone)]
847pub struct WorkerGivePicker {
848 pub worker_instance_id: String,
849 pub worker_label: String,
850 pub options: Vec<WorkerGiveOption>,
851}
852
853#[derive(Debug, Clone)]
855pub struct WorkerGiveTargetOption {
856 pub instance_id: String,
857 pub label: String,
858 pub distance_m: f32,
859}
860
861#[derive(Debug, Clone)]
863pub struct WorkerGiveTargetPicker {
864 pub item_instance_id: uuid::Uuid,
865 pub item_label: String,
866 pub quantity: Option<u32>,
867 pub options: Vec<WorkerGiveTargetOption>,
868}
869
870#[derive(Debug, Clone)]
872pub struct WorkerTakePicker {
873 pub worker_instance_id: String,
874 pub worker_label: String,
875 pub options: Vec<WorkerGiveOption>,
876 pub quantity: u32,
878}
879
880pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
882
883#[derive(Debug, Clone)]
885pub struct WorkerTeachOption {
886 pub blueprint_id: String,
887 pub label: String,
888 pub cost_copper: u64,
889 pub min_level: u32,
890 pub worker_level: u32,
891 pub can_afford: bool,
892 pub level_ok: bool,
893}
894
895#[derive(Debug, Clone)]
897pub struct WorkerTeachPicker {
898 pub worker_instance_id: String,
899 pub worker_label: String,
900 pub worker_level: u32,
901 pub options: Vec<WorkerTeachOption>,
902}
903
904#[derive(Debug, Clone, Default)]
907pub struct StickyWorkerStep {
908 shown: String,
909 pending: String,
910 pending_since: Option<Instant>,
911}
912
913impl StickyWorkerStep {
914 fn from_label(label: String) -> Self {
915 Self {
916 shown: label.clone(),
917 pending: label,
918 pending_since: Some(Instant::now()),
919 }
920 }
921
922 fn observe(&mut self, label: &str, now: Instant) {
923 let pending_since = self.pending_since.unwrap_or(now);
924 if label == self.pending {
925 if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD
926 {
927 self.shown = self.pending.clone();
928 }
929 return;
930 }
931 self.pending = label.to_string();
932 self.pending_since = Some(now);
933 if self.shown.is_empty() {
935 self.shown = self.pending.clone();
936 }
937 }
938}
939
940#[derive(Debug, Clone, Default)]
943pub struct StickyWorkerError {
944 message: String,
945 last_seen: Option<Instant>,
946}
947
948impl StickyWorkerError {
949 fn observe(&mut self, err: Option<&str>, now: Instant) {
950 if let Some(e) = err {
951 if !worker_error_is_transient(e) && !worker_error_is_hud_noise(e) {
952 self.message = e.to_string();
953 self.last_seen = Some(now);
954 }
955 return;
956 }
957 if let Some(seen) = self.last_seen {
958 if now.duration_since(seen) > WORKER_ERROR_HOLD {
959 self.message.clear();
960 self.last_seen = None;
961 }
962 }
963 }
964
965 pub fn shown(&self, now: Instant) -> Option<&str> {
966 if self.message.is_empty() {
967 return None;
968 }
969 let seen = self.last_seen?;
970 if now.duration_since(seen) > WORKER_ERROR_HOLD {
971 return None;
972 }
973 Some(self.message.as_str())
974 }
975}
976
977pub fn worker_attention_line(state: &GameState) -> Option<String> {
980 use flatland_protocol::WorkerStateView;
981 let now = Instant::now();
982 for w in &state.hired_workers {
983 if matches!(w.state, WorkerStateView::Strike) {
984 return Some(format!(
985 "Worker {}: on strike — fund bank, pay wages, or stock lodging chest",
986 w.label
987 ));
988 }
989 if let Some(err) = state
990 .worker_error_display
991 .get(&w.instance_id)
992 .and_then(|s| s.shown(now))
993 {
994 if !worker_error_is_hud_noise(err) {
995 return Some(format!("Worker {}: {err}", w.label));
996 }
997 }
998 if let Some(err) = &w.last_error {
999 if !worker_error_is_transient(err) && !worker_error_is_hud_noise(err) {
1000 return Some(format!("Worker {}: {err}", w.label));
1001 }
1002 }
1003 }
1004 None
1005}
1006
1007pub fn worker_error_is_transient(err: &str) -> bool {
1009 let e = err.to_ascii_lowercase();
1010 e.contains("continuing route")
1011 || e.contains("storage full")
1012 || e.starts_with("nothing to withdraw")
1013}
1014
1015pub fn worker_error_is_hud_noise(err: &str) -> bool {
1018 let e = err.to_ascii_lowercase();
1019 e.contains("returned to lodging after path")
1020 || e.contains("path failure")
1021 || e.contains("no path to")
1022 || e.contains("pathfinding")
1023 || e.contains("repathing")
1025 || e.contains("nudged clear")
1026}
1027
1028#[derive(Debug, Clone)]
1030pub struct PendingWorkerJobAck {
1031 pub seq: u32,
1032 pub worker_instance_id: String,
1033 pub worker_label: String,
1034 pub idle: bool,
1035 pub stop_count: usize,
1036 pub prev_route: Option<flatland_protocol::WorkerRouteView>,
1037 pub prev_mode: flatland_protocol::WorkerModeView,
1038 pub prev_step_label: String,
1039 pub prev_last_error: Option<String>,
1040}
1041
1042fn push_inventory_rows(
1043 rows: &mut Vec<InventoryRow>,
1044 depth: usize,
1045 stack: &flatland_protocol::ItemStack,
1046 from: &flatland_protocol::InventoryLocation,
1047 from_parent_instance_id: Option<uuid::Uuid>,
1048 section: InventorySection,
1049) {
1050 push_inventory_rows_filtered(
1051 rows,
1052 depth,
1053 stack,
1054 from,
1055 from_parent_instance_id,
1056 section,
1057 "",
1058 );
1059}
1060
1061fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
1062 if filter.is_empty() {
1063 return true;
1064 }
1065 let f = filter.to_ascii_lowercase();
1066 let name = stack
1067 .display_name
1068 .as_deref()
1069 .unwrap_or("")
1070 .to_ascii_lowercase();
1071 let tid = stack.template_id.to_ascii_lowercase();
1072 name.contains(&f)
1073 || tid.contains(&f)
1074 || stack
1075 .contents
1076 .iter()
1077 .any(|c| stack_matches_filter(c, filter))
1078}
1079
1080fn push_inventory_rows_filtered(
1081 rows: &mut Vec<InventoryRow>,
1082 depth: usize,
1083 stack: &flatland_protocol::ItemStack,
1084 from: &flatland_protocol::InventoryLocation,
1085 from_parent_instance_id: Option<uuid::Uuid>,
1086 section: InventorySection,
1087 filter: &str,
1088) {
1089 if !filter.is_empty() && !stack_matches_filter(stack, filter) {
1090 return;
1091 }
1092 let self_hit = filter.is_empty() || {
1093 let f = filter.to_ascii_lowercase();
1094 let name = stack
1095 .display_name
1096 .as_deref()
1097 .unwrap_or("")
1098 .to_ascii_lowercase();
1099 let tid = stack.template_id.to_ascii_lowercase();
1100 name.contains(&f) || tid.contains(&f)
1101 };
1102 rows.push(InventoryRow {
1103 depth,
1104 stack: stack.clone(),
1105 from: from.clone(),
1106 from_parent_instance_id,
1107 is_equip_shell: false,
1108 is_chest_shell: false,
1109 section,
1110 });
1111 for child in &stack.contents {
1112 if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
1113 push_inventory_rows_filtered(
1114 rows,
1115 depth + 1,
1116 child,
1117 from,
1118 stack.item_instance_id,
1119 section,
1120 if self_hit { "" } else { filter },
1121 );
1122 }
1123 }
1124}
1125
1126#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1127pub enum ShopTab {
1128 #[default]
1129 Buy,
1130 Sell,
1131}
1132
1133#[derive(Debug, Clone)]
1134pub struct NpcChatState {
1135 pub npc_id: String,
1136 pub npc_label: String,
1137 pub lines: Vec<String>,
1138 pub input: String,
1139 pub pending: bool,
1140 pub talk_depth: flatland_protocol::NpcTalkDepth,
1141 pub trade_allowed: bool,
1142 pub banner: Option<String>,
1143}
1144
1145impl Default for NpcChatState {
1146 fn default() -> Self {
1147 Self {
1148 npc_id: String::new(),
1149 npc_label: String::new(),
1150 lines: Vec::new(),
1151 input: String::new(),
1152 pending: false,
1153 talk_depth: flatland_protocol::NpcTalkDepth::Full,
1154 trade_allowed: true,
1155 banner: None,
1156 }
1157 }
1158}
1159
1160#[derive(Debug, Clone)]
1161pub struct GameState {
1162 pub session_id: SessionId,
1163 pub entity_id: EntityId,
1164 pub character_id: Option<uuid::Uuid>,
1166 pub tick: Tick,
1167 pub chunk_rev: u64,
1168 pub content_rev: u64,
1169 pub publish_rev: u64,
1170 pub entities: Vec<EntityState>,
1171 pub player: Option<EntityState>,
1172 pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1173 pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1174 pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1175 pub buildings: Vec<BuildingView>,
1176 pub doors: Vec<DoorView>,
1177 pub interior_map: Option<InteriorMapView>,
1178 pub npcs: Vec<NpcView>,
1179 pub blueprints: Vec<BlueprintView>,
1180 pub building_materials: Vec<flatland_protocol::BuildingMaterialView>,
1182 pub world_x0: f32,
1184 pub world_y0: f32,
1185 pub world_width_m: f32,
1186 pub world_height_m: f32,
1187 pub terrain_zones: Vec<TerrainZoneView>,
1188 pub z_platforms: Vec<ZPlatformView>,
1189 pub z_transitions: Vec<ZTransitionView>,
1190 #[doc(hidden)]
1193 pub z_bands_outdoor_backup: Option<(Vec<ZPlatformView>, Vec<ZTransitionView>)>,
1194 pub world_clock: flatland_protocol::WorldClock,
1195 pub inventory: std::collections::HashMap<String, u32>,
1196 pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1197 pub logs: VecDeque<String>,
1198 pub intents_sent: u64,
1199 pub ticks_received: u64,
1200 pub connected: bool,
1201 pub disconnect_reason: Option<String>,
1202 pub show_stats: bool,
1203 pub hud_log_hidden: bool,
1205 pub show_equip_menu: bool,
1206 pub equip_menu_index: usize,
1207 pub show_craft_menu: bool,
1208 pub craft_menu_index: usize,
1209 pub craft_batch_quantity: u32,
1211 pub show_plot_build_menu: bool,
1213 pub plot_build_focus_wall: bool,
1215 pub plot_build_wall_index: usize,
1216 pub plot_build_roof_index: usize,
1217 pub show_shop_menu: bool,
1218 pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1219 pub bank_panel: Option<flatland_protocol::BankPanel>,
1220 pub bank_menu_index: usize,
1221 pub bank_ui_mode: BankUiMode,
1222 pub storage_panel: Option<flatland_protocol::StoragePanel>,
1223 pub market_panel: Option<flatland_protocol::MarketPanel>,
1224 pub market_menu_index: usize,
1226 pub market_filter: String,
1228 pub market_filter_focused: bool,
1229 pub market_category_filter: Option<&'static str>,
1231 pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
1233 pub market_ui_mode: MarketUiMode,
1234 pub storage_menu_index: usize,
1235 pub storage_ui_mode: StorageUiMode,
1236 pub shop_tab: ShopTab,
1237 pub shop_menu_index: usize,
1238 pub shop_quantity: u32,
1239 pub shop_trade_log: VecDeque<String>,
1241 pub show_npc_verb_menu: bool,
1242 pub npc_verb_target: Option<String>,
1243 pub npc_verb_index: usize,
1244 pub player_verbs: crate::social::PlayerVerbState,
1246 pub social_chat: crate::social::SocialChatState,
1247 pub trade_ui: crate::social::TradeUiState,
1248 pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1249 pub show_npc_chat: bool,
1250 pub npc_chat: Option<NpcChatState>,
1251 pub show_inventory_menu: bool,
1252 pub inventory_menu_index: usize,
1253 pub inventory_tab: InventoryTab,
1254 pub inventory_filter: String,
1255 pub inventory_filter_focused: bool,
1256 pub show_move_picker: bool,
1257 pub move_picker_index: usize,
1258 pub move_picker: Option<MovePicker>,
1259 pub show_grant_picker: bool,
1260 pub grant_picker_index: usize,
1261 pub grant_picker: Option<GrantTargetPicker>,
1262 pub show_destroy_picker: bool,
1263 pub destroy_confirm_pending: bool,
1264 pub destroy_picker: Option<DestroyPicker>,
1265 pub show_rename_prompt: bool,
1267 pub rename_plot_id: Option<uuid::Uuid>,
1269 pub highlighted_plot_id: Option<uuid::Uuid>,
1271 pub show_worker_rename: bool,
1273 pub rename_buffer: String,
1274 pub combat_target: Option<EntityId>,
1276 pub combat_target_label: Option<String>,
1277 pub ground_target: Option<(f32, f32, f32)>,
1280 pub combat_fx: Vec<flatland_protocol::CombatFx>,
1282 pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1284 pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1286 pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1288 pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1290 pub terrain_kind_nav: Vec<flatland_protocol::TerrainKindNavView>,
1292 pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1294 pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1296 pub claim_mode: Option<ClaimModeState>,
1298 pub relocate_mode: Option<RelocateModeState>,
1300 pub sell_plot_confirm: Option<uuid::Uuid>,
1302 pub sell_plot_armed_at: Option<Instant>,
1304 pub show_plant_menu: bool,
1306 pub plant_menu_index: usize,
1307 pub show_farm_access: bool,
1309 pub farm_access_name_draft: String,
1311 pub farm_access_discount_bps: u32,
1313 pub farm_access_index: usize,
1315 pub plant_quantity: u32,
1316 pub in_combat: bool,
1317 pub auto_attack: bool,
1318 pub combat_has_los: bool,
1319 pub attack_cd_ticks: u64,
1320 pub gcd_ticks: u64,
1321 pub weapon_ability_id: String,
1322 pub mainhand_template_id: Option<String>,
1323 pub mainhand_label: Option<String>,
1324 pub mainhand_instance_id: Option<uuid::Uuid>,
1325 pub offhand_template_id: Option<String>,
1326 pub offhand_label: Option<String>,
1327 pub offhand_instance_id: Option<uuid::Uuid>,
1328 pub mainhand_hand_slots: u8,
1329 pub defense: Option<flatland_protocol::DefenseHud>,
1330 pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1332 pub carry_mass: f32,
1333 pub carry_mass_max: f32,
1334 pub encumbrance: flatland_protocol::EncumbranceState,
1335 pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1337 pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1339 pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1341 pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1343 pub combat_target_detail: Option<CombatTargetHud>,
1344 pub cast_progress: Option<CastProgressHud>,
1345 pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1347 pub plot_build_offer: Option<flatland_protocol::PlotBuildOfferHud>,
1349 pub ability_cooldowns: Vec<AbilityCooldownHud>,
1350 pub blocking_active: bool,
1351 pub max_target_slots: u8,
1352 pub combat_slots: Vec<CombatSlotHud>,
1353 pub rotation_presets: Vec<RotationPreset>,
1354 pub known_abilities: Vec<String>,
1356 pub ability_meta: std::collections::HashMap<String, flatland_protocol::AbilityMetaHud>,
1358 pub ability_mastery: std::collections::HashMap<String, flatland_protocol::AbilityMasteryHud>,
1360 pub hotbar: Vec<Option<String>>,
1362 pub max_abilities_per_rotation: u8,
1364 pub show_loadout_menu: bool,
1365 pub show_keychain_menu: bool,
1366 pub keychain_menu_index: usize,
1367 pub show_rotation_editor: bool,
1368 pub loadout_menu_index: usize,
1370 pub loadout_hotbar_slot: u8,
1372 pub loadout_ability_index: usize,
1374 pub loadout_focus_presets: bool,
1376 pub rotation_editor: RotationEditorState,
1377 pub harvest_in_progress: bool,
1379 pub harvest_started_at: Option<Instant>,
1381 pub pending_craft_ack: Option<(u32, String, u32)>,
1383 pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1384 pub interactables: Vec<flatland_protocol::InteractableView>,
1385 pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1386 pub career: Option<flatland_protocol::PlayerCareerView>,
1387 pub character_sheet_tab: CharacterSheetTab,
1388 pub ledger_period: LedgerPeriod,
1389 pub show_quest_offer: bool,
1390 pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
1391 pub show_quest_menu: bool,
1392 pub quest_menu_index: usize,
1393 pub quest_withdraw_confirm: bool,
1394 pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1395 pub show_workers_menu: bool,
1396 pub workers_menu_index: usize,
1397 pub workers_menu_compact: bool,
1399 pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1402 pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1404 pub show_worker_give_picker: bool,
1406 pub worker_give_picker_index: usize,
1407 pub worker_give_picker: Option<WorkerGivePicker>,
1408 pub show_worker_give_target_picker: bool,
1410 pub worker_give_target_picker_index: usize,
1411 pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1412 pub show_worker_take_picker: bool,
1414 pub worker_take_picker_index: usize,
1415 pub worker_take_picker: Option<WorkerTakePicker>,
1416 pub show_worker_teach_picker: bool,
1418 pub worker_teach_picker_index: usize,
1419 pub worker_teach_picker: Option<WorkerTeachPicker>,
1420 pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1422 pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1424 pub attending_worker_instance_id: Option<String>,
1426 pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1428}
1429
1430impl GameState {
1431 pub fn push_log(&mut self, line: impl Into<String>) {
1432 self.logs.push_back(line.into());
1433 while self.logs.len() > MAX_LOG_LINES {
1434 self.logs.pop_front();
1435 }
1436 }
1437
1438 pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1439 self.shop_trade_log.push_back(line.into());
1440 while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1441 self.shop_trade_log.pop_front();
1442 }
1443 }
1444
1445 pub fn clear_shop_trade_log(&mut self) {
1446 self.shop_trade_log.clear();
1447 }
1448
1449 fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1450 if !self.show_shop_menu {
1451 return;
1452 }
1453 let msg = notice.message.trim();
1454 if msg.is_empty() {
1455 return;
1456 }
1457 if notice.coins_delta != 0
1458 || msg.starts_with("Bought ")
1459 || msg.starts_with("Sold ")
1460 || msg.contains("taught you how to craft")
1461 || msg.starts_with("need ")
1462 {
1463 self.push_shop_trade_log(msg);
1464 }
1465 }
1466
1467 pub fn is_alive(&self) -> bool {
1468 self.player
1469 .as_ref()
1470 .and_then(|p| p.vitals)
1471 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1472 .unwrap_or(true)
1473 }
1474
1475 pub fn npc_verb_options(&self) -> Vec<&'static str> {
1477 let Some(ref id) = self.npc_verb_target else {
1478 return vec![];
1479 };
1480 let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
1481 return vec!["Talk"];
1482 };
1483 let role = npc.role.as_str();
1484 if Self::npc_role_is_bank(role) {
1485 return vec!["Bank", "Talk"];
1486 }
1487 if Self::npc_role_is_storage(role) {
1488 return vec!["Storage", "Talk"];
1489 }
1490 if Self::npc_role_is_market(role) {
1491 return vec!["Market", "Talk"];
1492 }
1493 if npc.can_trade || Self::npc_role_can_trade(role) {
1494 vec!["Talk", "Trade"]
1495 } else {
1496 vec!["Talk"]
1497 }
1498 }
1499
1500 fn npc_role_can_trade(role: &str) -> bool {
1501 matches!(role, "broker" | "cook" | "farmer" | "merchant")
1502 }
1503
1504 fn npc_role_is_bank(role: &str) -> bool {
1505 role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
1506 }
1507
1508 fn npc_role_is_storage(role: &str) -> bool {
1509 role.eq_ignore_ascii_case("storage_manager")
1510 }
1511
1512 fn npc_role_is_market(role: &str) -> bool {
1513 role.eq_ignore_ascii_case("market_clerk")
1514 }
1515
1516 pub fn bank_menu_options(&self) -> Vec<&'static str> {
1517 vec![
1518 "Deposit…",
1519 "Withdraw…",
1520 "Deposit all",
1521 "Withdraw all",
1522 "Transfer…",
1523 ]
1524 }
1525
1526 pub fn storage_menu_options(&self) -> Vec<String> {
1527 let mut opts = vec!["Store…".into(), "Take…".into()];
1528 if let Some(panel) = &self.storage_panel {
1529 for dest in &panel.ship_destinations {
1530 opts.push(format!(
1531 "Ship → {} ({} cp / {} ticks)",
1532 dest.label, dest.fee_copper, dest.travel_ticks
1533 ));
1534 }
1535 }
1536 opts
1537 }
1538
1539 pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
1543 let equipped = self.hand_equipped_instance_ids();
1544 self.person_rows()
1545 .into_iter()
1546 .filter(|r| r.depth == 0)
1547 .filter_map(|r| {
1548 let id = r.stack.item_instance_id?;
1549 if equipped.contains(&id) {
1550 return None;
1551 }
1552 Some(StoragePickOption {
1553 item_instance_id: id,
1554 label: storage_stack_label(&r.stack),
1555 quantity: r.stack.quantity,
1556 category: r.stack.category.clone().unwrap_or_default(),
1557 })
1558 })
1559 .collect()
1560 }
1561
1562 pub fn hand_equipped_instance_ids(&self) -> std::collections::HashSet<uuid::Uuid> {
1564 let mut ids = std::collections::HashSet::new();
1565 if let Some(id) = self.mainhand_instance_id {
1566 ids.insert(id);
1567 } else if let Some(tid) = &self.mainhand_template_id {
1568 if let Some(id) = self
1569 .inventory_stacks
1570 .iter()
1571 .find(|s| &s.template_id == tid)
1572 .and_then(|s| s.item_instance_id)
1573 {
1574 ids.insert(id);
1575 }
1576 }
1577 if let Some(id) = self.offhand_instance_id {
1578 ids.insert(id);
1579 } else if let Some(tid) = &self.offhand_template_id {
1580 if let Some(id) = self
1581 .inventory_stacks
1582 .iter()
1583 .find(|s| {
1584 &s.template_id == tid
1585 && s.item_instance_id
1586 .is_some_and(|iid| !ids.contains(&iid))
1587 })
1588 .and_then(|s| s.item_instance_id)
1589 {
1590 ids.insert(id);
1591 }
1592 }
1593 ids
1594 }
1595
1596 pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
1598 let Some(panel) = &self.storage_panel else {
1599 return Vec::new();
1600 };
1601 panel
1602 .contents
1603 .iter()
1604 .filter_map(|s| {
1605 let id = s.item_instance_id?;
1606 Some(StoragePickOption {
1607 item_instance_id: id,
1608 label: storage_stack_label(s),
1609 quantity: s.quantity,
1610 category: s.category.clone().unwrap_or_default(),
1611 })
1612 })
1613 .collect()
1614 }
1615
1616 pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
1618 let mut opts = Vec::new();
1619 if !self
1620 .market_list_item_options(&MarketListSourceKind::Person)
1621 .is_empty()
1622 {
1623 opts.push((MarketListSourceKind::Person, "On person".into()));
1624 }
1625 if let Some(panel) = &self.market_panel {
1626 for vault in &panel.list_vaults {
1627 let source = MarketListSourceKind::TownStorage {
1628 building_id: vault.building_id.clone(),
1629 };
1630 if self.market_list_item_options(&source).is_empty() {
1631 continue;
1632 }
1633 let label = if vault.building_label.is_empty() {
1634 format!("Town storage ({})", vault.building_id)
1635 } else {
1636 format!("Town storage — {}", vault.building_label)
1637 };
1638 opts.push((source, label));
1639 }
1640 }
1641 opts
1642 }
1643
1644 pub fn market_list_item_options(
1646 &self,
1647 source: &MarketListSourceKind,
1648 ) -> Vec<StoragePickOption> {
1649 let filter = self.market_filter.as_str();
1650 let cat_filter = self.market_category_filter;
1651 let mut opts: Vec<StoragePickOption> = match source {
1652 MarketListSourceKind::Person => {
1653 let equipped = self.hand_equipped_instance_ids();
1654 self.person_rows()
1655 .into_iter()
1656 .filter(|r| r.depth == 0)
1657 .filter(|r| self.stack_is_market_listable(&r.stack))
1658 .filter_map(|r| {
1659 let id = r.stack.item_instance_id?;
1660 if equipped.contains(&id) {
1661 return None;
1662 }
1663 Some(StoragePickOption {
1664 item_instance_id: id,
1665 label: storage_stack_label(&r.stack),
1666 quantity: r.stack.quantity,
1667 category: r
1668 .stack
1669 .category
1670 .clone()
1671 .or_else(|| {
1672 self.inventory_item_category(&r.stack.template_id)
1673 .map(str::to_string)
1674 })
1675 .unwrap_or_default(),
1676 })
1677 })
1678 .collect()
1679 }
1680 MarketListSourceKind::TownStorage { building_id } => {
1681 let Some(panel) = &self.market_panel else {
1682 return Vec::new();
1683 };
1684 let Some(vault) = panel
1685 .list_vaults
1686 .iter()
1687 .find(|v| &v.building_id == building_id)
1688 else {
1689 return Vec::new();
1690 };
1691 vault
1692 .contents
1693 .iter()
1694 .filter(|s| self.stack_is_market_listable(s))
1695 .filter_map(|s| {
1696 let id = s.item_instance_id?;
1697 Some(StoragePickOption {
1698 item_instance_id: id,
1699 label: storage_stack_label(s),
1700 quantity: s.quantity,
1701 category: s
1702 .category
1703 .clone()
1704 .or_else(|| {
1705 self.inventory_item_category(&s.template_id)
1706 .map(str::to_string)
1707 })
1708 .unwrap_or_default(),
1709 })
1710 })
1711 .collect()
1712 }
1713 };
1714 opts.retain(|o| {
1715 if !list_label_matches(&o.label, filter) {
1716 return false;
1717 }
1718 if let Some(group) = cat_filter {
1719 inventory_category_group(&o.category).0 == group
1720 } else {
1721 true
1722 }
1723 });
1724 opts
1725 }
1726
1727 fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
1728 if crate::currency::is_currency(&stack.template_id) {
1729 return false;
1730 }
1731 if let Some(flag) = stack.listable {
1732 return flag;
1733 }
1734 if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
1735 return hint.listable;
1736 }
1737 let cat = stack
1738 .category
1739 .as_deref()
1740 .or_else(|| self.inventory_item_category(&stack.template_id))
1741 .unwrap_or("");
1742 category_default_listable(cat)
1743 }
1744
1745 pub fn market_available_category_groups(&self) -> Vec<&'static str> {
1747 let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
1748 match &self.market_ui_mode {
1749 MarketUiMode::ListPick { source, .. } => {
1750 let raw: Vec<_> = match source {
1751 MarketListSourceKind::Person => self
1752 .person_rows()
1753 .into_iter()
1754 .filter(|r| r.depth == 0)
1755 .filter(|r| self.stack_is_market_listable(&r.stack))
1756 .filter(|r| list_label_matches(&storage_stack_label(&r.stack), &self.market_filter))
1757 .map(|r| {
1758 r.stack
1759 .category
1760 .clone()
1761 .or_else(|| {
1762 self.inventory_item_category(&r.stack.template_id)
1763 .map(str::to_string)
1764 })
1765 .unwrap_or_default()
1766 })
1767 .collect(),
1768 MarketListSourceKind::TownStorage { building_id } => self
1769 .market_panel
1770 .as_ref()
1771 .and_then(|p| {
1772 p.list_vaults
1773 .iter()
1774 .find(|v| &v.building_id == building_id)
1775 })
1776 .map(|vault| {
1777 vault
1778 .contents
1779 .iter()
1780 .filter(|s| self.stack_is_market_listable(s))
1781 .filter(|s| {
1782 list_label_matches(&storage_stack_label(s), &self.market_filter)
1783 })
1784 .map(|s| {
1785 s.category
1786 .clone()
1787 .or_else(|| {
1788 self.inventory_item_category(&s.template_id)
1789 .map(str::to_string)
1790 })
1791 .unwrap_or_default()
1792 })
1793 .collect::<Vec<_>>()
1794 })
1795 .unwrap_or_default(),
1796 };
1797 for category in raw {
1798 let (label, ord) = inventory_category_group(&category);
1799 seen.insert(ord, label);
1800 }
1801 }
1802 _ => {
1803 if let Some(panel) = &self.market_panel {
1804 for listing in &panel.listings {
1805 if !list_label_matches(&listing.display_name, &self.market_filter)
1806 && !list_label_matches(&listing.seller_label, &self.market_filter)
1807 {
1808 continue;
1809 }
1810 let (label, ord) = inventory_category_group(&listing.category);
1811 seen.insert(ord, label);
1812 }
1813 }
1814 }
1815 }
1816 seen.into_values().collect()
1817 }
1818
1819 pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
1821 let Some(panel) = &self.market_panel else {
1822 return Vec::new();
1823 };
1824 let filter = self.market_filter.as_str();
1825 let cat_filter = self.market_category_filter;
1826 panel
1827 .listings
1828 .iter()
1829 .enumerate()
1830 .filter(|(_, listing)| {
1831 if !list_label_matches(&listing.display_name, filter)
1832 && !list_label_matches(&listing.seller_label, filter)
1833 && !list_label_matches(&listing.template_id, filter)
1834 {
1835 return false;
1836 }
1837 if let Some(group) = cat_filter {
1838 inventory_category_group(&listing.category).0 == group
1839 } else {
1840 true
1841 }
1842 })
1843 .map(|(i, _)| i)
1844 .collect()
1845 }
1846
1847 pub fn clear_harvest_state(&mut self) {
1848 self.harvest_in_progress = false;
1849 self.harvest_started_at = None;
1850 }
1851
1852 fn harvest_state_stale(&self) -> bool {
1853 match self.harvest_started_at {
1854 Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
1855 None => self.harvest_in_progress,
1856 }
1857 }
1858
1859 pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
1860 self.player.as_ref().and_then(|p| p.vitals)
1861 }
1862
1863 pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
1864 let materials_ok = blueprint.inputs.iter().all(|input| {
1865 self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
1866 });
1867 let tools_ok = blueprint
1868 .required_tools
1869 .iter()
1870 .all(|tool| self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1);
1871 let station_ok = match blueprint.station.as_deref() {
1872 None | Some("hand") => true,
1873 Some(tag) => self.player_at_station_tag(tag),
1874 };
1875 materials_ok && tools_ok && station_ok
1876 }
1877
1878 pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
1879 if !self.can_craft_blueprint(blueprint) {
1880 return 0;
1881 }
1882 let mut limit = u32::MAX;
1883 for input in &blueprint.inputs {
1884 if input.quantity == 0 {
1885 continue;
1886 }
1887 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
1888 limit = limit.min(have / input.quantity);
1889 }
1890 for tool in &blueprint.required_tools {
1891 if tool.consumed {
1892 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
1893 limit = limit.min(have);
1894 }
1895 }
1896 let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
1897 if CRAFT_STAMINA_COST > 0.0 {
1898 limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
1899 }
1900 limit
1901 }
1902
1903 pub fn clamp_craft_batch_quantity(&mut self) {
1904 let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
1905 self.craft_batch_quantity = 1;
1906 return;
1907 };
1908 let max = self.max_craft_batches(bp).max(1);
1909 self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
1910 }
1911
1912 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
1913 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1914 return;
1915 };
1916 let max = self.max_craft_batches(&bp).max(1);
1917 let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
1918 self.craft_batch_quantity = next as u32;
1919 }
1920
1921 pub fn craft_batch_set_max(&mut self) {
1922 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1923 return;
1924 };
1925 let max = self.max_craft_batches(&bp);
1926 self.craft_batch_quantity = if max == 0 { 1 } else { max };
1927 }
1928
1929 pub fn craft_batch_set_min(&mut self) {
1930 self.craft_batch_quantity = 1;
1931 }
1932
1933 pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
1934 let preserve_ui = self.show_shop_menu;
1935 let tab = self.shop_tab;
1936 let index = self.shop_menu_index;
1937 let qty = self.shop_quantity;
1938
1939 self.show_shop_menu = true;
1940 self.bank_panel = None;
1941 self.show_craft_menu = false;
1942 self.show_inventory_menu = false;
1943 self.show_stats = false;
1944 if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
1945 self.npc_verb_target = Some(catalog.npc_id.clone());
1946 }
1947 self.shop_catalog = Some(catalog);
1948
1949 if preserve_ui {
1950 self.shop_tab = tab;
1951 self.shop_menu_index = index;
1952 self.shop_quantity = qty;
1953 } else {
1954 self.shop_tab = ShopTab::Buy;
1955 self.shop_menu_index = 0;
1956 self.shop_quantity = 1;
1957 self.clear_shop_trade_log();
1958 }
1959 self.show_npc_verb_menu = false;
1960 self.clamp_shop_selection();
1961 }
1962
1963 pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
1964 let same_teller = self
1965 .bank_panel
1966 .as_ref()
1967 .is_some_and(|p| p.npc_id == panel.npc_id);
1968 self.bank_panel = Some(panel);
1969 self.storage_panel = None;
1970 self.market_panel = None;
1971 self.shop_catalog = None;
1972 self.show_shop_menu = false;
1973 self.show_craft_menu = false;
1974 self.show_inventory_menu = false;
1975 self.show_stats = false;
1976 self.show_npc_verb_menu = false;
1977 self.show_npc_chat = false;
1978 self.npc_chat = None;
1979 if !same_teller {
1980 self.bank_menu_index = 0;
1981 self.bank_ui_mode = BankUiMode::Menu;
1982 }
1983 if let Some(panel) = &self.bank_panel {
1984 if self.npc_verb_target.is_none() {
1985 self.npc_verb_target = Some(panel.npc_id.clone());
1986 }
1987 }
1988 }
1989
1990 pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
1991 let same_manager = self
1992 .storage_panel
1993 .as_ref()
1994 .is_some_and(|p| p.npc_id == panel.npc_id);
1995 self.storage_panel = Some(panel);
1996 self.bank_panel = None;
1997 self.market_panel = None;
1998 self.bank_ui_mode = BankUiMode::Menu;
1999 self.shop_catalog = None;
2000 self.show_shop_menu = false;
2001 self.show_craft_menu = false;
2002 self.show_inventory_menu = false;
2003 self.show_stats = false;
2004 self.show_npc_verb_menu = false;
2005 self.show_npc_chat = false;
2006 self.npc_chat = None;
2007 if !same_manager {
2008 self.storage_menu_index = 0;
2009 self.storage_ui_mode = StorageUiMode::Menu;
2010 } else {
2011 self.clamp_storage_pick_index();
2012 }
2013 if let Some(panel) = &self.storage_panel {
2014 if self.npc_verb_target.is_none() {
2015 self.npc_verb_target = Some(panel.npc_id.clone());
2016 }
2017 }
2018 }
2019
2020 pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
2021 self.market_panel = Some(panel);
2022 self.bank_panel = None;
2023 self.storage_panel = None;
2024 self.shop_catalog = None;
2025 self.show_shop_menu = false;
2026 self.show_craft_menu = false;
2027 self.show_inventory_menu = false;
2028 self.show_stats = false;
2029 self.show_npc_verb_menu = false;
2030 self.show_npc_chat = false;
2031 self.npc_chat = None;
2032 self.market_menu_index = 0;
2033 self.market_buy_confirm = None;
2034 self.market_ui_mode = MarketUiMode::Browse;
2035 self.market_filter.clear();
2036 self.market_filter_focused = false;
2037 self.market_category_filter = None;
2038 if let Some(panel) = &self.market_panel {
2039 if self.npc_verb_target.is_none() {
2040 self.npc_verb_target = Some(panel.npc_id.clone());
2041 }
2042 }
2043 }
2044
2045 pub fn clear_market_panel(&mut self) {
2046 self.market_panel = None;
2047 self.market_menu_index = 0;
2048 self.market_buy_confirm = None;
2049 self.market_ui_mode = MarketUiMode::Browse;
2050 self.market_filter.clear();
2051 self.market_filter_focused = false;
2052 self.market_category_filter = None;
2053 }
2054
2055 pub fn clear_bank_panel(&mut self) {
2056 self.bank_panel = None;
2057 self.bank_menu_index = 0;
2058 self.bank_ui_mode = BankUiMode::Menu;
2059 }
2060
2061 pub fn clear_storage_panel(&mut self) {
2062 self.storage_panel = None;
2063 self.storage_menu_index = 0;
2064 self.storage_ui_mode = StorageUiMode::Menu;
2065 }
2066
2067 fn clamp_storage_pick_index(&mut self) {
2068 match &self.storage_ui_mode {
2069 StorageUiMode::StorePick { index } => {
2070 let n = self.storage_store_options().len();
2071 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2072 self.storage_ui_mode = StorageUiMode::StorePick { index: next };
2073 }
2074 StorageUiMode::TakePick { index } => {
2075 let n = self.storage_vault_options().len();
2076 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2077 self.storage_ui_mode = StorageUiMode::TakePick { index: next };
2078 }
2079 StorageUiMode::ShipPick {
2080 dest_building_id,
2081 dest_label,
2082 index,
2083 } => {
2084 let n = self.storage_vault_options().len();
2085 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2086 self.storage_ui_mode = StorageUiMode::ShipPick {
2087 dest_building_id: dest_building_id.clone(),
2088 dest_label: dest_label.clone(),
2089 index: next,
2090 };
2091 }
2092 StorageUiMode::Menu
2093 | StorageUiMode::StoreAmount { .. }
2094 | StorageUiMode::TakeAmount { .. }
2095 | StorageUiMode::ShipAmount { .. } => {}
2096 }
2097 }
2098
2099 pub fn shop_list_len(&self) -> usize {
2100 let Some(catalog) = &self.shop_catalog else {
2101 return 0;
2102 };
2103 match self.shop_tab {
2104 ShopTab::Buy => catalog.sells.len(),
2105 ShopTab::Sell => catalog.buys.len(),
2106 }
2107 }
2108
2109 pub fn shop_menu_move(&mut self, delta: i32) {
2110 let n = self.shop_list_len();
2111 if n == 0 {
2112 return;
2113 }
2114 let idx = self.shop_menu_index as i32;
2115 let next = (idx + delta).rem_euclid(n as i32);
2116 self.shop_menu_index = next as usize;
2117 self.clamp_shop_quantity();
2118 }
2119
2120 pub fn shop_quantity_adjust(&mut self, delta: i32) {
2121 let max = self.shop_quantity_max();
2122 if max == 0 {
2123 self.shop_quantity = 0;
2124 return;
2125 }
2126 let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
2127 self.shop_quantity = next as u32;
2128 }
2129
2130 pub(crate) fn clamp_shop_selection(&mut self) {
2131 let n = self.shop_list_len();
2132 if n == 0 {
2133 self.shop_menu_index = 0;
2134 } else {
2135 self.shop_menu_index = self.shop_menu_index.min(n - 1);
2136 }
2137 self.clamp_shop_quantity();
2138 }
2139
2140 fn shop_quantity_max(&self) -> u32 {
2141 let Some(catalog) = &self.shop_catalog else {
2142 return 1;
2143 };
2144 match self.shop_tab {
2145 ShopTab::Buy => {
2146 if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
2147 if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
2148 return 1;
2149 }
2150 }
2151 99
2152 }
2153 ShopTab::Sell => catalog
2154 .buys
2155 .get(self.shop_menu_index)
2156 .map(|l| l.quantity)
2157 .unwrap_or(0),
2158 }
2159 }
2160
2161 pub fn shop_quantity_set_max(&mut self) {
2162 self.shop_quantity = self.shop_quantity_max();
2163 }
2164
2165 pub fn shop_quantity_set_min(&mut self) {
2166 let max = self.shop_quantity_max();
2167 self.shop_quantity = if max == 0 { 0 } else { 1 };
2168 }
2169
2170 fn clamp_shop_quantity(&mut self) {
2171 let max = self.shop_quantity_max();
2172 if max == 0 {
2173 self.shop_quantity = 0;
2174 } else {
2175 self.shop_quantity = self.shop_quantity.max(1).min(max);
2176 }
2177 }
2178
2179 pub fn player_at_station_tag(&self, tag: &str) -> bool {
2180 let Some(id) = self.effective_inside_building() else {
2181 return false;
2182 };
2183 self.buildings
2184 .iter()
2185 .find(|b| b.id == id)
2186 .is_some_and(|b| b.tags.iter().any(|t| t == tag))
2187 }
2188
2189 pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
2191 if self.can_craft_blueprint(blueprint) {
2192 return None;
2193 }
2194 let mut missing = Vec::new();
2195 for input in &blueprint.inputs {
2196 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2197 if have < input.quantity {
2198 let name = self.blueprint_ingredient_label(input);
2199 missing.push(format!("{}×{} (have {have})", input.quantity, name));
2200 }
2201 }
2202 for tool in &blueprint.required_tools {
2203 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2204 if have < 1 {
2205 missing.push(format!("tool: {}", self.blueprint_tool_label(tool)));
2206 }
2207 }
2208 if let Some(station) = blueprint.station.as_deref() {
2209 if station != "hand" && !self.player_at_station_tag(station) {
2210 missing.push(format!("station: {station} (enter building)"));
2211 }
2212 }
2213 if missing.is_empty() {
2214 None
2215 } else {
2216 Some(missing.join(", "))
2217 }
2218 }
2219
2220 pub fn player_entity(&self) -> Option<&EntityState> {
2221 self.player
2222 .as_ref()
2223 .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
2224 }
2225
2226 pub fn apply_client_ui_prefs(&mut self) {
2228 let cfg = crate::client_config::ClientConfig::load();
2229 if let Some(hidden) = cfg.hud_log_hidden {
2230 self.hud_log_hidden = hidden;
2231 }
2232 if let Some(compact) = cfg.workers_menu_compact {
2233 self.workers_menu_compact = compact;
2234 }
2235 }
2236
2237 pub fn player_position(&self) -> (f32, f32) {
2238 let (x, y, _) = self.player_position_with_z();
2239 (x, y)
2240 }
2241
2242 pub fn player_position_with_z(&self) -> (f32, f32, f32) {
2243 if let Some(p) = self.player_entity() {
2244 (
2245 p.transform.position.x,
2246 p.transform.position.y,
2247 p.transform.position.z,
2248 )
2249 } else {
2250 (0.0, 0.0, 0.0)
2251 }
2252 }
2253
2254 pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
2255 let mut rows: Vec<(String, u32, String)> = self
2256 .inventory
2257 .iter()
2258 .filter(|(_, q)| **q > 0)
2259 .map(|(id, qty)| {
2260 let label = self
2261 .inventory_hints
2262 .get(id)
2263 .map(|h| h.display_name.clone())
2264 .unwrap_or_else(|| id.clone());
2265 (id.clone(), *qty, label)
2266 })
2267 .collect();
2268 rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
2269 rows
2270 }
2271
2272 pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
2273 self.inventory_hints
2274 .get(template_id)
2275 .map(|h| h.category.as_str())
2276 .filter(|c| !c.is_empty())
2277 }
2278
2279 pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
2280 stack
2281 .props
2282 .get("grants_item_status_effect")
2283 .map(|s| !s.is_empty())
2284 .unwrap_or(false)
2285 }
2286
2287 pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
2288 stack
2289 .props
2290 .get("grants_item_status_effect")
2291 .map(String::as_str)
2292 .filter(|s| !s.is_empty())
2293 }
2294
2295 pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
2296 stack
2297 .props
2298 .get("grants_item_status_mode")
2299 .map(String::as_str)
2300 .unwrap_or("on_hit")
2301 }
2302
2303 pub fn grant_target_options(
2305 &self,
2306 grant: &flatland_protocol::ItemStack,
2307 ) -> Vec<GrantTargetOption> {
2308 let mode = Self::grant_mode(grant);
2309 let grant_tags: Vec<&str> = grant
2310 .props
2311 .get("grants_item_status_tags")
2312 .map(|s| {
2313 s.split(',')
2314 .map(str::trim)
2315 .filter(|t| !t.is_empty())
2316 .collect()
2317 })
2318 .unwrap_or_default();
2319 let grant_id = grant.item_instance_id;
2320 let mut out = Vec::new();
2321 let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
2322 let Some(iid) = stack.item_instance_id else {
2323 return;
2324 };
2325 if Some(iid) == grant_id {
2326 return;
2327 }
2328 if stack.props.get("enchantable").map(String::as_str) == Some("0") {
2329 return;
2330 }
2331 if !grant_target_matches_mode(stack, mode) {
2332 return;
2333 }
2334 if !grant_tags_match(stack, &grant_tags) {
2335 return;
2336 }
2337 let name = stack
2338 .display_name
2339 .clone()
2340 .unwrap_or_else(|| stack.template_id.clone());
2341 let bindings = if stack.status_bindings.is_empty() {
2342 String::new()
2343 } else {
2344 format!(
2345 " · {}",
2346 stack
2347 .status_bindings
2348 .iter()
2349 .map(|b| b.effect_id.as_str())
2350 .collect::<Vec<_>>()
2351 .join(", ")
2352 )
2353 };
2354 out.push(GrantTargetOption {
2355 label: format!("{where_label}: {name}{bindings}"),
2356 target_instance_id: iid,
2357 });
2358 };
2359 fn walk(
2360 stacks: &[flatland_protocol::ItemStack],
2361 where_label: &str,
2362 push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
2363 ) {
2364 for s in stacks {
2365 push(s, where_label);
2366 if !s.contents.is_empty() {
2367 let nested = format!(
2368 "{where_label}/{}",
2369 s.display_name
2370 .as_deref()
2371 .unwrap_or(s.template_id.as_str())
2372 );
2373 walk(&s.contents, &nested, push);
2374 }
2375 }
2376 }
2377 walk(&self.inventory_stacks, "Bag", &mut push);
2378 for (slot, stack) in &self.worn {
2379 push(stack, body_slot_label(*slot));
2380 let nest = format!(
2381 "{}/{}",
2382 body_slot_label(*slot),
2383 stack
2384 .display_name
2385 .as_deref()
2386 .unwrap_or(stack.template_id.as_str())
2387 );
2388 walk(&stack.contents, &nest, &mut push);
2389 }
2390 out
2391 }
2392
2393 pub fn item_base_mass(&self, template_id: &str) -> f32 {
2394 self.inventory_hints
2395 .get(template_id)
2396 .and_then(|h| h.base_mass)
2397 .unwrap_or(0.5)
2398 }
2399
2400 pub fn item_base_volume(&self, template_id: &str) -> f32 {
2401 self.inventory_hints
2402 .get(template_id)
2403 .and_then(|h| h.base_volume)
2404 .unwrap_or(1.0)
2405 }
2406
2407 pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
2408 let unit = stack
2409 .base_mass
2410 .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
2411 unit * stack.quantity as f32
2412 }
2413
2414 fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
2415 let unit = stack.base_volume.unwrap_or(1.0);
2416 unit * stack.quantity as f32
2417 + stack
2418 .contents
2419 .iter()
2420 .map(Self::stack_tree_volume)
2421 .sum::<f32>()
2422 }
2423
2424 fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
2425 contents.iter().map(Self::stack_tree_volume).sum()
2426 }
2427
2428 fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
2429 self.inventory_hints
2430 .get(template_id)
2431 .and_then(|h| h.capacity_volume)
2432 .filter(|c| *c > 0.0)
2433 }
2434
2435 fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
2436 stack
2437 .capacity_volume
2438 .filter(|c| *c > 0.0)
2439 .or_else(|| self.template_capacity_volume(&stack.template_id))
2440 }
2441
2442 pub fn container_volume_label(&self, row: &InventoryRow) -> String {
2444 let Some((used, cap)) = self.container_volume_stats(row) else {
2445 return String::new();
2446 };
2447 let free = (cap - used).max(0.0);
2448 format!(" vol {used:.0}/{cap:.0} ({free:.0} free)")
2449 }
2450
2451 fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
2452 if row.is_chest_shell {
2453 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
2454 return None;
2455 };
2456 let chest = self
2457 .placed_containers
2458 .iter()
2459 .find(|c| c.id == *container_id)?;
2460 let cap = self
2461 .stack_capacity_volume(&row.stack)
2462 .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
2463 let used = if chest.accessible {
2464 Self::contents_used_volume(&chest.contents)
2465 } else {
2466 0.0
2467 };
2468 return Some((used, cap));
2469 }
2470
2471 let cap = self.stack_capacity_volume(&row.stack)?;
2472 let used = Self::contents_used_volume(&row.stack.contents);
2473 Some((used, cap))
2474 }
2475
2476 pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
2477 if row.is_chest_shell {
2478 return true;
2479 }
2480 if row.is_equip_shell {
2481 return self.inventory_item_category(&row.stack.template_id) == Some("container");
2482 }
2483 self.inventory_item_category(&row.stack.template_id) == Some("container")
2484 || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
2485 }
2486
2487 fn container_stack_for(
2488 &self,
2489 location: &flatland_protocol::InventoryLocation,
2490 parent_instance_id: Option<uuid::Uuid>,
2491 ) -> Option<flatland_protocol::ItemStack> {
2492 match location {
2493 flatland_protocol::InventoryLocation::Root => {
2494 let pid = parent_instance_id?;
2495 self.find_stack_by_instance(&self.inventory_stacks, pid)
2496 }
2497 flatland_protocol::InventoryLocation::Worn { slot } => {
2498 let worn = self.worn.get(slot)?;
2499 if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
2500 Some(worn.clone())
2501 } else {
2502 self.find_stack_by_instance(&worn.contents, parent_instance_id?)
2503 }
2504 }
2505 flatland_protocol::InventoryLocation::Placed { container_id } => {
2506 let chest = self
2507 .placed_containers
2508 .iter()
2509 .find(|c| c.id == *container_id)?;
2510 if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
2511 Some(flatland_protocol::ItemStack {
2512 template_id: chest.template_id.clone(),
2513 quantity: 1,
2514 item_instance_id: chest.item_instance_id,
2515 props: Default::default(),
2516 status_bindings: Vec::new(),
2517 contents: chest.contents.clone(),
2518 display_name: Some(chest.display_name.clone()),
2519 category: Some("container".into()),
2520 capacity_volume: self
2521 .inventory_hints
2522 .get(&chest.template_id)
2523 .and_then(|h| h.capacity_volume),
2524 worker_lodging_capacity: chest.worker_lodging_capacity,
2525 ..Default::default()
2526 })
2527 } else {
2528 self.find_stack_by_instance(&chest.contents, parent_instance_id?)
2529 }
2530 }
2531 flatland_protocol::InventoryLocation::Keychain => None,
2532 flatland_protocol::InventoryLocation::WhisperPouch => None,
2533 }
2534 }
2535
2536 fn find_stack_by_instance(
2537 &self,
2538 stacks: &[flatland_protocol::ItemStack],
2539 instance_id: uuid::Uuid,
2540 ) -> Option<flatland_protocol::ItemStack> {
2541 for stack in stacks {
2542 if stack.item_instance_id == Some(instance_id) {
2543 return Some(stack.clone());
2544 }
2545 if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
2546 return Some(found);
2547 }
2548 }
2549 None
2550 }
2551
2552 pub fn max_movable_to(
2554 &self,
2555 template_id: &str,
2556 stack_qty: u32,
2557 from: &flatland_protocol::InventoryLocation,
2558 to: &flatland_protocol::InventoryLocation,
2559 parent_instance_id: Option<uuid::Uuid>,
2560 ) -> u32 {
2561 let unit_vol = self.item_base_volume(template_id);
2562 let unit_mass = self.item_base_mass(template_id);
2563 let mut limit = stack_qty;
2564
2565 if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
2566 let cap = parent
2567 .capacity_volume
2568 .or_else(|| {
2569 self.inventory_hints
2570 .get(&parent.template_id)
2571 .and_then(|h| h.capacity_volume)
2572 })
2573 .unwrap_or(0.0);
2574 if cap > 0.0 && unit_vol > 0.0 {
2575 let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
2576 limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
2577 }
2578 }
2579
2580 let to_person = matches!(
2581 to,
2582 flatland_protocol::InventoryLocation::Root
2583 | flatland_protocol::InventoryLocation::Worn { .. }
2584 );
2585 let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
2586 if to_person && from_placed && unit_mass > 0.0 {
2587 let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
2588 if self.encumbrance == flatland_protocol::EncumbranceState::Over {
2589 limit = 0;
2590 } else {
2591 limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
2592 }
2593 }
2594
2595 limit.max(0).min(stack_qty)
2596 }
2597
2598 pub fn move_picker_max_at_selection(&self) -> u32 {
2599 let Some(picker) = &self.move_picker else {
2600 return 1;
2601 };
2602 let Some(opt) = picker.options.get(self.move_picker_index) else {
2603 return picker.stack_quantity;
2604 };
2605 match &opt.kind {
2606 MoveOptionKind::Cancel
2607 | MoveOptionKind::Drop
2608 | MoveOptionKind::Use
2609 | MoveOptionKind::GrantApply
2610 | MoveOptionKind::SellPlotToCrown { .. }
2611 | MoveOptionKind::PickupPlaced { .. }
2612 | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
2613 MoveOptionKind::Move {
2614 location,
2615 parent_instance_id,
2616 } => self.max_movable_to(
2617 &picker.template_id,
2618 picker.stack_quantity,
2619 &picker.from,
2620 location,
2621 *parent_instance_id,
2622 ),
2623 }
2624 }
2625
2626 pub fn clamp_move_picker_quantity(&mut self) {
2627 let max = self.move_picker_max_at_selection();
2628 if let Some(picker) = &mut self.move_picker {
2629 if max == 0 {
2630 picker.quantity = 1;
2631 } else {
2632 picker.quantity = picker.quantity.clamp(1, max);
2633 }
2634 }
2635 }
2636
2637 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
2638 let max = self.move_picker_max_at_selection().max(1);
2639 if let Some(picker) = &mut self.move_picker {
2640 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2641 picker.quantity = next as u32;
2642 }
2643 }
2644
2645 pub fn move_picker_set_quantity_max(&mut self) {
2646 let max = self.move_picker_max_at_selection();
2647 if let Some(picker) = &mut self.move_picker {
2648 picker.quantity = if max == 0 {
2649 1
2650 } else {
2651 max.min(picker.stack_quantity)
2652 };
2653 }
2654 }
2655
2656 pub fn move_picker_set_quantity_min(&mut self) {
2657 if let Some(picker) = &mut self.move_picker {
2658 picker.quantity = 1;
2659 }
2660 }
2661
2662 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
2663 if let Some(picker) = &mut self.destroy_picker {
2664 let max = picker.stack_quantity.max(1);
2665 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2666 picker.quantity = next as u32;
2667 }
2668 }
2669
2670 pub fn destroy_picker_set_quantity_max(&mut self) {
2671 if let Some(picker) = &mut self.destroy_picker {
2672 picker.quantity = picker.stack_quantity.max(1);
2673 }
2674 }
2675
2676 pub fn destroy_picker_set_quantity_min(&mut self) {
2677 if let Some(picker) = &mut self.destroy_picker {
2678 picker.quantity = 1;
2679 }
2680 }
2681
2682 pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2683 let have = self.inventory.get(template_id).copied().unwrap_or(0);
2684 (have, have >= need)
2685 }
2686
2687 pub fn plot_build_stock_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2689 let have = self
2690 .plot_build_offer
2691 .as_ref()
2692 .and_then(|o| {
2693 o.available
2694 .iter()
2695 .find(|s| s.template_id == template_id)
2696 .map(|s| s.quantity)
2697 })
2698 .unwrap_or_else(|| self.inventory.get(template_id).copied().unwrap_or(0));
2699 (have, have >= need)
2700 }
2701
2702 pub fn plot_build_wall_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
2703 self.building_materials
2704 .iter()
2705 .filter(|m| m.can_wall)
2706 .collect()
2707 }
2708
2709 pub fn plot_build_roof_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
2710 self.building_materials
2711 .iter()
2712 .filter(|m| m.can_roof)
2713 .collect()
2714 }
2715
2716 pub fn plot_build_selected_wall(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
2717 self.plot_build_wall_options()
2718 .get(self.plot_build_wall_index)
2719 .copied()
2720 }
2721
2722 pub fn plot_build_selected_roof(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
2723 self.plot_build_roof_options()
2724 .get(self.plot_build_roof_index)
2725 .copied()
2726 }
2727
2728 pub fn plot_build_bom_lines(&self) -> Vec<(String, String, u32)> {
2730 let Some(wall) = self.plot_build_selected_wall() else {
2731 return Vec::new();
2732 };
2733 let Some(roof) = self.plot_build_selected_roof() else {
2734 return Vec::new();
2735 };
2736 let area = self
2737 .plot_build_offer
2738 .as_ref()
2739 .filter(|o| o.pad_ok)
2740 .map(|o| o.pad_width_m * o.pad_depth_m)
2741 .unwrap_or(0.0);
2742 if area <= 0.0 {
2743 return Vec::new();
2744 }
2745 let mut map: std::collections::HashMap<String, (String, u32)> =
2746 std::collections::HashMap::new();
2747 for line in &wall.wall_bom {
2748 let qty = (area * line.per_m2).ceil().max(0.0) as u32;
2749 if qty == 0 {
2750 continue;
2751 }
2752 let name = if line.display_name.is_empty() {
2753 line.template_id.clone()
2754 } else {
2755 line.display_name.clone()
2756 };
2757 let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
2758 entry.1 = entry.1.saturating_add(qty);
2759 }
2760 for line in &roof.roof_bom {
2761 let qty = (area * line.per_m2).ceil().max(0.0) as u32;
2762 if qty == 0 {
2763 continue;
2764 }
2765 let name = if line.display_name.is_empty() {
2766 line.template_id.clone()
2767 } else {
2768 line.display_name.clone()
2769 };
2770 let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
2771 entry.1 = entry.1.saturating_add(qty);
2772 }
2773 let mut out: Vec<_> = map
2774 .into_iter()
2775 .map(|(id, (name, qty))| (id, name, qty))
2776 .collect();
2777 out.sort_by(|a, b| a.0.cmp(&b.0));
2778 out
2779 }
2780
2781 pub fn plot_build_duration_secs(&self) -> Option<f32> {
2782 let wall = self.plot_build_selected_wall()?;
2783 let roof = self.plot_build_selected_roof()?;
2784 let offer = self.plot_build_offer.as_ref()?;
2785 if !offer.pad_ok {
2786 return None;
2787 }
2788 let area = offer.pad_width_m * offer.pad_depth_m;
2789 let mult = wall.tick_mult.max(roof.tick_mult).max(0.1);
2790 let ticks = (offer.base_ticks as f32 + area * offer.tick_per_m2 as f32 * mult).ceil();
2791 Some(ticks.max(2.0) / 30.0)
2792 }
2793
2794 pub fn plot_build_can_afford(&self) -> bool {
2795 if self
2796 .plot_build_offer
2797 .as_ref()
2798 .is_none_or(|o| !o.pad_ok)
2799 {
2800 return false;
2801 }
2802 self.plot_build_bom_lines()
2803 .iter()
2804 .all(|(id, _, need)| self.plot_build_stock_status(id, *need).1)
2805 }
2806
2807 pub fn currency_display(&self) -> String {
2808 crate::currency::currency_line(&self.inventory)
2809 }
2810
2811 pub fn in_shallow_water(&self) -> bool {
2813 let (px, py) = self.player_position();
2814 self.terrain_at(px, py)
2815 .is_some_and(|k| k == TerrainKindView::ShallowWater)
2816 }
2817
2818 pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
2819 self.terrain_zone_at(x, y).map(|z| z.kind)
2820 }
2821
2822 pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
2824 use std::cell::RefCell;
2825
2826 const CHUNK: i32 = 8;
2827 thread_local! {
2828 static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
2829 RefCell::new(None);
2830 }
2831
2832 let zones = &self.terrain_zones;
2833 if zones.is_empty() {
2834 return None;
2835 }
2836 if zones.len() <= 48 {
2837 return zones
2838 .iter()
2839 .enumerate()
2840 .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
2841 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
2842 .map(|(_, z)| z);
2843 }
2844
2845 let ptr = zones.as_ptr();
2846 let len = zones.len();
2847 INDEX.with(|cell| {
2848 let mut slot = cell.borrow_mut();
2849 let stale = match slot.as_ref() {
2850 Some((p, l, _)) => *p != ptr || *l != len,
2851 None => true,
2852 };
2853 if stale {
2854 let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
2855 std::collections::HashMap::new();
2856 for (zi, z) in zones.iter().enumerate() {
2857 let x0 = z.x0.min(z.x1).floor() as i32;
2858 let y0 = z.y0.min(z.y1).floor() as i32;
2859 let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
2860 let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
2861 let cx0 = x0.div_euclid(CHUNK);
2862 let cy0 = y0.div_euclid(CHUNK);
2863 let cx1 = x1.div_euclid(CHUNK);
2864 let cy1 = y1.div_euclid(CHUNK);
2865 for cy in cy0..=cy1 {
2866 for cx in cx0..=cx1 {
2867 chunks.entry((cx, cy)).or_default().push(zi);
2868 }
2869 }
2870 }
2871 *slot = Some((ptr, len, chunks));
2872 }
2873 let chunks = &slot.as_ref().expect("index").2;
2874 let cx = (x.floor() as i32).div_euclid(CHUNK);
2875 let cy = (y.floor() as i32).div_euclid(CHUNK);
2876 let mut best: Option<(usize, &TerrainZoneView)> = None;
2877 if let Some(list) = chunks.get(&(cx, cy)) {
2878 for &zi in list {
2879 let Some(z) = zones.get(zi) else { continue };
2880 if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
2881 continue;
2882 }
2883 best = match best {
2884 None => Some((zi, z)),
2885 Some((bi, bz)) => {
2886 if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
2887 Some((zi, z))
2888 } else {
2889 Some((bi, bz))
2890 }
2891 }
2892 };
2893 }
2894 }
2895 best.map(|(_, z)| z)
2896 })
2897 }
2898
2899 pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
2901 self.terrain_zone_at(x, y)
2902 .map(|z| z.elevation)
2903 .unwrap_or(0.0)
2904 }
2905
2906 pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
2908 const TOL: f32 = 0.35;
2909 let mut levels = vec![self.elevation_at(x, y)];
2910 for p in &self.z_platforms {
2911 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2912 levels.push(p.z);
2913 }
2914 }
2915 levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2916 levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
2917 levels
2918 }
2919
2920 pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
2921 const TOL: f32 = 0.35;
2922 self.walkable_levels_at(x, y)
2923 .iter()
2924 .any(|&l| (l - z).abs() <= TOL)
2925 }
2926
2927 pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
2928 let mut top = self.elevation_at(x, y);
2929 for p in &self.z_platforms {
2930 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2931 top = top.max(p.z);
2932 }
2933 }
2934 top
2935 }
2936
2937 pub fn effective_inside_building(&self) -> Option<String> {
2939 self.player_entity().and_then(|p| p.inside_building.clone())
2940 }
2941
2942 pub fn placed_container_in_current_space(
2946 &self,
2947 c: &flatland_protocol::PlacedContainerView,
2948 ) -> bool {
2949 match (
2950 self.effective_inside_building().as_deref(),
2951 c.building_id.as_deref(),
2952 ) {
2953 (None, None) => true,
2954 (Some(a), Some(b)) => a == b,
2955 _ => false,
2956 }
2957 }
2958
2959 pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
2960 self.inventory_stacks = stacks.to_vec();
2961 self.inventory.clear();
2962 self.inventory_hints.clear();
2963 fn walk(
2964 stacks: &[flatland_protocol::ItemStack],
2965 inventory: &mut std::collections::HashMap<String, u32>,
2966 hints: &mut std::collections::HashMap<String, InventoryHint>,
2967 ) {
2968 for stack in stacks {
2969 *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
2970 if stack.display_name.is_some()
2971 || stack.category.is_some()
2972 || stack.base_mass.is_some()
2973 || stack.base_volume.is_some()
2974 {
2975 hints.insert(
2976 stack.template_id.clone(),
2977 InventoryHint {
2978 display_name: stack
2979 .display_name
2980 .clone()
2981 .unwrap_or_else(|| stack.template_id.clone()),
2982 category: stack.category.clone().unwrap_or_default(),
2983 base_mass: stack.base_mass,
2984 base_volume: stack.base_volume,
2985 capacity_volume: stack.capacity_volume,
2986 stackable: stack.stackable.unwrap_or(true),
2987 listable: stack.listable.unwrap_or_else(|| {
2988 category_default_listable(
2989 stack.category.as_deref().unwrap_or(""),
2990 )
2991 }),
2992 },
2993 );
2994 }
2995 walk(&stack.contents, inventory, hints);
2996 }
2997 }
2998 walk(stacks, &mut self.inventory, &mut self.inventory_hints);
2999 for item in self.worn.values() {
3001 walk(
3002 std::slice::from_ref(item),
3003 &mut self.inventory,
3004 &mut self.inventory_hints,
3005 );
3006 }
3007 }
3008
3009 pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
3012 let subtract_items =
3013 notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
3014 for stack in ¬ice.inventory_delta {
3015 if stack.quantity == 0 {
3016 continue;
3017 }
3018 if subtract_items {
3019 crate::currency::drain_template_stacks(
3020 &mut self.inventory_stacks,
3021 &stack.template_id,
3022 stack.quantity,
3023 );
3024 continue;
3025 }
3026 let stackable = self
3027 .inventory_hints
3028 .get(&stack.template_id)
3029 .map(|h| h.stackable)
3030 .or(stack.stackable)
3031 .unwrap_or(true);
3032 if stackable {
3033 if let Some(existing) = self
3034 .inventory_stacks
3035 .iter_mut()
3036 .find(|s| s.template_id == stack.template_id)
3037 {
3038 existing.quantity = existing.quantity.saturating_add(stack.quantity);
3039 if stack.display_name.is_some() {
3040 existing.display_name = stack.display_name.clone();
3041 }
3042 if stack.category.is_some() {
3043 existing.category = stack.category.clone();
3044 }
3045 continue;
3046 }
3047 }
3048 self.inventory_stacks.push(stack.clone());
3049 }
3050 if notice.coins_delta != 0 {
3051 crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
3052 }
3053 if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
3054 let stacks = self.inventory_stacks.clone();
3055 self.sync_inventory_from_stacks(&stacks);
3056 }
3057 self.record_shop_trade_notice(notice);
3058 }
3059
3060 pub fn worn_rows(&self) -> Vec<InventoryRow> {
3065 let mut rows = Vec::new();
3066 for (slot, item) in &self.worn {
3067 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3068 rows.push(InventoryRow {
3069 depth: 0,
3070 stack: item.clone(),
3071 from: from.clone(),
3072 from_parent_instance_id: None,
3073 is_equip_shell: true,
3074 is_chest_shell: false,
3075 section: InventorySection::Worn,
3076 });
3077 for child in &item.contents {
3078 push_inventory_rows(
3079 &mut rows,
3080 1,
3081 child,
3082 &from,
3083 item.item_instance_id,
3084 InventorySection::Worn,
3085 );
3086 }
3087 }
3088 rows
3089 }
3090
3091 pub fn trade_presentable_stacks(&self) -> Vec<&flatland_protocol::ItemStack> {
3093 let equipped = self.hand_equipped_instance_ids();
3094 self.inventory_stacks
3095 .iter()
3096 .filter(|s| {
3097 s.item_instance_id
3098 .is_some_and(|id| !equipped.contains(&id))
3099 })
3100 .collect()
3101 }
3102
3103 pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
3105 let equipped = self.hand_equipped_instance_ids();
3106 self.inventory_stacks
3107 .iter()
3108 .filter_map(|stack| {
3109 let item_instance_id = stack.item_instance_id?;
3110 if equipped.contains(&item_instance_id) {
3111 return None;
3112 }
3113 let label = stack
3114 .display_name
3115 .clone()
3116 .unwrap_or_else(|| stack.template_id.clone());
3117 let label = if stack.quantity > 1 {
3118 format!("{label} ×{}", stack.quantity)
3119 } else {
3120 label
3121 };
3122 Some(WorkerGiveOption {
3123 item_instance_id,
3124 label,
3125 quantity: stack.quantity,
3126 template_id: stack.template_id.clone(),
3127 })
3128 })
3129 .collect()
3130 }
3131
3132 pub fn teachable_blueprint_options(
3134 &self,
3135 worker: &flatland_protocol::HiredWorkerView,
3136 ) -> Vec<WorkerTeachOption> {
3137 let copper = crate::currency::copper_from_counts(&self.inventory);
3138 let mut options: Vec<WorkerTeachOption> = self
3139 .blueprints
3140 .iter()
3141 .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
3142 .map(|bp| {
3143 let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
3144 let cost = bp.worker_train_copper;
3145 WorkerTeachOption {
3146 blueprint_id: bp.id.clone(),
3147 label: if bp.label.is_empty() {
3148 bp.id.clone()
3149 } else {
3150 bp.label.clone()
3151 },
3152 cost_copper: cost,
3153 min_level,
3154 worker_level: worker.level,
3155 can_afford: copper >= cost,
3156 level_ok: worker.level >= min_level,
3157 }
3158 })
3159 .collect();
3160 options.sort_by(|a, b| a.label.cmp(&b.label));
3161 options
3162 }
3163
3164 pub fn person_rows(&self) -> Vec<InventoryRow> {
3167 self.person_rows_filtered("")
3168 }
3169
3170 pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
3171 let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
3172 roots.sort_by(|a, b| {
3173 let ca = a
3174 .category
3175 .as_deref()
3176 .or_else(|| self.inventory_item_category(&a.template_id))
3177 .unwrap_or("");
3178 let cb = b
3179 .category
3180 .as_deref()
3181 .or_else(|| self.inventory_item_category(&b.template_id))
3182 .unwrap_or("");
3183 let ga = inventory_category_group(ca).1;
3184 let gb = inventory_category_group(cb).1;
3185 ga.cmp(&gb).then_with(|| {
3186 let na = a
3187 .display_name
3188 .as_deref()
3189 .unwrap_or(a.template_id.as_str());
3190 let nb = b
3191 .display_name
3192 .as_deref()
3193 .unwrap_or(b.template_id.as_str());
3194 na.cmp(nb)
3195 })
3196 });
3197 let mut rows = Vec::new();
3198 for stack in roots {
3199 push_inventory_rows_filtered(
3200 &mut rows,
3201 0,
3202 stack,
3203 &flatland_protocol::InventoryLocation::Root,
3204 None,
3205 InventorySection::Person,
3206 filter,
3207 );
3208 }
3209 rows
3210 }
3211
3212 pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
3213 if filter.is_empty() {
3214 return self.worn_rows();
3215 }
3216 let mut rows = Vec::new();
3217 for (slot, item) in &self.worn {
3218 if !stack_matches_filter(item, filter) {
3219 continue;
3220 }
3221 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3222 let self_hit = {
3223 let f = filter.to_ascii_lowercase();
3224 let name = item
3225 .display_name
3226 .as_deref()
3227 .unwrap_or("")
3228 .to_ascii_lowercase();
3229 let tid = item.template_id.to_ascii_lowercase();
3230 name.contains(&f) || tid.contains(&f)
3231 };
3232 rows.push(InventoryRow {
3233 depth: 0,
3234 stack: item.clone(),
3235 from: from.clone(),
3236 from_parent_instance_id: None,
3237 is_equip_shell: true,
3238 is_chest_shell: false,
3239 section: InventorySection::Worn,
3240 });
3241 for child in &item.contents {
3242 if self_hit || stack_matches_filter(child, filter) {
3243 push_inventory_rows_filtered(
3244 &mut rows,
3245 1,
3246 child,
3247 &from,
3248 item.item_instance_id,
3249 InventorySection::Worn,
3250 if self_hit { "" } else { filter },
3251 );
3252 }
3253 }
3254 }
3255 rows
3256 }
3257
3258 pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
3260 let mut rows = self.worn_rows();
3261 rows.extend(self.person_rows());
3262 rows.into_iter().map(|r| (r.depth, r.stack)).collect()
3263 }
3264
3265 pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
3269 let (px, py) = self.player_position();
3270 let mut list: Vec<NearbyContainer> = self
3271 .placed_containers
3272 .iter()
3273 .filter(|c| self.placed_container_in_current_space(c))
3274 .filter_map(|c| {
3275 let distance_m = (c.x - px).hypot(c.y - py);
3276 if distance_m > CONTAINER_RANGE_M {
3277 return None;
3278 }
3279 let mut rows = Vec::new();
3280 let from = flatland_protocol::InventoryLocation::Placed {
3281 container_id: c.id.clone(),
3282 };
3283 rows.push(InventoryRow {
3284 depth: 0,
3285 stack: flatland_protocol::ItemStack {
3286 template_id: c.template_id.clone(),
3287 quantity: 1,
3288 item_instance_id: c.item_instance_id,
3289 props: Default::default(),
3290 status_bindings: Vec::new(),
3291 contents: Vec::new(),
3292 display_name: Some(c.display_name.clone()),
3293 category: Some("container".into()),
3294 capacity_volume: c.capacity_volume,
3295 worker_lodging_capacity: c.worker_lodging_capacity,
3296 ..Default::default()
3297 },
3298 from: from.clone(),
3299 from_parent_instance_id: None,
3300 is_equip_shell: false,
3301 is_chest_shell: true,
3302 section: InventorySection::Nearby,
3303 });
3304 if c.accessible {
3305 for child in &c.contents {
3306 push_inventory_rows(
3307 &mut rows,
3308 1,
3309 child,
3310 &from,
3311 c.item_instance_id,
3312 InventorySection::Nearby,
3313 );
3314 }
3315 }
3316 Some(NearbyContainer {
3317 view: c.clone(),
3318 distance_m,
3319 rows,
3320 })
3321 })
3322 .collect();
3323 list.sort_by(|a, b| {
3324 a.distance_m
3325 .partial_cmp(&b.distance_m)
3326 .unwrap_or(std::cmp::Ordering::Equal)
3327 });
3328 list
3329 }
3330
3331 pub fn nearest_placed_container(
3333 &self,
3334 max_dist: f32,
3335 ) -> Option<flatland_protocol::PlacedContainerView> {
3336 let (px, py) = self.player_position();
3337 self.placed_containers
3338 .iter()
3339 .filter(|c| self.placed_container_in_current_space(c))
3340 .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
3341 .min_by(|a, b| {
3342 let da = (a.x - px).hypot(a.y - py);
3343 let db = (b.x - px).hypot(b.y - py);
3344 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3345 })
3346 .cloned()
3347 }
3348
3349 pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
3352 let filter = self.inventory_filter.as_str();
3353 match self.inventory_tab {
3354 InventoryTab::OnPerson => {
3355 let mut rows = self.worn_rows_filtered(filter);
3356 rows.extend(self.person_rows_filtered(filter));
3357 rows
3358 }
3359 InventoryTab::Nearby => {
3360 let mut rows = Vec::new();
3361 for nc in self.nearby_containers() {
3362 if filter.is_empty() {
3363 rows.extend(nc.rows);
3364 continue;
3365 }
3366 let shell = nc.rows.first().cloned();
3367 let contents: Vec<_> = nc
3368 .rows
3369 .iter()
3370 .skip(1)
3371 .filter(|r| stack_matches_filter(&r.stack, filter))
3372 .cloned()
3373 .collect();
3374 let shell_hit = shell
3375 .as_ref()
3376 .map(|s| stack_matches_filter(&s.stack, filter))
3377 .unwrap_or(false);
3378 if shell_hit || !contents.is_empty() {
3379 if let Some(s) = shell {
3380 rows.push(s);
3381 }
3382 if shell_hit {
3383 rows.extend(nc.rows.into_iter().skip(1));
3384 } else {
3385 rows.extend(contents);
3386 }
3387 }
3388 }
3389 rows
3390 }
3391 }
3392 }
3393
3394 pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
3395 self.inventory_selectable_rows()
3396 .into_iter()
3397 .nth(self.inventory_menu_index)
3398 }
3399
3400 fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
3401 let cat = self
3402 .inventory_item_category(&row.stack.template_id)
3403 .unwrap_or("");
3404 if cat == "key" {
3405 self.key_inventory_label(&row.stack)
3406 } else {
3407 row.stack
3408 .display_name
3409 .clone()
3410 .unwrap_or_else(|| row.stack.template_id.clone())
3411 }
3412 }
3413
3414 fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
3416 let bindings = format_status_bindings_suffix(
3417 &row.stack.status_bindings,
3418 self.tick,
3419 DEFAULT_TICK_HZ,
3420 );
3421 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3422 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3423 let mode = Self::grant_mode(&row.stack);
3424 format!(" [grant {effect} · {mode} — e apply]")
3425 } else {
3426 String::new()
3427 };
3428 let qty = if row.stack.quantity > 1 {
3429 format!(" ×{}", row.stack.quantity)
3430 } else {
3431 String::new()
3432 };
3433 let worn_slot = if row.is_equip_shell {
3434 match row.from {
3435 flatland_protocol::InventoryLocation::Worn { slot } => {
3436 format!(" ({})", body_slot_label(slot))
3437 }
3438 _ => String::new(),
3439 }
3440 } else {
3441 String::new()
3442 };
3443 format!("{grant_hint}{bindings}{qty}{worn_slot}")
3444 }
3445
3446 fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
3447 (
3448 row.stack.template_id.clone(),
3449 self.inventory_row_base_label(row),
3450 self.inventory_row_visible_mod_signature(row),
3451 )
3452 }
3453
3454 fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
3456 let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
3457 for row in self.inventory_selectable_rows() {
3458 if row.stack.item_instance_id.is_none() {
3459 continue;
3460 }
3461 let key = self.inventory_row_instance_identity_key(&row);
3462 *counts.entry(key).or_default() += 1;
3463 }
3464 counts
3465 .into_iter()
3466 .filter(|(_, n)| *n > 1)
3467 .map(|(k, _)| k)
3468 .collect()
3469 }
3470
3471 fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
3472 let hex: String = id
3473 .as_simple()
3474 .to_string()
3475 .chars()
3476 .filter(|c| c.is_ascii_hexdigit())
3477 .collect();
3478 let short = if hex.len() >= 4 {
3479 &hex[hex.len() - 4..]
3480 } else {
3481 hex.as_str()
3482 };
3483 format!("Instance {id} (#{short})")
3484 }
3485
3486 pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
3488 let cat = self
3489 .inventory_item_category(&row.stack.template_id)
3490 .unwrap_or("");
3491 let label = self.inventory_row_base_label(row);
3492 let hint: String = if row.is_equip_shell {
3493 " [worn — Enter to unequip]".into()
3494 } else if row.is_chest_shell {
3495 let (locked, lodging_note) = match &row.from {
3496 flatland_protocol::InventoryLocation::Placed { container_id } => {
3497 let locked = self
3498 .placed_containers
3499 .iter()
3500 .find(|c| c.id == *container_id)
3501 .map(|c| c.locked)
3502 .unwrap_or(false);
3503 let lodging_note = self
3504 .lodging_occupancy_label(container_id)
3505 .map(|who| format!(" [lodging: {who}]"))
3506 .unwrap_or_default();
3507 (locked, lodging_note)
3508 }
3509 _ => (false, String::new()),
3510 };
3511 if locked {
3512 format!(" [locked — Enter pick up · l unlock]{lodging_note}")
3513 } else {
3514 format!(" [Enter pick up · l lock]{lodging_note}")
3515 }
3516 } else if cat == "key" {
3517 self.key_inventory_hint(&row.stack)
3518 } else {
3519 match cat {
3520 "weapon" => " [weapon]".into(),
3521 "container" => " [bag/chest/belt]".into(),
3522 "lodging" => " [worker lodging]".into(),
3523 "armor" => " [armor]".into(),
3524 _ => String::new(),
3525 }
3526 };
3527 let qty = if row.stack.quantity > 1 {
3528 format!(" ×{}", row.stack.quantity)
3529 } else {
3530 String::new()
3531 };
3532 let bindings = format_status_bindings_suffix(
3533 &row.stack.status_bindings,
3534 self.tick,
3535 DEFAULT_TICK_HZ,
3536 );
3537 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3538 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3539 let mode = Self::grant_mode(&row.stack);
3540 format!(" [grant {effect} · {mode} — e apply]")
3541 } else {
3542 String::new()
3543 };
3544 let mass = self.stack_mass(&row.stack);
3545 let mass_kg = (mass >= 0.05).then_some(mass);
3546 let mass_str = mass_kg
3547 .map(|m| format!(" {m:.1} kg"))
3548 .unwrap_or_default();
3549 let volume = self.container_volume_stats(row);
3550 let vol_str = self.container_volume_label(row);
3551
3552 let mut title = label.clone();
3553 title.push_str(&qty);
3554 if row.is_equip_shell {
3555 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3556 title.push_str(&format!(" ({})", body_slot_label(slot)));
3557 }
3558 }
3559
3560 InventoryRowView {
3561 depth: row.depth,
3562 text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
3563 title: format!("{title}{grant_hint}{bindings}"),
3564 mass_kg,
3565 volume,
3566 instance_tooltip: None,
3567 }
3568 }
3569
3570 fn push_browser_item(
3571 &self,
3572 lines: &mut Vec<InventoryBrowserLine>,
3573 row: &InventoryRow,
3574 global_idx: &mut usize,
3575 target: usize,
3576 highlight: bool,
3577 ambiguous_instance_keys: &HashSet<(String, String, String)>,
3578 ) {
3579 let mut view = self.format_inventory_row(row);
3580 if let Some(id) = row.stack.item_instance_id {
3581 let key = self.inventory_row_instance_identity_key(row);
3582 if ambiguous_instance_keys.contains(&key) {
3583 view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
3584 }
3585 }
3586 lines.push(InventoryBrowserLine::Item {
3587 selectable_index: *global_idx,
3588 selected: highlight && *global_idx == target,
3589 depth: view.depth,
3590 text: view.text,
3591 title: view.title,
3592 mass_kg: view.mass_kg,
3593 volume: view.volume,
3594 instance_tooltip: view.instance_tooltip,
3595 });
3596 *global_idx += 1;
3597 }
3598
3599 pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
3602 let mut lines = Vec::new();
3603 let target = self.inventory_menu_index;
3604 let highlight = !self.show_move_picker && !self.show_grant_picker;
3605 let filter = self.inventory_filter.as_str();
3606 let mut global_idx = 0usize;
3607 let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
3608
3609 match self.inventory_tab {
3610 InventoryTab::OnPerson => {
3611 lines.push(InventoryBrowserLine::Section("— Worn —".into()));
3612 let worn = self.worn_rows_filtered(filter);
3613 if worn.is_empty() {
3614 lines.push(InventoryBrowserLine::Hint(
3615 " (nothing equipped — wear a backpack/belt from \"On you\" below)".into(),
3616 ));
3617 } else {
3618 for row in &worn {
3619 if row.is_equip_shell {
3620 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3621 lines.push(InventoryBrowserLine::SlotLabel(format!(
3622 " {}:",
3623 body_slot_label(slot)
3624 )));
3625 }
3626 }
3627 self.push_browser_item(
3628 &mut lines,
3629 row,
3630 &mut global_idx,
3631 target,
3632 highlight,
3633 &ambiguous_instance_keys,
3634 );
3635 }
3636 }
3637
3638 lines.push(InventoryBrowserLine::Blank);
3639 lines.push(InventoryBrowserLine::Section(
3640 "— On you (loose, not worn) —".into(),
3641 ));
3642 let person = self.person_rows_filtered(filter);
3643 if person.is_empty() {
3644 lines.push(InventoryBrowserLine::Hint(" (empty)".into()));
3645 } else {
3646 let mut last_group: Option<&'static str> = None;
3647 for row in &person {
3648 if row.depth == 0 {
3649 let cat = row
3650 .stack
3651 .category
3652 .as_deref()
3653 .or_else(|| self.inventory_item_category(&row.stack.template_id))
3654 .unwrap_or("");
3655 let (group, _) = inventory_category_group(cat);
3656 if last_group != Some(group) {
3657 lines.push(InventoryBrowserLine::SlotLabel(format!(
3658 " {group}"
3659 )));
3660 last_group = Some(group);
3661 }
3662 }
3663 self.push_browser_item(
3664 &mut lines,
3665 row,
3666 &mut global_idx,
3667 target,
3668 highlight,
3669 &ambiguous_instance_keys,
3670 );
3671 }
3672 }
3673 }
3674 InventoryTab::Nearby => {
3675 let nearby = self.nearby_containers();
3676 if nearby.is_empty() {
3677 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3678 lines.push(InventoryBrowserLine::Hint(
3679 " (none within reach — walk up to a chest)".into(),
3680 ));
3681 lines.push(InventoryBrowserLine::Hint(
3682 " Select an on-person item, then m / Enter → move into chest.".into(),
3683 ));
3684 } else {
3685 let mut any_visible = false;
3686 for nc in &nearby {
3687 let shell = nc.rows.first();
3688 let contents: Vec<&InventoryRow> = if filter.is_empty() {
3689 nc.rows.iter().skip(1).collect()
3690 } else {
3691 let shell_hit = shell
3692 .map(|s| {
3693 let f = filter.to_ascii_lowercase();
3694 let name = s
3695 .stack
3696 .display_name
3697 .as_deref()
3698 .unwrap_or("")
3699 .to_ascii_lowercase();
3700 let tid = s.stack.template_id.to_ascii_lowercase();
3701 name.contains(&f) || tid.contains(&f)
3702 })
3703 .unwrap_or(false);
3704 if shell_hit {
3705 nc.rows.iter().skip(1).collect()
3706 } else {
3707 nc.rows
3708 .iter()
3709 .skip(1)
3710 .filter(|r| stack_matches_filter(&r.stack, filter))
3711 .collect()
3712 }
3713 };
3714 let shell_visible = filter.is_empty()
3715 || shell
3716 .map(|s| stack_matches_filter(&s.stack, filter))
3717 .unwrap_or(false)
3718 || !contents.is_empty();
3719 if !shell_visible && shell.is_some() {
3720 continue;
3721 }
3722 any_visible = true;
3723 lines.push(InventoryBrowserLine::Blank);
3724 let lock_note = if nc.view.locked && nc.view.accessible {
3725 " unlocked with your key"
3726 } else if nc.view.locked {
3727 " locked"
3728 } else {
3729 ""
3730 };
3731 lines.push(InventoryBrowserLine::Section(format!(
3732 "— {} ({:.0}m away){lock_note} —",
3733 nc.view.display_name, nc.distance_m
3734 )));
3735 if !nc.view.accessible {
3736 lines.push(InventoryBrowserLine::Hint(
3737 " locked — need the matching key (l to try)".into(),
3738 ));
3739 } else if nc.rows.is_empty() {
3740 lines.push(InventoryBrowserLine::Hint(
3741 " (empty — switch to On person, select an item, m to move in)"
3742 .into(),
3743 ));
3744 } else if let Some(shell_row) = shell {
3745 self.push_browser_item(
3746 &mut lines,
3747 shell_row,
3748 &mut global_idx,
3749 target,
3750 highlight,
3751 &ambiguous_instance_keys,
3752 );
3753 for row in contents {
3754 self.push_browser_item(
3755 &mut lines,
3756 row,
3757 &mut global_idx,
3758 target,
3759 highlight,
3760 &ambiguous_instance_keys,
3761 );
3762 }
3763 }
3764 }
3765 if !any_visible {
3766 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3767 lines.push(InventoryBrowserLine::Hint(
3768 " (no matching items — clear filter with Esc)".into(),
3769 ));
3770 }
3771 }
3772 }
3773 }
3774 lines
3775 }
3776
3777 pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
3779 let mut opts = Vec::new();
3780 opts.push(MoveOption {
3781 label: "Relocate…".into(),
3782 kind: MoveOptionKind::RelocatePlaced {
3783 container_id: container_id.to_string(),
3784 },
3785 });
3786 opts.push(MoveOption {
3787 label: "On your person (loose)".into(),
3788 kind: MoveOptionKind::PickupPlaced {
3789 container_id: container_id.to_string(),
3790 nest_location: flatland_protocol::InventoryLocation::Root,
3791 nest_parent_instance_id: None,
3792 },
3793 });
3794 for (slot, item) in &self.worn {
3795 if item.category.as_deref() != Some("container") {
3796 continue;
3797 }
3798 if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
3799 continue;
3800 }
3801 let Some(parent_id) = item.item_instance_id else {
3802 continue;
3803 };
3804 let shell_name = item
3805 .display_name
3806 .clone()
3807 .unwrap_or_else(|| item.template_id.clone());
3808 opts.push(MoveOption {
3809 label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
3810 kind: MoveOptionKind::PickupPlaced {
3811 container_id: container_id.to_string(),
3812 nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
3813 nest_parent_instance_id: Some(parent_id),
3814 },
3815 });
3816 Self::append_chest_pickup_nested(
3818 &mut opts,
3819 container_id,
3820 flatland_protocol::InventoryLocation::Worn { slot: *slot },
3821 item,
3822 &format!("in {shell_name}"),
3823 );
3824 }
3825 opts.push(MoveOption {
3826 label: "Cancel".into(),
3827 kind: MoveOptionKind::Cancel,
3828 });
3829 opts
3830 }
3831
3832 fn append_chest_pickup_nested(
3833 opts: &mut Vec<MoveOption>,
3834 container_id: &str,
3835 location: flatland_protocol::InventoryLocation,
3836 parent: &flatland_protocol::ItemStack,
3837 context: &str,
3838 ) {
3839 for child in &parent.contents {
3840 if child.category.as_deref() != Some("container") {
3841 continue;
3842 }
3843 if !Self::is_volume_container_stack(child) {
3844 continue;
3845 }
3846 if child.world_placeable == Some(true) {
3848 continue;
3849 }
3850 let Some(child_id) = child.item_instance_id else {
3851 continue;
3852 };
3853 let name = child
3854 .display_name
3855 .clone()
3856 .unwrap_or_else(|| child.template_id.clone());
3857 opts.push(MoveOption {
3858 label: format!("{name} ({context})"),
3859 kind: MoveOptionKind::PickupPlaced {
3860 container_id: container_id.to_string(),
3861 nest_location: location.clone(),
3862 nest_parent_instance_id: Some(child_id),
3863 },
3864 });
3865 Self::append_chest_pickup_nested(
3866 opts,
3867 container_id,
3868 location.clone(),
3869 child,
3870 &format!("in {name}"),
3871 );
3872 }
3873 }
3874
3875 pub fn move_destinations_for(
3877 &self,
3878 from: &flatland_protocol::InventoryLocation,
3879 from_parent_instance_id: Option<uuid::Uuid>,
3880 moving_instance_id: Option<uuid::Uuid>,
3881 moving_template_id: &str,
3882 ) -> Vec<MoveOption> {
3883 let mut opts = Vec::new();
3884 if *from != flatland_protocol::InventoryLocation::Root {
3885 opts.push(MoveOption {
3886 label: "On your person (loose)".into(),
3887 kind: MoveOptionKind::Move {
3888 location: flatland_protocol::InventoryLocation::Root,
3889 parent_instance_id: None,
3890 },
3891 });
3892 }
3893 for (slot, item) in &self.worn {
3894 if item.category.as_deref() != Some("container") {
3895 continue;
3896 }
3897 let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3898 let shell_name = item
3899 .display_name
3900 .clone()
3901 .unwrap_or_else(|| item.template_id.clone());
3902
3903 if *slot != BodySlot::Waist
3905 && item.item_instance_id != moving_instance_id
3906 && Self::is_volume_container_stack(item)
3907 {
3908 Self::push_move_destination(
3909 &mut opts,
3910 format!("{shell_name} (worn {})", body_slot_label(*slot)),
3911 location.clone(),
3912 item.item_instance_id,
3913 from,
3914 from_parent_instance_id,
3915 );
3916 }
3917
3918 if *slot == BodySlot::Waist
3920 && Self::attaches_to_belt_loop(moving_template_id)
3921 && item.item_instance_id != moving_instance_id
3922 {
3923 Self::push_move_destination(
3924 &mut opts,
3925 format!("{shell_name} (belt loop)"),
3926 location.clone(),
3927 item.item_instance_id,
3928 from,
3929 from_parent_instance_id,
3930 );
3931 }
3932
3933 let context = if *slot == BodySlot::Waist {
3934 format!("on {shell_name}")
3935 } else {
3936 format!("in {shell_name}")
3937 };
3938 Self::append_nested_container_destinations(
3939 &mut opts,
3940 location,
3941 item,
3942 &context,
3943 from,
3944 from_parent_instance_id,
3945 moving_instance_id,
3946 );
3947 }
3948 for nc in self.nearby_containers() {
3949 if !nc.view.accessible {
3950 continue;
3951 }
3952 let location = flatland_protocol::InventoryLocation::Placed {
3953 container_id: nc.view.id.clone(),
3954 };
3955 Self::push_move_destination(
3956 &mut opts,
3957 format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
3958 location,
3959 nc.view.item_instance_id,
3960 from,
3961 from_parent_instance_id,
3962 );
3963 }
3964 let allow_drop = moving_instance_id
3965 .map(|id| !self.hand_equipped_instance_ids().contains(&id))
3966 .unwrap_or(true)
3967 && moving_instance_id
3968 .and_then(|id| self.stack_for_instance(id))
3969 .map(|stack| {
3970 !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
3971 })
3972 .unwrap_or(
3973 moving_template_id != KEY_TEMPLATE
3974 && moving_template_id != PROPERTY_DEED_TEMPLATE,
3975 );
3976 if allow_drop {
3977 opts.push(MoveOption {
3978 label: "Drop on the ground".into(),
3979 kind: MoveOptionKind::Drop,
3980 });
3981 }
3982 opts.push(MoveOption {
3983 label: "Cancel".into(),
3984 kind: MoveOptionKind::Cancel,
3985 });
3986 opts
3987 }
3988
3989 fn is_same_container_dest(
3990 dest_location: &flatland_protocol::InventoryLocation,
3991 dest_parent: Option<uuid::Uuid>,
3992 from: &flatland_protocol::InventoryLocation,
3993 from_parent: Option<uuid::Uuid>,
3994 ) -> bool {
3995 dest_location == from && dest_parent == from_parent
3996 }
3997
3998 fn push_move_destination(
3999 opts: &mut Vec<MoveOption>,
4000 label: String,
4001 location: flatland_protocol::InventoryLocation,
4002 parent_instance_id: Option<uuid::Uuid>,
4003 from: &flatland_protocol::InventoryLocation,
4004 from_parent_instance_id: Option<uuid::Uuid>,
4005 ) {
4006 if Self::is_same_container_dest(
4007 &location,
4008 parent_instance_id,
4009 from,
4010 from_parent_instance_id,
4011 ) {
4012 return;
4013 }
4014 opts.push(MoveOption {
4015 label,
4016 kind: MoveOptionKind::Move {
4017 location,
4018 parent_instance_id,
4019 },
4020 });
4021 }
4022
4023 fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
4024 stack.capacity_volume.is_some_and(|c| c > 0.0)
4025 }
4026
4027 fn attaches_to_belt_loop(template_id: &str) -> bool {
4028 matches!(template_id, "leather_pouch" | "dimensional_pouch")
4029 }
4030
4031 fn append_nested_container_destinations(
4032 opts: &mut Vec<MoveOption>,
4033 location: flatland_protocol::InventoryLocation,
4034 container: &flatland_protocol::ItemStack,
4035 context: &str,
4036 from: &flatland_protocol::InventoryLocation,
4037 from_parent_instance_id: Option<uuid::Uuid>,
4038 moving_instance_id: Option<uuid::Uuid>,
4039 ) {
4040 for child in &container.contents {
4041 if Self::is_volume_container_stack(child)
4042 && child.item_instance_id != moving_instance_id
4043 {
4044 let name = child
4045 .display_name
4046 .clone()
4047 .unwrap_or_else(|| child.template_id.clone());
4048 Self::push_move_destination(
4049 opts,
4050 format!("{name} ({context})"),
4051 location.clone(),
4052 child.item_instance_id,
4053 from,
4054 from_parent_instance_id,
4055 );
4056 }
4057 let nested_context = format!(
4058 "in {}",
4059 child.display_name.as_deref().unwrap_or(&child.template_id)
4060 );
4061 Self::append_nested_container_destinations(
4062 opts,
4063 location.clone(),
4064 child,
4065 &nested_context,
4066 from,
4067 from_parent_instance_id,
4068 moving_instance_id,
4069 );
4070 }
4071 }
4072
4073 fn clamp_inventory_indices(&mut self) {
4074 let n = self.inventory_selectable_rows().len();
4075 self.inventory_menu_index = if n == 0 {
4076 0
4077 } else {
4078 self.inventory_menu_index.min(n - 1)
4079 };
4080 if let Some(picker) = &self.move_picker {
4081 let pn = picker.options.len();
4082 self.move_picker_index = if pn == 0 {
4083 0
4084 } else {
4085 self.move_picker_index.min(pn - 1)
4086 };
4087 }
4088 }
4089
4090 fn sync_interior_map_context(&mut self) {
4095 if self.effective_inside_building().is_none() {
4096 self.interior_map = None;
4097 if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
4098 self.z_platforms = platforms;
4099 self.z_transitions = transitions;
4100 }
4101 return;
4102 }
4103 self.sync_interior_z_bands();
4104 }
4105
4106 fn sync_interior_z_bands(&mut self) {
4108 if self.effective_inside_building().is_some() {
4109 if let Some(map) = &self.interior_map {
4110 if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
4111 if self.z_bands_outdoor_backup.is_none() {
4112 self.z_bands_outdoor_backup = Some((
4113 std::mem::take(&mut self.z_platforms),
4114 std::mem::take(&mut self.z_transitions),
4115 ));
4116 }
4117 self.z_platforms = map.z_platforms.clone();
4118 self.z_transitions = map.z_transitions.clone();
4119 }
4120 }
4121 }
4122 }
4123
4124 fn apply_snapshot_fields(
4125 &mut self,
4126 snapshot: &flatland_protocol::Snapshot,
4127 entity_id: EntityId,
4128 ) {
4129 self.tick = snapshot.tick;
4130 self.chunk_rev = snapshot.chunk_rev;
4131 self.content_rev = snapshot.content_rev;
4132 self.publish_rev = snapshot.publish_rev;
4133 self.resource_nodes = snapshot.resource_nodes.clone();
4134 self.ground_drops = snapshot.ground_drops.clone();
4135 self.placed_containers = snapshot.placed_containers.clone();
4136 self.world_x0 = snapshot.world_x0;
4137 self.world_y0 = snapshot.world_y0;
4138 self.world_width_m = snapshot.world_width_m;
4139 self.world_height_m = snapshot.world_height_m;
4140 self.world_clock = snapshot.world_clock;
4141 self.terrain_zones = snapshot.terrain_zones.clone();
4142 self.z_platforms = snapshot.z_platforms.clone();
4143 self.z_transitions = snapshot.z_transitions.clone();
4144 self.z_bands_outdoor_backup = None;
4146 self.buildings = snapshot.buildings.clone();
4147 self.doors = snapshot.doors.clone();
4148 self.interior_map = snapshot.interior_map.clone();
4149 self.npcs = snapshot.npcs.clone();
4150 self.blueprints = snapshot.blueprints.clone();
4151 self.building_materials = snapshot.building_materials.clone();
4152 self.sync_inventory_from_stacks(&snapshot.inventory);
4153 self.player = snapshot
4154 .entities
4155 .iter()
4156 .find(|e| e.id == entity_id)
4157 .cloned();
4158 self.entities = snapshot.entities.clone();
4159 self.quest_log = snapshot.quest_log.clone();
4160 self.apply_hired_workers(snapshot.hired_workers.clone());
4161 self.interactables = snapshot.interactables.clone();
4162 self.ledger = snapshot.ledger.clone();
4163 self.career = snapshot.career.clone();
4164 self.combat_fx = snapshot.combat_fx.clone();
4165 self.property_zones = snapshot.property_zones.clone();
4166 self.tax_zones = snapshot.tax_zones.clone();
4167 self.growth_zones = snapshot.growth_zones.clone();
4168 self.biome_zones = snapshot.biome_zones.clone();
4169 self.terrain_kind_nav = snapshot.terrain_kind_nav.clone();
4170 self.property_plots = snapshot.property_plots.clone();
4171 self.property_plot_settings = snapshot.property_plot_settings.clone();
4172 if self.effective_inside_building().is_some() {
4175 self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
4176 }
4177 self.sync_interior_map_context();
4178 self.refresh_whisper_range();
4179 }
4180
4181 fn refresh_inventory_ui(&mut self) {
4185 if let Some(picker) = &self.move_picker {
4186 let instance_id = picker.item_instance_id;
4187 let still_exists = self
4188 .inventory_selectable_rows()
4189 .iter()
4190 .any(|r| r.stack.item_instance_id == Some(instance_id));
4191 if !still_exists {
4192 self.move_picker = None;
4193 self.show_move_picker = false;
4194 }
4195 }
4196 if let Some(picker) = &self.destroy_picker {
4197 let instance_id = picker.item_instance_id;
4198 let still_exists = self
4199 .inventory_selectable_rows()
4200 .iter()
4201 .any(|r| r.stack.item_instance_id == Some(instance_id));
4202 if !still_exists {
4203 self.destroy_picker = None;
4204 self.show_destroy_picker = false;
4205 self.destroy_confirm_pending = false;
4206 }
4207 }
4208 self.clamp_inventory_indices();
4209 }
4210
4211 fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
4217 let selected_id = self
4218 .hired_workers
4219 .get(self.workers_menu_index)
4220 .map(|w| w.instance_id.clone());
4221 workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
4222 let now = Instant::now();
4223 for w in &workers {
4224 let prev_err = self
4225 .hired_workers
4226 .iter()
4227 .find(|p| p.instance_id == w.instance_id)
4228 .and_then(|p| p.last_error.as_deref());
4229 let new_err = w.last_error.as_deref();
4230 if new_err != prev_err {
4231 if let Some(err) = new_err {
4232 if !worker_error_is_transient(err) {
4233 self.push_log(format!("Worker {}: {err}", w.label));
4234 }
4235 }
4236 }
4237 }
4238 let mut next_display = BTreeMap::new();
4239 let mut next_errors = BTreeMap::new();
4240 for w in &workers {
4241 let mut sticky = self
4242 .worker_step_display
4243 .remove(&w.instance_id)
4244 .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
4245 sticky.observe(&w.step_label, now);
4246 next_display.insert(w.instance_id.clone(), sticky);
4247
4248 let mut err_sticky = self
4249 .worker_error_display
4250 .remove(&w.instance_id)
4251 .unwrap_or_default();
4252 err_sticky.observe(w.last_error.as_deref(), now);
4253 if err_sticky.shown(now).is_some() {
4254 next_errors.insert(w.instance_id.clone(), err_sticky);
4255 }
4256 }
4257 self.worker_step_display = next_display;
4258 self.worker_error_display = next_errors;
4259 self.hired_workers = workers;
4260 self.sync_worker_take_picker_from_hired();
4261 if let Some(id) = selected_id {
4262 if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
4263 self.workers_menu_index = idx;
4264 return;
4265 }
4266 }
4267 if self.workers_menu_index >= self.hired_workers.len() {
4268 self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
4269 }
4270 }
4271
4272 fn sync_worker_take_picker_from_hired(&mut self) {
4274 if !self.show_worker_take_picker {
4275 return;
4276 }
4277 let Some(picker) = self.worker_take_picker.clone() else {
4278 return;
4279 };
4280 let Some(worker) = self
4281 .hired_workers
4282 .iter()
4283 .find(|w| w.instance_id == picker.worker_instance_id)
4284 .cloned()
4285 else {
4286 self.show_worker_take_picker = false;
4287 self.worker_take_picker = None;
4288 self.worker_take_picker_index = 0;
4289 return;
4290 };
4291 let options: Vec<WorkerGiveOption> = worker
4292 .inventory
4293 .iter()
4294 .filter_map(|stack| {
4295 let item_instance_id = stack.item_instance_id?;
4296 let label = stack
4297 .display_name
4298 .clone()
4299 .unwrap_or_else(|| stack.template_id.clone());
4300 let label = if stack.quantity > 1 {
4301 format!("{label} ×{}", stack.quantity)
4302 } else {
4303 label
4304 };
4305 Some(WorkerGiveOption {
4306 item_instance_id,
4307 label,
4308 quantity: stack.quantity,
4309 template_id: stack.template_id.clone(),
4310 })
4311 })
4312 .collect();
4313 if options.is_empty() {
4314 self.show_worker_take_picker = false;
4315 self.worker_take_picker = None;
4316 self.worker_take_picker_index = 0;
4317 return;
4318 }
4319 let prev_id = picker
4320 .options
4321 .get(self.worker_take_picker_index)
4322 .map(|o| o.item_instance_id);
4323 let idx = prev_id
4324 .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
4325 .unwrap_or(0)
4326 .min(options.len().saturating_sub(1));
4327 let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
4328 let quantity = picker.quantity.clamp(1, max_qty);
4329 self.worker_take_picker_index = idx;
4330 self.worker_take_picker = Some(WorkerTakePicker {
4331 worker_instance_id: picker.worker_instance_id,
4332 worker_label: picker.worker_label,
4333 options,
4334 quantity,
4335 });
4336 }
4337
4338 pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
4340 self.worker_step_display
4341 .get(worker_instance_id)
4342 .map(|s| s.shown.as_str())
4343 .or_else(|| {
4344 self.hired_workers
4345 .iter()
4346 .find(|w| w.instance_id == worker_instance_id)
4347 .map(|w| w.step_label.as_str())
4348 })
4349 .unwrap_or("")
4350 }
4351
4352 pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
4354 let now = Instant::now();
4355 self.worker_error_display
4356 .get(worker_instance_id)
4357 .and_then(|s| s.shown(now))
4358 .or_else(|| {
4359 self.hired_workers
4360 .iter()
4361 .find(|w| w.instance_id == worker_instance_id)
4362 .and_then(|w| w.last_error.as_deref())
4363 .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
4364 })
4365 .filter(|e| !worker_error_is_hud_noise(e))
4366 }
4367
4368 fn apply_combat_hud(&mut self, combat: &CombatHud) {
4369 self.in_combat = combat.in_combat;
4370 self.auto_attack = combat.auto_attack;
4371 self.combat_has_los = combat.has_los;
4372 self.attack_cd_ticks = combat.attack_cd_ticks;
4373 self.gcd_ticks = combat.gcd_ticks;
4374 self.weapon_ability_id = combat.ability_id.clone();
4375 self.mainhand_template_id = combat.mainhand_template_id.clone();
4376 self.mainhand_label = combat.mainhand_label.clone();
4377 self.mainhand_instance_id = combat.mainhand_instance_id;
4378 self.offhand_template_id = combat.offhand_template_id.clone();
4379 self.offhand_label = combat.offhand_label.clone();
4380 self.offhand_instance_id = combat.offhand_instance_id;
4381 self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
4382 1
4383 } else {
4384 combat.mainhand_hand_slots
4385 };
4386 self.defense = combat.defense.clone();
4387 self.worn = combat.worn.iter().cloned().collect();
4388 self.carry_mass = combat.carry_mass;
4389 self.carry_mass_max = combat.carry_mass_max;
4390 self.encumbrance = combat.encumbrance;
4391 self.cast_progress = combat.cast.clone();
4392 self.timed_channel = combat.timed_channel.clone();
4393 self.plot_build_offer = combat.plot_build.clone();
4394 self.ability_cooldowns = combat.ability_cooldowns.clone();
4395 self.blocking_active = combat.blocking_active;
4396 self.max_target_slots = combat.max_target_slots.max(1);
4397 self.combat_slots = combat.slots.clone();
4398 self.rotation_presets = combat.rotation_presets.clone();
4399 self.known_abilities = combat.known_abilities.clone();
4400 self.ability_meta = combat
4401 .ability_meta
4402 .iter()
4403 .cloned()
4404 .map(|meta| (meta.id.clone(), meta))
4405 .collect();
4406 self.ability_mastery = combat
4407 .ability_mastery
4408 .iter()
4409 .cloned()
4410 .map(|row| (row.ability_id.clone(), row))
4411 .collect();
4412 self.hotbar = combat.hotbar.clone();
4413 self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
4414 self.keychain_stacks = combat.keychain.clone();
4415 self.whisper_pouch_stacks = combat.whisper_pouch.clone();
4416 self.combat_target_detail = combat.target.clone();
4417 self.statuses = combat.statuses.clone();
4418 self.combat_target = combat.target_entity_id;
4419 if combat.progression_xp_base > 0.0 {
4420 self.progression_curve = Some(flatland_protocol::ProgressionCurve {
4421 baseline_display: combat.progression_baseline,
4422 xp_base: combat.progression_xp_base,
4423 xp_growth: combat.progression_xp_growth,
4424 });
4425 }
4426 if let Some(xp) = &combat.progression_xp {
4427 if let Some(player) = &mut self.player {
4428 player.progression_xp = Some(xp.clone());
4429 if let Some(attrs) = combat.attributes {
4430 player.attributes = Some(attrs);
4431 }
4432 if let Some(skills) = &combat.skills {
4433 player.skills = Some(skills.clone());
4434 }
4435 }
4436 }
4437 if let Some(label) = &combat.target_label {
4438 self.combat_target_label = Some(label.clone());
4439 } else if let Some(id) = combat.target_entity_id {
4440 self.combat_target_label = self
4441 .entities
4442 .iter()
4443 .find(|e| e.id == id)
4444 .map(|e| e.label.clone())
4445 .or_else(|| self.combat_target_label.clone());
4446 }
4447 self.refresh_inventory_ui();
4448 }
4449
4450 pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
4452 self.combat_slots
4453 .iter()
4454 .find(|s| s.slot_index == slot)
4455 .and_then(|s| s.target_entity_id)
4456 .or_else(|| if slot == 1 { self.combat_target } else { None })
4457 }
4458
4459 pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
4461 self.ability_meta
4462 .get(ability_id)
4463 .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
4464 .unwrap_or(self.ground_target.is_some())
4467 }
4468
4469 pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
4471 self.ability_meta
4472 .get(ability_id)
4473 .map(|meta| meta.aim_mode == "ground")
4474 .unwrap_or(false)
4475 }
4476
4477 pub fn ability_auto_rotation_eligible(&self, ability_id: &str) -> bool {
4480 self.ability_meta
4481 .get(ability_id)
4482 .map(|meta| meta.auto_rotation_eligible)
4483 .unwrap_or(true)
4484 }
4485
4486 pub fn set_ground_target(&mut self, x: f32, y: f32) {
4488 self.ground_target = Some((x, y, 0.0));
4489 }
4490
4491 pub fn clear_ground_target(&mut self) {
4493 self.ground_target = None;
4494 }
4495
4496 pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
4499 if !(1..=9).contains(&slot_1_to_9) {
4500 return None;
4501 }
4502 self.hotbar
4503 .get((slot_1_to_9 - 1) as usize)
4504 .and_then(|a| a.as_deref())
4505 .filter(|id| !id.is_empty())
4506 }
4507
4508 pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
4510 let binding = self.hotbar_ability(slot_1_to_9)?;
4511 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
4512 let name = self
4513 .inventory_hints
4514 .get(template_id)
4515 .map(|h| h.display_name.as_str())
4516 .unwrap_or(template_id);
4517 let qty = self.inventory.get(template_id).copied().unwrap_or(0);
4518 Some(format!("{name}×{qty}"))
4519 } else {
4520 Some(binding.to_string())
4521 }
4522 }
4523
4524 pub fn loadout_ability_choices(&self) -> Vec<String> {
4526 let mut out = self.known_abilities.clone();
4527 let weapon = self.weapon_ability_id.trim();
4528 if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
4529 out.push(weapon.to_string());
4530 }
4531 out
4532 }
4533
4534 pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
4536 let mut out = Vec::new();
4537 for ability in self.loadout_ability_choices() {
4538 let meta = if ability == self.weapon_ability_id {
4539 Some("weapon".into())
4540 } else {
4541 None
4542 };
4543 out.push(LoadoutHotbarChoice {
4544 binding: ability.clone(),
4545 label: ability,
4546 meta,
4547 });
4548 }
4549 let mut consumables: Vec<(String, String, u32)> = Vec::new();
4550 for stack in &self.inventory_stacks {
4551 if Self::stack_is_item_grant(stack) {
4552 continue;
4553 }
4554 if self.inventory_item_category(&stack.template_id) != Some("consumable") {
4555 continue;
4556 }
4557 let qty = stack.quantity.max(1);
4558 if let Some((_, _, existing)) = consumables
4559 .iter_mut()
4560 .find(|(id, _, _)| id == &stack.template_id)
4561 {
4562 *existing = existing.saturating_add(qty);
4563 } else {
4564 let label = stack
4565 .display_name
4566 .clone()
4567 .or_else(|| {
4568 self.inventory_hints
4569 .get(&stack.template_id)
4570 .map(|h| h.display_name.clone())
4571 })
4572 .unwrap_or_else(|| stack.template_id.clone());
4573 consumables.push((stack.template_id.clone(), label, qty));
4574 }
4575 }
4576 consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
4577 for (template_id, label, qty) in consumables {
4578 out.push(LoadoutHotbarChoice {
4579 binding: flatland_protocol::hotbar_consumable_binding(&template_id),
4580 label: format!("{label} ×{qty}"),
4581 meta: Some("use".into()),
4582 });
4583 }
4584 out
4585 }
4586
4587 pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
4589 self.combat_candidates()
4590 }
4591
4592 pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
4594 let (px, py) = self.player_position();
4595 let dist = |id: EntityId| {
4596 self.entities
4597 .iter()
4598 .find(|e| e.id == id)
4599 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4600 .unwrap_or(f32::MAX)
4601 };
4602
4603 let mut allies = Vec::new();
4604 if let Some(me) = self.player.as_ref() {
4606 let alive = me
4607 .vitals
4608 .as_ref()
4609 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4610 .unwrap_or(true);
4611 if alive {
4612 allies.push((self.entity_id, "Yourself".into()));
4613 }
4614 }
4615 for entity in &self.entities {
4616 if entity.id == self.entity_id {
4617 continue;
4618 }
4619 if entity.vitals.is_some() {
4620 let alive = entity
4621 .vitals
4622 .as_ref()
4623 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4624 .unwrap_or(true);
4625 if alive {
4626 allies.push((entity.id, entity.label.clone()));
4627 }
4628 }
4629 }
4630 allies.sort_by(|(a, _), (b, _)| {
4631 if *a == self.entity_id {
4632 return std::cmp::Ordering::Less;
4633 }
4634 if *b == self.entity_id {
4635 return std::cmp::Ordering::Greater;
4636 }
4637 dist(*a)
4638 .partial_cmp(&dist(*b))
4639 .unwrap_or(std::cmp::Ordering::Equal)
4640 });
4641
4642 let mut monsters = self.combat_candidates();
4643 monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
4644 allies.into_iter().chain(monsters).collect()
4645 }
4646
4647 fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
4648 match slot_index {
4649 2 => self.t2_candidates(),
4650 _ => self.t1_candidates(),
4651 }
4652 }
4653
4654 pub fn pick_combat_target_at(
4656 &self,
4657 wx: f32,
4658 wy: f32,
4659 slot_index: u8,
4660 radius_m: f32,
4661 ) -> Option<(EntityId, String)> {
4662 let mut best: Option<(f32, EntityId, String)> = None;
4663 for (id, label) in self.candidates_for_slot(slot_index) {
4664 let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
4665 if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
4667 let d = distance(wx, wy, npc.x, npc.y);
4668 if d <= radius_m {
4669 best = match best {
4670 Some((bd, _, _)) if bd <= d => best,
4671 _ => Some((d, id, label)),
4672 };
4673 }
4674 }
4675 continue;
4676 };
4677 let d = distance(
4678 wx,
4679 wy,
4680 entity.transform.position.x,
4681 entity.transform.position.y,
4682 );
4683 if d <= radius_m {
4684 best = match best {
4685 Some((bd, _, _)) if bd <= d => best,
4686 _ => Some((d, id, label)),
4687 };
4688 }
4689 }
4690 best.map(|(_, id, label)| (id, label))
4691 }
4692
4693 pub(crate) fn restore_from_welcome(
4695 &mut self,
4696 session_id: SessionId,
4697 entity_id: EntityId,
4698 snapshot: &flatland_protocol::Snapshot,
4699 ) {
4700 self.clear_harvest_state();
4701 self.disconnect_reason = None;
4702 self.show_stats = false;
4703 self.show_craft_menu = false;
4704 self.show_shop_menu = false;
4705 self.shop_catalog = None;
4706 self.show_inventory_menu = false;
4707 self.session_id = session_id;
4708 self.entity_id = entity_id;
4709 self.connected = true;
4710 self.apply_snapshot_fields(snapshot, entity_id);
4711 if let Some(combat) = &snapshot.combat {
4712 self.apply_combat_hud(combat);
4713 let stacks = self.inventory_stacks.clone();
4714 self.sync_inventory_from_stacks(&stacks);
4715 }
4716 }
4717
4718 fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
4719 self.tick = delta.tick;
4720 self.world_clock = delta.world_clock;
4721
4722 if delta.entities.is_empty() {
4724 self.ground_drops = delta.ground_drops.clone();
4725 self.combat_fx = delta.combat_fx.clone();
4726 self.property_plots = delta.property_plots.clone();
4727 self.apply_terrain_overlays(&delta.terrain_overlays);
4728 if let Some(combat) = &delta.combat {
4729 self.apply_combat_hud(combat);
4730 let stacks = self.inventory_stacks.clone();
4731 self.sync_inventory_from_stacks(&stacks);
4732 }
4733 self.refresh_whisper_range();
4735 return;
4736 }
4737 if !delta.buildings.is_empty() {
4738 self.buildings = delta.buildings.clone();
4739 }
4740 if !delta.blueprints.is_empty() {
4741 self.blueprints = delta.blueprints.clone();
4742 }
4743 if !delta.building_materials.is_empty() {
4744 self.building_materials = delta.building_materials.clone();
4745 }
4746 self.sync_inventory_from_stacks(&delta.inventory);
4747
4748 if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
4749 self.player = Some(updated.clone());
4750 }
4751 self.entities = delta.entities.clone();
4752 if self.player.is_none() {
4753 self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
4754 }
4755
4756 self.sync_interior_map_context();
4757
4758 if !delta.resource_nodes.is_empty() {
4762 self.resource_nodes = delta.resource_nodes.clone();
4763 } else if delta.interior_map.is_some()
4764 || self.effective_inside_building().is_some()
4765 {
4766 self.resource_nodes = delta.resource_nodes.clone();
4767 }
4768 self.ground_drops = delta.ground_drops.clone();
4769 self.placed_containers = delta.placed_containers.clone();
4771 if !delta.doors.is_empty() {
4772 self.doors = delta.doors.clone();
4773 }
4774 if self.effective_inside_building().is_some() {
4775 if let Some(map) = &delta.interior_map {
4776 self.interior_map = Some(map.clone());
4777 }
4778 } else {
4779 self.interior_map = None;
4780 }
4781 self.sync_interior_z_bands();
4782 self.npcs = delta.npcs.clone();
4784 if !delta.quest_log.is_empty() {
4785 self.quest_log = delta.quest_log.clone();
4786 }
4787 self.apply_hired_workers(delta.hired_workers.clone());
4788 if !delta.interactables.is_empty() {
4789 self.interactables = delta.interactables.clone();
4790 }
4791 if delta.ledger.is_some() {
4792 self.ledger = delta.ledger.clone();
4793 }
4794 if delta.career.is_some() {
4795 self.career = delta.career.clone();
4796 }
4797 self.combat_fx = delta.combat_fx.clone();
4798 if !delta.property_plots.is_empty() {
4800 self.property_plots = delta.property_plots.clone();
4801 }
4802 self.apply_terrain_overlays(&delta.terrain_overlays);
4803 if let Some(combat) = &delta.combat {
4804 self.apply_combat_hud(combat);
4805 let stacks = self.inventory_stacks.clone();
4806 self.sync_inventory_from_stacks(&stacks);
4807 } else {
4808 self.refresh_inventory_ui();
4809 }
4810 self.refresh_whisper_range();
4811 }
4812
4813 fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
4816 self.terrain_zones
4817 .retain(|z| !z.id.starts_with("rt:"));
4818 self.terrain_zones.extend(overlays.iter().cloned());
4819 }
4820
4821 fn refresh_whisper_range(&mut self) {
4824 let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
4825 return;
4826 };
4827 let (px, py) = self.player_position();
4828 let in_range = self.entities.iter().any(|e| {
4829 e.id == peer
4830 && distance(
4831 px,
4832 py,
4833 e.transform.position.x,
4834 e.transform.position.y,
4835 ) <= INTERACTION_RADIUS_M
4836 });
4837 if !in_range {
4838 self.social_chat.cancel_whisper_out_of_range();
4839 }
4840 }
4841
4842 pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
4844 let (px, py) = self.player_position();
4845 let mut out = Vec::new();
4846 for npc in &self.npcs {
4847 let Some(eid) = npc.entity_id else {
4848 continue;
4849 };
4850 let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
4851 let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
4852 if alive && has_hp {
4853 out.push((eid, npc.label.clone()));
4854 }
4855 }
4856 out.sort_by(|(a_id, a_label), (b_id, b_label)| {
4857 let dist = |id: EntityId| {
4858 self.entities
4859 .iter()
4860 .find(|e| e.id == id)
4861 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4862 .unwrap_or(f32::MAX)
4863 };
4864 dist(*a_id)
4865 .partial_cmp(&dist(*b_id))
4866 .unwrap_or(std::cmp::Ordering::Equal)
4867 .then_with(|| a_label.cmp(b_label))
4868 .then_with(|| a_id.cmp(b_id))
4869 });
4870 out
4871 }
4872
4873 pub fn refresh_combat_target_label(&mut self) {
4874 let Some(id) = self.combat_target else {
4875 return;
4876 };
4877 if let Some((_, label)) = self
4878 .combat_candidates()
4879 .into_iter()
4880 .find(|(eid, _)| *eid == id)
4881 {
4882 self.combat_target_label = Some(label);
4883 } else if let Some(label) = self
4884 .entities
4885 .iter()
4886 .find(|e| e.id == id)
4887 .map(|e| e.label.clone())
4888 {
4889 self.combat_target_label = Some(label);
4890 }
4891 }
4892
4893 pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
4894 self.quest_log
4895 .iter()
4896 .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
4897 .collect()
4898 }
4899
4900 pub fn has_worker_lodging(&self) -> bool {
4902 self.free_worker_lodging_slots() > 0
4903 }
4904
4905 pub fn free_worker_lodging_slots(&self) -> i64 {
4907 let slots: u32 = self
4908 .placed_containers
4909 .iter()
4910 .filter(|c| match (self.character_id, c.owner_character_id) {
4911 (Some(me), Some(owner)) => me == owner,
4912 (Some(_), None) => false,
4913 (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
4914 })
4915 .map(|c| c.worker_lodging_capacity.unwrap_or(0))
4916 .sum();
4917 let used = self.hired_workers.len() as u32;
4918 slots as i64 - used as i64
4919 }
4920
4921 pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
4923 let mut names: Vec<String> = self
4924 .hired_workers
4925 .iter()
4926 .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
4927 .map(|w| w.label.clone())
4928 .collect();
4929 names.sort();
4930 names
4931 }
4932
4933 pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
4935 let is_lodging = self
4936 .placed_containers
4937 .iter()
4938 .find(|c| c.id == container_id)
4939 .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
4940 if !is_lodging {
4941 return None;
4942 }
4943 let names = self.lodging_occupant_labels(container_id);
4944 Some(if names.is_empty() {
4945 "vacant".into()
4946 } else {
4947 names.join(", ")
4948 })
4949 }
4950
4951 pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
4952 self.quest_log
4953 .iter()
4954 .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
4955 .or_else(|| {
4956 self.quest_log
4957 .iter()
4958 .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
4959 })
4960 }
4961
4962 pub fn nearby_lockable_door(&self) -> bool {
4964 let (px, py) = self.player_position();
4965 self.doors
4966 .iter()
4967 .any(|d| d.lock_id.is_some() && (d.x - px).hypot(d.y - py) <= 3.5)
4968 }
4969
4970 pub fn nearby_open_player_door(&self) -> bool {
4972 if self.effective_inside_building().is_some() {
4973 return false;
4974 }
4975 let (px, py) = self.player_position();
4976 self.doors.iter().any(|d| {
4977 if !d.open || d.locked {
4978 return false;
4979 }
4980 let player_house = self
4981 .buildings
4982 .iter()
4983 .find(|b| b.id == d.building_id)
4984 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
4985 player_house && (d.x - px).hypot(d.y - py) <= 3.5
4986 })
4987 }
4988
4989 pub fn nearby_player_exit_door(&self) -> bool {
4991 let Some(bid) = self.effective_inside_building() else {
4992 return false;
4993 };
4994 let (px, py) = self.player_position();
4995 self.doors.iter().any(|d| {
4996 if d.building_id != bid || d.portal.is_none() {
4997 return false;
4998 }
4999 let player_house = self
5000 .buildings
5001 .iter()
5002 .find(|b| b.id == d.building_id)
5003 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
5004 player_house && (d.x - px).hypot(d.y - py) <= 1.5
5005 })
5006 }
5007
5008 pub fn nearest_interact_target(&self) -> Option<String> {
5010 let (px, py) = self.player_position();
5011 let inside = self.effective_inside_building();
5012
5013 #[derive(Clone, Copy, PartialEq, Eq)]
5014 enum Kind {
5015 Player,
5016 Npc,
5017 HiredWorker,
5018 QuestBoard,
5019 ExitDoor,
5020 EnterDoor,
5021 Well,
5022 Water,
5023 }
5024
5025 fn kind_priority(kind: Kind) -> u8 {
5026 match kind {
5027 Kind::Player => 0,
5028 Kind::Npc => 0,
5029 Kind::HiredWorker => 0,
5030 Kind::QuestBoard => 1,
5031 Kind::ExitDoor => 2,
5032 Kind::EnterDoor => 3,
5033 Kind::Well => 4,
5034 Kind::Water => 5,
5035 }
5036 }
5037
5038 let mut best: Option<(f32, Kind, String)> = None;
5039
5040 let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
5041 if dist > max {
5042 return;
5043 }
5044 let replace = match best {
5045 None => true,
5046 Some((bd, _bk, _)) if dist < bd - 0.05 => true,
5047 Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
5048 kind_priority(kind) < kind_priority(bk)
5049 }
5050 _ => false,
5051 };
5052 if replace {
5053 best = Some((dist, kind, id));
5054 }
5055 };
5056
5057 for npc in &self.npcs {
5058 consider(
5059 distance(px, py, npc.x, npc.y),
5060 INTERACTION_RADIUS_M,
5061 Kind::Npc,
5062 npc.id.clone(),
5063 );
5064 }
5065
5066 for worker in &self.hired_workers {
5067 consider(
5068 distance(px, py, worker.x, worker.y),
5069 INTERACTION_RADIUS_M,
5070 Kind::HiredWorker,
5071 worker.instance_id.clone(),
5072 );
5073 }
5074
5075 for entity in &self.entities {
5076 if entity.id == self.entity_id || entity.vitals.is_none() || entity.label.trim().is_empty()
5077 {
5078 continue;
5079 }
5080 if self
5082 .hired_workers
5083 .iter()
5084 .any(|w| w.entity_id == entity.id)
5085 {
5086 continue;
5087 }
5088 consider(
5089 distance(
5090 px,
5091 py,
5092 entity.transform.position.x,
5093 entity.transform.position.y,
5094 ),
5095 INTERACTION_RADIUS_M,
5096 Kind::Player,
5097 entity.id.to_string(),
5098 );
5099 }
5100
5101 for door in &self.doors {
5102 if let Some(ref bid) = inside {
5103 if door.building_id != *bid {
5104 continue;
5105 }
5106 let is_exit = door.portal.is_some();
5107 let max = if is_exit {
5108 INTERACTION_RADIUS_M
5109 } else {
5110 DOOR_INTERACTION_RADIUS_M
5111 };
5112 let kind = if is_exit {
5113 Kind::ExitDoor
5114 } else {
5115 Kind::EnterDoor
5116 };
5117 consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
5118 continue;
5119 }
5120 consider(
5121 distance(px, py, door.x, door.y),
5122 DOOR_INTERACTION_RADIUS_M,
5123 Kind::EnterDoor,
5124 door.id.clone(),
5125 );
5126 }
5127
5128 if inside.is_none() {
5129 for inter in &self.interactables {
5130 if inter.kind == "quest_board" {
5131 consider(
5132 distance(px, py, inter.x, inter.y),
5133 QUEST_BOARD_INTERACTION_RADIUS_M,
5134 Kind::QuestBoard,
5135 inter.id.clone(),
5136 );
5137 }
5138 }
5139 for building in &self.buildings {
5140 if !building.tags.iter().any(|t| t == "well") {
5141 continue;
5142 }
5143 consider(
5144 distance(px, py, building.x, building.y),
5145 INTERACTION_RADIUS_M,
5146 Kind::Well,
5147 building.id.clone(),
5148 );
5149 }
5150 if self.in_shallow_water() {
5151 consider(
5152 0.0,
5153 INTERACTION_RADIUS_M,
5154 Kind::Water,
5155 "water_source".into(),
5156 );
5157 }
5158 }
5159
5160 best.map(|(_, _, id)| id)
5161 }
5162
5163 pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
5165 if self.effective_inside_building().is_some() {
5166 return None;
5167 }
5168 let (px, py) = self.player_position();
5169 self.interactables
5170 .iter()
5171 .filter(|i| i.kind == "quest_board")
5172 .map(|i| {
5173 let label = if i.label.is_empty() {
5174 "Quest board".to_string()
5175 } else {
5176 i.label.clone()
5177 };
5178 (label, distance(px, py, i.x, i.y))
5179 })
5180 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
5181 }
5182
5183 pub fn template_display_name(&self, template_id: &str) -> String {
5185 self.inventory_hints
5186 .get(template_id)
5187 .map(|h| h.display_name.clone())
5188 .filter(|n| !n.is_empty())
5189 .unwrap_or_else(|| humanize_template_id(template_id))
5190 }
5191
5192 pub fn blueprint_item_label(&self, template_id: &str, display_name: &str) -> String {
5194 if !display_name.is_empty() {
5195 display_name.to_string()
5196 } else {
5197 self.template_display_name(template_id)
5198 }
5199 }
5200
5201 pub fn blueprint_output_label(&self, blueprint: &BlueprintView) -> String {
5202 self.blueprint_item_label(&blueprint.output, &blueprint.output_display_name)
5203 }
5204
5205 pub fn blueprint_ingredient_label(
5206 &self,
5207 input: &flatland_protocol::BlueprintIngredientView,
5208 ) -> String {
5209 self.blueprint_item_label(&input.template_id, &input.display_name)
5210 }
5211
5212 pub fn blueprint_tool_label(&self, tool: &flatland_protocol::ToolRequirementView) -> String {
5213 self.blueprint_item_label(&tool.item, &tool.display_name)
5214 }
5215
5216 pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
5218 use crate::worker_route_editor::{
5219 node_candidates, node_candidates_stable, route_editor_lodging_anchor,
5220 };
5221 let lodging = self
5222 .worker_route_editor
5223 .as_ref()
5224 .and_then(|ed| ed.lodging_container_id.as_deref());
5225 match route_editor_lodging_anchor(lodging, &self.placed_containers) {
5226 Some((ax, ay)) => node_candidates(&self.resource_nodes, ax, ay),
5227 None => node_candidates_stable(&self.resource_nodes),
5228 }
5229 }
5230
5231 pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
5232 if dist_m.is_nan() {
5233 return "—".into();
5234 }
5235 let from_bed = self
5236 .worker_route_editor
5237 .as_ref()
5238 .and_then(|ed| ed.lodging_container_id.as_deref())
5239 .and_then(|id| {
5240 self.placed_containers
5241 .iter()
5242 .find(|c| c.id == id)
5243 .map(|c| c.display_name.clone())
5244 });
5245 match from_bed {
5246 Some(bed) => format!("{dist_m:.0}m from {bed}"),
5247 None => format!("{dist_m:.0}m"),
5248 }
5249 }
5250
5251 pub fn placed_container_public_label(
5253 &self,
5254 c: &flatland_protocol::PlacedContainerView,
5255 ) -> String {
5256 let is_owner = match (self.character_id, c.owner_character_id) {
5257 (Some(me), Some(owner)) => me == owner,
5258 _ => false,
5259 };
5260 if is_owner {
5261 c.display_name.clone()
5262 } else {
5263 self.template_display_name(&c.template_id)
5264 }
5265 }
5266
5267 pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
5269 let mut out = Vec::new();
5270 for stack in &self.inventory_stacks {
5271 if stack.template_id == KEY_TEMPLATE {
5272 out.push(KeychainEntry {
5273 stack: stack.clone(),
5274 stowed: false,
5275 });
5276 }
5277 }
5278 for stack in &self.keychain_stacks {
5279 if stack.template_id == KEY_TEMPLATE {
5280 out.push(KeychainEntry {
5281 stack: stack.clone(),
5282 stowed: true,
5283 });
5284 }
5285 }
5286 out
5287 }
5288
5289 pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
5291 if stack.template_id != KEY_TEMPLATE {
5292 return None;
5293 }
5294 if let Some(name) = stack
5295 .props
5296 .get(PROP_OPENS_CONTAINER_NAME)
5297 .filter(|n| !n.is_empty())
5298 {
5299 return Some(name.clone());
5300 }
5301 let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
5302 self.container_name_for_lock_id(opens)
5303 }
5304
5305 pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
5307 if stack.template_id == KEY_TEMPLATE {
5308 self.template_display_name(KEY_TEMPLATE)
5309 } else {
5310 stack
5311 .display_name
5312 .clone()
5313 .unwrap_or_else(|| stack.template_id.clone())
5314 }
5315 }
5316
5317 pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
5319 if stack.template_id != KEY_TEMPLATE {
5320 return String::new();
5321 }
5322 match self.key_pair_chest_label(stack) {
5323 Some(chest) if self.key_drop_blocked(stack) => {
5324 format!(" [key for {chest} — can't drop while locked]")
5325 }
5326 Some(chest) => format!(" [key for {chest}]"),
5327 None => " [key — unpaired]".into(),
5328 }
5329 }
5330
5331 pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
5333 for c in &self.placed_containers {
5334 if c.lock_id.as_deref() == Some(lock) {
5335 return Some(c.display_name.clone());
5336 }
5337 }
5338 Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
5339 self.worn
5340 .values()
5341 .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
5342 })
5343 }
5344
5345 pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
5347 if stack.template_id != KEY_TEMPLATE {
5348 return false;
5349 }
5350 let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
5351 return false;
5352 };
5353 for c in &self.placed_containers {
5354 if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
5355 return true;
5356 }
5357 }
5358 if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
5359 return true;
5360 }
5361 self.worn
5362 .values()
5363 .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
5364 }
5365
5366 pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
5368 stack.template_id == PROPERTY_DEED_TEMPLATE
5369 }
5370
5371 pub fn is_property_deed_template(template_id: &str) -> bool {
5372 template_id == PROPERTY_DEED_TEMPLATE
5373 }
5374
5375 pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
5376 stack
5377 .props
5378 .get("plot_id")
5379 .and_then(|s| uuid::Uuid::parse_str(s).ok())
5380 }
5381
5382 pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
5384 let (px, py) = self.player_position();
5385 let (cx, cy) = self.farm_plot_cell_under_player()?;
5386 let tx = cx as f32 + 0.5;
5387 let ty = cy as f32 + 0.5;
5388 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5389 return None;
5390 }
5391 let kind = self
5392 .terrain_at(tx, ty)
5393 .or_else(|| self.terrain_at(px, py));
5394 if kind == Some(TerrainKindView::Tilled) {
5395 return None;
5396 }
5397 if matches!(
5398 kind,
5399 Some(TerrainKindView::ShallowWater)
5400 | Some(TerrainKindView::DeepWater)
5401 | Some(TerrainKindView::Rock)
5402 ) {
5403 return None;
5404 }
5405 Some((tx, ty))
5406 }
5407
5408 fn container_name_in_stacks(
5409 stacks: &[flatland_protocol::ItemStack],
5410 lock: &str,
5411 ) -> Option<String> {
5412 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
5413 for s in stacks {
5414 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
5415 return Some(GameState::stack_container_label(s));
5416 }
5417 if let Some(name) = walk(&s.contents, lock) {
5418 return Some(name);
5419 }
5420 }
5421 None
5422 }
5423 walk(stacks, lock)
5424 }
5425
5426 fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
5427 stack
5428 .props
5429 .get(PROP_CUSTOM_NAME)
5430 .cloned()
5431 .or_else(|| stack.display_name.clone())
5432 .unwrap_or_else(|| stack.template_id.clone())
5433 }
5434
5435 fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5436 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5437 for s in stacks {
5438 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
5439 return true;
5440 }
5441 if walk(&s.contents, lock) {
5442 return true;
5443 }
5444 }
5445 false
5446 }
5447 walk(stacks, lock)
5448 }
5449
5450 fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
5451 if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
5452 return Some(stack.clone());
5453 }
5454 for worn in self.worn.values() {
5455 if worn.item_instance_id == Some(instance_id) {
5456 return Some(worn.clone());
5457 }
5458 if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
5459 return Some(stack.clone());
5460 }
5461 }
5462 None
5463 }
5464
5465 pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
5467 self.property_zones
5468 .iter()
5469 .enumerate()
5470 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5471 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5472 .map(|(_, z)| z)
5473 }
5474
5475 pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
5477 self.tax_zones
5478 .iter()
5479 .enumerate()
5480 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5481 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5482 .map(|(_, z)| z)
5483 }
5484
5485 pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
5487 let mut max_bps = 0u32;
5488 let mut y = y0 + 0.5;
5489 while y < y1 {
5490 let mut x = x0 + 0.5;
5491 while x < x1 {
5492 if let Some(tz) = self.tax_zone_at(x, y) {
5493 max_bps = max_bps.max(tz.rate_bps);
5494 }
5495 x += 1.0;
5496 }
5497 y += 1.0;
5498 }
5499 max_bps
5500 }
5501
5502 pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5504 let mode = self.claim_mode.as_ref()?;
5505 let w = mode.width_m.max(1) as f32;
5506 let h = mode.height_m.max(1) as f32;
5507 Some((mode.anchor_x, mode.anchor_y, mode.anchor_x + w, mode.anchor_y + h))
5508 }
5509
5510 pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5512 let mode = self.relocate_mode.as_ref()?;
5513 let x0 = mode.cursor_x.floor();
5514 let y0 = mode.cursor_y.floor();
5515 Some((x0, y0, x0 + 1.0, y0 + 1.0))
5516 }
5517
5518 pub fn claim_quote(
5521 &self,
5522 ) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
5523 let mode = self.claim_mode.as_ref()?;
5524 let zone = self
5525 .property_zones
5526 .iter()
5527 .find(|z| z.id == mode.zone_id)?;
5528 let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
5529 let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
5530 let zone_area = zone_view_area_m2(zone).max(1.0);
5531 let area_frac = (area / zone_area).clamp(0.0, 1.0);
5532 let weight = self
5533 .property_plot_settings
5534 .as_ref()
5535 .map(|s| s.tax_premium_weight)
5536 .unwrap_or(0.5)
5537 .max(0.0);
5538 let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
5539 let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
5540 let purchase = ((zone.crown_price_copper as f64)
5541 * (area_frac as f64)
5542 * (premium as f64))
5543 .ceil()
5544 .max(0.0) as u64;
5545 let upkeep = if zone.upkeep_copper_per_day == 0 {
5546 0
5547 } else {
5548 ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
5549 .ceil()
5550 .max(1.0) as u64
5551 };
5552 let copper = crate::currency::copper_from_counts(&self.inventory);
5553 let can_afford = copper >= purchase;
5554 let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
5555 Some((purchase, upkeep, area, premium, can_afford, valid, reason))
5556 }
5557
5558 fn validate_claim_footprint(
5559 &self,
5560 zone: &flatland_protocol::PropertyZoneView,
5561 x0: f32,
5562 y0: f32,
5563 x1: f32,
5564 y1: f32,
5565 area: f32,
5566 ) -> (bool, String) {
5567 let min_area = self
5568 .property_plot_settings
5569 .as_ref()
5570 .map(|s| s.min_plot_area_m2)
5571 .unwrap_or(4.0);
5572 if area + f32::EPSILON < min_area {
5573 return (false, "plot too small".into());
5574 }
5575 if zone.max_area_m2.is_some_and(|m| area > m) {
5576 return (false, "plot exceeds max area".into());
5577 }
5578 if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
5579 return (false, "plot must lie inside the property zone".into());
5580 }
5581 if self.property_plots.iter().any(|p| {
5582 rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1)
5583 }) {
5584 return (false, "plot overlaps an existing claim".into());
5585 }
5586 (true, String::new())
5587 }
5588
5589 pub fn free_property_zone_under_player(
5591 &self,
5592 ) -> Option<&flatland_protocol::PropertyZoneView> {
5593 let (px, py) = self.player_position();
5594 let zone = self.property_zone_at(px, py)?;
5595 if self
5596 .property_plots
5597 .iter()
5598 .any(|p| point_in_plot(px, py, p))
5599 {
5600 return None;
5601 }
5602 Some(zone)
5603 }
5604
5605 pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5607 let (px, py) = self.player_position();
5608 self.property_plots
5609 .iter()
5610 .find(|p| p.is_mine && point_in_plot(px, py, p))
5611 }
5612
5613 pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5615 let (px, py) = self.player_position();
5616 self.property_plots
5617 .iter()
5618 .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
5619 }
5620
5621 pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
5623 if self.farmable_plot_under_player().is_none() {
5624 return None;
5625 }
5626 let (px, py) = self.player_position();
5627 Some((px.floor() as i32, py.floor() as i32))
5628 }
5629
5630 fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
5631 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5632 self.resource_nodes.iter().any(|n| {
5633 let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
5634 ncx == cx && ncy == cy
5635 || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
5636 })
5637 }
5638
5639 fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
5640 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5641 let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
5642 || self
5643 .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
5644 .is_some_and(|z| z.kind == TerrainKindView::Tilled);
5645 if !tilled {
5646 return false;
5647 }
5648 !self.resource_node_occupies_farm_cell(cx, cy)
5649 }
5650
5651 pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
5653 let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
5654 return false;
5655 };
5656 self.free_tilled_plant_slot_at(cx, cy)
5657 }
5658
5659 pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
5661 let (px, py) = self.player_position();
5662 for dy in -2..=2 {
5663 for dx in -2..=2 {
5664 let cx = px.floor() as i32 + dx;
5665 let cy = py.floor() as i32 + dy;
5666 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5667 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5668 continue;
5669 }
5670 if self.free_tilled_plant_slot_at(cx, cy) {
5671 return true;
5672 }
5673 }
5674 }
5675 false
5676 }
5677
5678 fn stack_is_farm_seed(stack: &flatland_protocol::ItemStack) -> bool {
5679 stack.quantity > 0
5680 && (stack.props.contains_key("seed_for")
5681 || stack.template_id.ends_with("_seed")
5682 || stack.template_id == "potato_seed"
5683 || stack.template_id == "carrot_seed")
5684 }
5685
5686 pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
5688 let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
5689 fn walk(
5690 stacks: &[flatland_protocol::ItemStack],
5691 counts: &mut std::collections::HashMap<String, u32>,
5692 ) {
5693 for s in stacks {
5694 if GameState::stack_is_farm_seed(s) {
5695 *counts.entry(s.template_id.clone()).or_default() += s.quantity;
5696 }
5697 walk(&s.contents, counts);
5698 }
5699 }
5700 walk(&self.inventory_stacks, &mut counts);
5701 for worn in self.worn.values() {
5702 walk(std::slice::from_ref(worn), &mut counts);
5703 }
5704 let mut out: Vec<_> = counts
5705 .into_iter()
5706 .map(|(template_id, quantity)| {
5707 let label = self
5708 .inventory_hints
5709 .get(&template_id)
5710 .map(|h| h.display_name.clone())
5711 .filter(|n| !n.trim().is_empty())
5712 .unwrap_or_else(|| humanize_template_id(&template_id));
5713 (template_id, quantity, label)
5714 })
5715 .collect();
5716 out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
5717 out
5718 }
5719
5720 pub fn first_farm_seed_template(&self) -> Option<String> {
5722 self.farm_seed_entries()
5723 .into_iter()
5724 .next()
5725 .map(|(id, _, _)| id)
5726 }
5727
5728 pub fn clamp_plant_menu(&mut self) {
5729 let n = self.farm_seed_entries().len();
5730 if n == 0 {
5731 self.plant_menu_index = 0;
5732 self.plant_quantity = 1;
5733 return;
5734 }
5735 self.plant_menu_index = self.plant_menu_index.min(n - 1);
5736 let max_qty = self
5737 .farm_seed_entries()
5738 .get(self.plant_menu_index)
5739 .map(|(_, q, _)| *q)
5740 .unwrap_or(1)
5741 .max(1);
5742 self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
5743 }
5744
5745 pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
5746 let entries = self.farm_seed_entries();
5747 let (id, max, label) = entries.get(self.plant_menu_index)?;
5748 let qty = self.plant_quantity.min(*max).max(1);
5749 Some((id.clone(), qty, label.clone()))
5750 }
5751
5752 pub fn location_context_lines(&self) -> Vec<ContextLine> {
5754 let (px, py) = self.player_position();
5755 let inside = self.effective_inside_building();
5756 let mut lines = Vec::new();
5757
5758 if let Some(kind) = self.terrain_at(px, py) {
5759 lines.push(ContextLine {
5760 on_top: true,
5761 text: format!("Terrain: {}", terrain_kind_label(kind)),
5762 });
5763 }
5764
5765 if let Some(id) = inside.as_ref() {
5766 if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
5767 lines.push(ContextLine {
5768 on_top: true,
5769 text: format!("Inside: {}", b.label),
5770 });
5771 }
5772 }
5773
5774 let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
5775
5776 for node in &self.resource_nodes {
5777 if node.id.starts_with("preview:") {
5778 continue;
5779 }
5780 let dist = distance(px, py, node.x, node.y);
5781 if dist > NEARBY_SCAN_M {
5782 continue;
5783 }
5784 let on_top = dist <= ON_TOP_RADIUS_M;
5785 let prefix = if on_top { "On" } else { "Near" };
5786 let name = resource_node_near_display_label(&node.label);
5787 let action = resource_node_near_action_suffix(node);
5788 nearby.push((
5789 dist,
5790 ContextLine {
5791 on_top,
5792 text: format!("{prefix}: {name} ({dist:.1}m){action}"),
5793 },
5794 ));
5795 }
5796
5797 for drop in &self.ground_drops {
5798 let dist = distance(px, py, drop.x, drop.y);
5799 if dist > INTERACTION_RADIUS_M {
5800 continue;
5801 }
5802 let on_top = dist <= ON_TOP_RADIUS_M;
5803 let name = self.template_display_name(&drop.template_id);
5804 let prefix = if on_top { "On" } else { "Near" };
5805 let qty = if drop.quantity > 1 {
5806 format!(" ×{}", drop.quantity)
5807 } else {
5808 String::new()
5809 };
5810 nearby.push((
5811 dist,
5812 ContextLine {
5813 on_top,
5814 text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
5815 },
5816 ));
5817 }
5818
5819 for c in &self.placed_containers {
5820 if !self.placed_container_in_current_space(c) {
5821 continue;
5822 }
5823 let dist = distance(px, py, c.x, c.y);
5824 if dist > CONTAINER_RANGE_M {
5825 continue;
5826 }
5827 let on_top = dist <= ON_TOP_RADIUS_M;
5828 let name = self.placed_container_public_label(c);
5829 let lock = if c.locked { " [locked]" } else { "" };
5830 let prefix = if on_top { "On" } else { "Near" };
5831 nearby.push((
5832 dist,
5833 ContextLine {
5834 on_top,
5835 text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
5836 },
5837 ));
5838 }
5839
5840 for npc in &self.npcs {
5841 let dist = distance(px, py, npc.x, npc.y);
5842 if dist > NEARBY_SCAN_M {
5843 continue;
5844 }
5845 let on_top = dist <= ON_TOP_RADIUS_M;
5846 let prefix = if on_top { "On" } else { "Near" };
5847 nearby.push((
5848 dist,
5849 ContextLine {
5850 on_top,
5851 text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
5852 },
5853 ));
5854 }
5855
5856 for door in &self.doors {
5857 let dist = distance(px, py, door.x, door.y);
5858 if dist > DOOR_INTERACTION_RADIUS_M {
5859 continue;
5860 }
5861 let building = self
5862 .buildings
5863 .iter()
5864 .find(|b| b.id == door.building_id)
5865 .map(|b| b.label.as_str())
5866 .unwrap_or(door.building_id.as_str());
5867 let player_house = self
5868 .buildings
5869 .iter()
5870 .find(|b| b.id == door.building_id)
5871 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
5872 let action = if inside.is_some() && door.portal.is_some() {
5873 if player_house {
5874 if door.locked {
5875 "locked — l unlock · Enter exit".to_string()
5876 } else if door.open {
5877 "close · Enter exit · l lock".to_string()
5878 } else {
5879 "open · Enter exit · l lock".to_string()
5880 }
5881 } else {
5882 "exit".to_string()
5883 }
5884 } else if player_house {
5885 if door.locked {
5886 "locked — l unlock".to_string()
5887 } else if door.open {
5888 "close · Enter go inside · l lock".to_string()
5889 } else {
5890 "open · l lock".to_string()
5891 }
5892 } else {
5893 "enter".to_string()
5894 };
5895 nearby.push((
5896 dist,
5897 ContextLine {
5898 on_top: dist <= ON_TOP_RADIUS_M,
5899 text: format!("{building} door ({dist:.1}m) — f {action}"),
5900 },
5901 ));
5902 }
5903
5904 if inside.is_none() {
5905 for inter in &self.interactables {
5906 if inter.kind != "quest_board" {
5907 continue;
5908 }
5909 let dist = distance(px, py, inter.x, inter.y);
5910 if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
5911 continue;
5912 }
5913 let on_top = dist <= ON_TOP_RADIUS_M;
5914 let prefix = if on_top { "On" } else { "Near" };
5915 let label = if inter.label.is_empty() {
5916 "Quest board".to_string()
5917 } else {
5918 inter.label.clone()
5919 };
5920 nearby.push((
5921 dist,
5922 ContextLine {
5923 on_top,
5924 text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
5925 },
5926 ));
5927 }
5928 }
5929
5930 if self.in_shallow_water() {
5931 let already = self
5932 .terrain_at(px, py)
5933 .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
5934 if !already {
5935 nearby.push((
5936 0.0,
5937 ContextLine {
5938 on_top: true,
5939 text: "Shallow water — f fill bottle".into(),
5940 },
5941 ));
5942 } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
5943 line.text.push_str(" — f fill bottle");
5944 }
5945 }
5946
5947 if self.claim_mode.is_some() {
5948 nearby.push((
5949 0.0,
5950 ContextLine {
5951 on_top: true,
5952 text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
5953 .into(),
5954 },
5955 ));
5956 } else if let Some(plot) = self.my_plot_under_player() {
5957 let name = plot_public_label(plot);
5958 let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
5959 format!("{name} — f again to sell to crown")
5960 } else {
5961 format!(
5962 "{name} — Shift+c till · p plant · f harvest · B build · l door lock · o farm access · Shift+n rename"
5963 )
5964 };
5965 nearby.push((
5966 0.0,
5967 ContextLine {
5968 on_top: true,
5969 text: prompt,
5970 },
5971 ));
5972 } else if let Some(plot) = self.farmable_plot_under_player() {
5973 let name = plot_public_label(plot);
5974 let disc = if plot.farm_public {
5975 plot.public_tax_discount_bps / 100
5976 } else {
5977 plot.farm_allow
5978 .iter()
5979 .find(|g| Some(g.character_id) == self.character_id)
5980 .map(|g| g.tax_discount_bps / 100)
5981 .unwrap_or(0)
5982 };
5983 nearby.push((
5984 0.0,
5985 ContextLine {
5986 on_top: true,
5987 text: format!(
5988 "{name} (farming · tax −{disc}%) — Shift+c till · p plant · f harvest"
5989 ),
5990 },
5991 ));
5992 } else if let Some(zone) = self.free_property_zone_under_player() {
5993 let label = zone
5994 .label
5995 .as_deref()
5996 .filter(|s| !s.trim().is_empty())
5997 .unwrap_or(zone.id.as_str());
5998 nearby.push((
5999 0.0,
6000 ContextLine {
6001 on_top: true,
6002 text: format!("Claimable land: {label} — k buy plot"),
6003 },
6004 ));
6005 }
6006
6007 for entity in &self.entities {
6008 if entity.id == self.entity_id {
6009 continue;
6010 }
6011 let dist = distance(
6012 px,
6013 py,
6014 entity.transform.position.x,
6015 entity.transform.position.y,
6016 );
6017 if dist > NEARBY_SCAN_M {
6018 continue;
6019 }
6020 let label = if entity.label.is_empty() {
6021 format!("entity {}", entity.id)
6022 } else {
6023 entity.label.clone()
6024 };
6025 nearby.push((
6026 dist,
6027 ContextLine {
6028 on_top: dist <= ON_TOP_RADIUS_M,
6029 text: format!("Near: {label} ({dist:.1}m)"),
6030 },
6031 ));
6032 }
6033
6034 nearby.sort_by(|a, b| {
6035 a.0.partial_cmp(&b.0)
6036 .unwrap_or(std::cmp::Ordering::Equal)
6037 .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
6038 });
6039 lines.extend(nearby.into_iter().map(|(_, l)| l));
6040
6041 if lines.is_empty() {
6042 lines.push(ContextLine {
6043 on_top: false,
6044 text: "(nothing notable nearby)".into(),
6045 });
6046 }
6047
6048 lines
6049 }
6050}
6051
6052#[derive(Debug, Clone)]
6054pub struct ContextLine {
6055 pub on_top: bool,
6056 pub text: String,
6057}
6058
6059const ON_TOP_RADIUS_M: f32 = 0.65;
6060const NEARBY_SCAN_M: f32 = 5.0;
6061
6062pub fn resource_node_near_display_label(label: &str) -> String {
6064 label
6065 .strip_suffix(" (growing)")
6066 .unwrap_or(label)
6067 .to_string()
6068}
6069
6070fn resource_label_looks_like_raw_id(label: &str, id: &str) -> bool {
6071 let t = label.trim();
6072 if t.is_empty() || t == id {
6073 return true;
6074 }
6075 let lower = t.to_ascii_lowercase();
6076 if lower.contains("_copy") {
6077 return true;
6078 }
6079 false
6080}
6081
6082fn humanize_item_template_label(template: &str) -> String {
6083 let base = template.rsplit('/').next().unwrap_or(template).trim();
6084 if base.is_empty() {
6085 return "Resource".into();
6086 }
6087 let stripped = base
6088 .strip_prefix("crop-")
6089 .or_else(|| base.strip_prefix("crop_"))
6090 .unwrap_or(base);
6091 stripped
6092 .split(|c: char| c == '-' || c == '_')
6093 .filter(|p| !p.is_empty())
6094 .map(|p| {
6095 let mut chars = p.chars();
6096 match chars.next() {
6097 Some(c) => format!("{}{}", c.to_ascii_uppercase(), chars.as_str()),
6098 None => String::new(),
6099 }
6100 })
6101 .collect::<Vec<_>>()
6102 .join(" ")
6103}
6104
6105pub fn resource_node_id_suffix(id: &str) -> String {
6107 let chars: Vec<char> = id
6108 .chars()
6109 .rev()
6110 .filter(|c| c.is_ascii_alphanumeric())
6111 .take(4)
6112 .collect();
6113 chars.into_iter().rev().collect()
6114}
6115
6116pub fn resource_node_route_label(node: &flatland_protocol::ResourceNodeView) -> String {
6118 resource_node_route_label_parts(&node.id, &node.label, &node.item_template)
6119}
6120
6121pub fn resource_node_route_label_parts(id: &str, label: &str, item_template: &str) -> String {
6122 let cleaned = resource_node_near_display_label(label);
6123 let friendly = if !resource_label_looks_like_raw_id(&cleaned, id) {
6124 cleaned
6125 } else if !item_template.trim().is_empty() {
6126 humanize_item_template_label(item_template)
6127 } else {
6128 id.to_string()
6129 };
6130 let suffix = resource_node_id_suffix(id);
6131 if suffix.is_empty() {
6132 friendly
6133 } else {
6134 format!("{friendly} ({suffix})")
6135 }
6136}
6137
6138pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
6140 use flatland_protocol::ResourceNodeState;
6141 if node.harvest_off {
6142 return " (decorative)".to_string();
6143 }
6144 if let Some(p) = node.growth_progress {
6145 if p < 1.0 - f32::EPSILON {
6146 let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
6147 return format!(" (growing, {pct}%)");
6148 }
6149 return " — f harvest".to_string();
6150 }
6151 match node.state {
6152 ResourceNodeState::Available => " — f harvest".to_string(),
6153 ResourceNodeState::Harvesting => " (being harvested)".to_string(),
6154 ResourceNodeState::Cooldown => " (depleted)".to_string(),
6155 }
6156}
6157
6158fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
6159 use flatland_protocol::TerrainKindView;
6160 match kind {
6161 TerrainKindView::Grass => "Grass",
6162 TerrainKindView::Dirt => "Dirt",
6163 TerrainKindView::Tilled => "Tilled",
6164 TerrainKindView::Desert => "Desert",
6165 TerrainKindView::Hill => "Hills",
6166 TerrainKindView::Bog => "Bog",
6167 TerrainKindView::Beach => "Beach",
6168 TerrainKindView::ShallowWater => "Shallow water",
6169 TerrainKindView::DeepWater => "Deep water",
6170 TerrainKindView::Trail => "Trail",
6171 TerrainKindView::Road => "Road",
6172 TerrainKindView::Rock => "Rock",
6173 }
6174}
6175
6176fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
6177 crate::world_zones::zone_rects_contain(rects, x, y)
6178}
6179
6180fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
6181 zone.rects
6182 .iter()
6183 .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
6184 .sum()
6185}
6186
6187fn claim_rect_fully_inside_zone(
6188 zone: &flatland_protocol::PropertyZoneView,
6189 x0: f32,
6190 y0: f32,
6191 x1: f32,
6192 y1: f32,
6193) -> bool {
6194 let mut y = y0 + 0.5;
6195 while y < y1 {
6196 let mut x = x0 + 0.5;
6197 while x < x1 {
6198 if !zone_rects_contain(&zone.rects, x, y) {
6199 return false;
6200 }
6201 x += 1.0;
6202 }
6203 y += 1.0;
6204 }
6205 true
6206}
6207
6208fn rects_overlap_half_open(
6209 ax0: f32,
6210 ay0: f32,
6211 ax1: f32,
6212 ay1: f32,
6213 bx0: f32,
6214 by0: f32,
6215 bx1: f32,
6216 by1: f32,
6217) -> bool {
6218 ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
6219}
6220
6221fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
6222 x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
6223}
6224
6225fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
6226 plot_public_label(p)
6227}
6228
6229pub fn plot_public_label(p: &flatland_protocol::PropertyPlotView) -> String {
6231 let zone = p
6232 .zone_label
6233 .as_deref()
6234 .filter(|s| !s.trim().is_empty())
6235 .unwrap_or_else(|| {
6236 if p.property_zone_id.is_empty() {
6237 "Homestead"
6238 } else {
6239 p.property_zone_id.as_str()
6240 }
6241 });
6242 let label = if p.label.trim().is_empty() {
6243 if p.plot_code.trim().is_empty() {
6244 p.plot_id.to_string()[..8.min(p.plot_id.to_string().len())].to_string()
6245 } else {
6246 p.plot_code.clone()
6247 }
6248 } else {
6249 p.label.clone()
6250 };
6251 match p.owner_label.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
6252 Some(owner) => format!("{owner} — {zone} — {label}"),
6253 None => format!("{zone} — {label}"),
6254 }
6255}
6256
6257fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
6259 let a = x0.min(x1).floor();
6260 let b = y0.min(y1).floor();
6261 let mut c = x0.max(x1).ceil();
6262 let mut d = y0.max(y1).ceil();
6263 if (c - a) < 1.0 {
6264 c = a + 1.0;
6265 }
6266 if (d - b) < 1.0 {
6267 d = b + 1.0;
6268 }
6269 (a, b, c, d)
6270}
6271
6272fn humanize_template_id(template_id: &str) -> String {
6273 if looks_like_template_uuid(template_id) {
6275 return "Unknown item".into();
6276 }
6277 template_id
6278 .split('_')
6279 .map(|word| {
6280 let mut chars = word.chars();
6281 match chars.next() {
6282 None => String::new(),
6283 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
6284 }
6285 })
6286 .collect::<Vec<_>>()
6287 .join(" ")
6288}
6289
6290fn looks_like_template_uuid(template_id: &str) -> bool {
6291 let bytes = template_id.as_bytes();
6292 if bytes.len() != 36 {
6293 return false;
6294 }
6295 let is_hex = |b: u8| b.is_ascii_hexdigit();
6296 let groups = [8usize, 4, 4, 4, 12];
6297 let mut i = 0;
6298 for (gi, &len) in groups.iter().enumerate() {
6299 if gi > 0 {
6300 if bytes.get(i) != Some(&b'-') {
6301 return false;
6302 }
6303 i += 1;
6304 }
6305 for _ in 0..len {
6306 if !bytes.get(i).copied().is_some_and(is_hex) {
6307 return false;
6308 }
6309 i += 1;
6310 }
6311 }
6312 true
6313}
6314
6315const HARVEST_RANGE_M: f32 = 1.5;
6317
6318pub struct GameClient<S: PlayConnection> {
6319 session: S,
6320 seq: Seq,
6321 pub state: GameState,
6322 last_move_forward: f32,
6323 last_move_strafe: f32,
6324}
6325
6326impl<S: PlayConnection> GameClient<S> {
6327 pub fn new(session: S) -> Self {
6328 let session_id = session.session_id();
6329 let entity_id = session.entity_id();
6330 let mut client = Self {
6331 session,
6332 seq: 0,
6333 last_move_forward: 0.0,
6334 last_move_strafe: 0.0,
6335 state: GameState {
6336 session_id,
6337 entity_id,
6338 character_id: None,
6339 tick: 0,
6340 chunk_rev: 0,
6341 content_rev: 0,
6342 publish_rev: 0,
6343 entities: Vec::new(),
6344 player: None,
6345 resource_nodes: Vec::new(),
6346 ground_drops: Vec::new(),
6347 placed_containers: Vec::new(),
6348 buildings: Vec::new(),
6349 doors: Vec::new(),
6350 interior_map: None,
6351 npcs: Vec::new(),
6352 blueprints: Vec::new(),
6353 building_materials: Vec::new(),
6354 world_x0: 0.0,
6355 world_y0: 0.0,
6356 world_width_m: 0.0,
6357 world_height_m: 0.0,
6358 terrain_zones: Vec::new(),
6359 z_platforms: Vec::new(),
6360 z_transitions: Vec::new(),
6361 z_bands_outdoor_backup: None,
6362 world_clock: flatland_protocol::WorldClock::default(),
6363 inventory: std::collections::HashMap::new(),
6364 inventory_hints: std::collections::HashMap::new(),
6365 logs: VecDeque::new(),
6366 intents_sent: 0,
6367 ticks_received: 0,
6368 connected: false,
6369 disconnect_reason: None,
6370 show_stats: false,
6371 hud_log_hidden: false,
6372 show_equip_menu: false,
6373 equip_menu_index: 0,
6374 show_craft_menu: false,
6375 show_plot_build_menu: false,
6376 plot_build_focus_wall: true,
6377 plot_build_wall_index: 0,
6378 plot_build_roof_index: 0,
6379 craft_menu_index: 0,
6380 craft_batch_quantity: 1,
6381 show_shop_menu: false,
6382 shop_catalog: None,
6383 bank_panel: None,
6384 bank_menu_index: 0,
6385 bank_ui_mode: BankUiMode::Menu,
6386 storage_panel: None,
6387 market_panel: None,
6388 market_menu_index: 0,
6389 market_filter: String::new(),
6390 market_filter_focused: false,
6391 market_category_filter: None,
6392 market_buy_confirm: None,
6393 market_ui_mode: MarketUiMode::Browse,
6394 storage_menu_index: 0,
6395 storage_ui_mode: StorageUiMode::Menu,
6396 shop_tab: ShopTab::default(),
6397 shop_menu_index: 0,
6398 shop_quantity: 1,
6399 shop_trade_log: VecDeque::new(),
6400 show_npc_verb_menu: false,
6401 npc_verb_target: None,
6402 npc_verb_index: 0,
6403 player_verbs: crate::social::PlayerVerbState::default(),
6404 social_chat: crate::social::SocialChatState::default(),
6405 trade_ui: crate::social::TradeUiState::default(),
6406 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
6407 show_npc_chat: false,
6408 npc_chat: None,
6409 show_inventory_menu: false,
6410 inventory_menu_index: 0,
6411 inventory_tab: InventoryTab::OnPerson,
6412 inventory_filter: String::new(),
6413 inventory_filter_focused: false,
6414 show_move_picker: false,
6415 show_rename_prompt: false,
6416 rename_plot_id: None,
6417 highlighted_plot_id: None,
6418 show_worker_rename: false,
6419 rename_buffer: String::new(),
6420 move_picker_index: 0,
6421 move_picker: None,
6422 show_grant_picker: false,
6423 grant_picker_index: 0,
6424 grant_picker: None,
6425 show_destroy_picker: false,
6426 destroy_confirm_pending: false,
6427 destroy_picker: None,
6428 combat_target: None,
6429 combat_target_label: None,
6430 ground_target: None,
6431 combat_fx: Vec::new(),
6432 property_zones: Vec::new(),
6433 tax_zones: Vec::new(),
6434 growth_zones: Vec::new(),
6435 biome_zones: Vec::new(),
6436 terrain_kind_nav: Vec::new(),
6437 property_plots: Vec::new(),
6438 property_plot_settings: None,
6439 claim_mode: None,
6440 relocate_mode: None,
6441 sell_plot_confirm: None,
6442 sell_plot_armed_at: None,
6443 show_plant_menu: false,
6444 plant_menu_index: 0,
6445 show_farm_access: false,
6446 farm_access_name_draft: String::new(),
6447 farm_access_discount_bps: 0,
6448 farm_access_index: 0,
6449 plant_quantity: 1,
6450 in_combat: false,
6451 auto_attack: true,
6452 combat_has_los: false,
6453 attack_cd_ticks: 0,
6454 gcd_ticks: 0,
6455 weapon_ability_id: "unarmed".into(),
6456 mainhand_template_id: None,
6457 mainhand_label: None,
6458 mainhand_instance_id: None,
6459 offhand_template_id: None,
6460 offhand_label: None,
6461 offhand_instance_id: None,
6462 mainhand_hand_slots: 1,
6463 defense: None,
6464 worn: BTreeMap::new(),
6465 carry_mass: 0.0,
6466 carry_mass_max: 0.0,
6467 encumbrance: flatland_protocol::EncumbranceState::Light,
6468 inventory_stacks: Vec::new(),
6469 keychain_stacks: Vec::new(),
6470 whisper_pouch_stacks: Vec::new(),
6471 combat_target_detail: None,
6472 statuses: Vec::new(),
6473 cast_progress: None,
6474 timed_channel: None,
6475 plot_build_offer: None,
6476 ability_cooldowns: Vec::new(),
6477 blocking_active: false,
6478 max_target_slots: 1,
6479 combat_slots: Vec::new(),
6480 rotation_presets: Vec::new(),
6481 known_abilities: Vec::new(),
6482 ability_meta: std::collections::HashMap::new(),
6483 ability_mastery: std::collections::HashMap::new(),
6484 hotbar: vec![None; 9],
6485 max_abilities_per_rotation: 0,
6486 show_loadout_menu: false,
6487 show_keychain_menu: false,
6488 keychain_menu_index: 0,
6489 show_rotation_editor: false,
6490 loadout_menu_index: 0,
6491 loadout_hotbar_slot: 1,
6492 loadout_ability_index: 0,
6493 loadout_focus_presets: false,
6494 rotation_editor: RotationEditorState::default(),
6495 harvest_in_progress: false,
6496 harvest_started_at: None,
6497 pending_craft_ack: None,
6498 pending_worker_job_ack: None,
6499 attending_worker_instance_id: None,
6500 quest_log: Vec::new(),
6501 interactables: Vec::new(),
6502 ledger: None,
6503 career: None,
6504 character_sheet_tab: CharacterSheetTab::Character,
6505 ledger_period: LedgerPeriod::Day,
6506 show_quest_offer: false,
6507 pending_quest_offer: None,
6508 show_quest_menu: false,
6509 quest_menu_index: 0,
6510 quest_withdraw_confirm: false,
6511 hired_workers: Vec::new(),
6512 show_workers_menu: false,
6513 workers_menu_index: 0,
6514 workers_menu_compact: false,
6515 worker_step_display: BTreeMap::new(),
6516 worker_error_display: BTreeMap::new(),
6517 show_worker_give_picker: false,
6518 worker_give_picker_index: 0,
6519 worker_give_picker: None,
6520 show_worker_give_target_picker: false,
6521 worker_give_target_picker_index: 0,
6522 worker_give_target_picker: None,
6523 show_worker_take_picker: false,
6524 worker_take_picker_index: 0,
6525 worker_take_picker: None,
6526 show_worker_teach_picker: false,
6527 worker_teach_picker_index: 0,
6528 worker_teach_picker: None,
6529 worker_route_editor: None,
6530 progression_curve: None,
6531 },
6532 };
6533 client.state.apply_client_ui_prefs();
6534 client
6535 }
6536
6537 pub fn entity_id(&self) -> EntityId {
6538 self.state.entity_id
6539 }
6540
6541 pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
6542 if self.state.connected {
6543 return Ok(());
6544 }
6545
6546 loop {
6547 match self.session.next_event().await {
6548 Some(SessionEvent::Welcome {
6549 session_id,
6550 entity_id,
6551 snapshot,
6552 }) => {
6553 self.state
6554 .restore_from_welcome(session_id, entity_id, &snapshot);
6555 self.state.apply_client_ui_prefs();
6556 self.state.push_log(format!(
6557 "Connected — session {session_id}, entity {entity_id}"
6558 ));
6559 return Ok(());
6560 }
6561 Some(SessionEvent::Disconnected { .. }) => {
6562 anyhow::bail!("disconnected before welcome");
6563 }
6564 Some(_) => continue,
6565 None => anyhow::bail!("session closed before welcome"),
6566 }
6567 }
6568 }
6569
6570 pub fn drain_events(&mut self) {
6572 while let Some(event) = self.session.try_next_event() {
6573 if self.handle_event_sync(event).is_err() {
6574 break;
6575 }
6576 }
6577 }
6578
6579 pub async fn next_event(&mut self) -> Option<SessionEvent> {
6581 self.session.next_event().await
6582 }
6583
6584 pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6585 self.handle_event_sync(event)
6586 }
6587
6588 fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6589 match event {
6590 SessionEvent::Welcome {
6591 session_id,
6592 entity_id,
6593 snapshot,
6594 } => {
6595 let resumed = self.state.connected;
6596 self.state
6597 .restore_from_welcome(session_id, entity_id, &snapshot);
6598 if resumed {
6599 self.state.push_log(format!(
6600 "Session restored — session {session_id}, entity {entity_id}"
6601 ));
6602 }
6603 }
6604 SessionEvent::ContentUpdated { snapshot } => {
6605 self.state
6606 .apply_snapshot_fields(&snapshot, self.state.entity_id);
6607 self.state.push_log(format!(
6608 "World updated (content rev {})",
6609 snapshot.content_rev
6610 ));
6611 }
6612 SessionEvent::Tick(delta) => {
6613 self.state.apply_tick_fields(&delta, self.state.entity_id);
6614 self.state.ticks_received += 1;
6615 }
6616 SessionEvent::IntentAck {
6617 entity_id,
6618 seq,
6619 tick,
6620 } => {
6621 crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
6622 if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
6623 if *craft_seq == seq {
6624 let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
6625 if batches > 1 {
6626 self.state.push_log(format!("Crafting {label} ×{batches}…"));
6627 } else {
6628 self.state.push_log(format!("Crafting {label}…"));
6629 }
6630 }
6631 }
6632 if self
6633 .state
6634 .pending_worker_job_ack
6635 .as_ref()
6636 .is_some_and(|p| p.seq == seq)
6637 {
6638 let pending = self.state.pending_worker_job_ack.take().unwrap();
6639 if pending.idle {
6640 self.state.push_log(format!(
6641 "Route cleared for {} — worker idle",
6642 pending.worker_label
6643 ));
6644 } else {
6645 self.state.push_log(format!(
6646 "Route saved for {} — {} stop(s), job loop active",
6647 pending.worker_label, pending.stop_count
6648 ));
6649 }
6650 if self
6651 .state
6652 .worker_route_editor
6653 .as_ref()
6654 .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
6655 {
6656 self.close_worker_route_editor();
6657 }
6658 }
6659 }
6660 SessionEvent::Chat(msg) => {
6661 let label = match msg.channel {
6662 flatland_protocol::ChatChannel::Nearby => "nearby",
6663 flatland_protocol::ChatChannel::Direct => "speak",
6664 flatland_protocol::ChatChannel::Whisper => "whisper",
6665 flatland_protocol::ChatChannel::WhisperStone => "stone",
6666 };
6667 let clarity = match msg.clarity {
6668 flatland_protocol::ChatClarity::Clear => "",
6669 flatland_protocol::ChatClarity::Partial => "~",
6670 flatland_protocol::ChatClarity::Heavy => "…",
6671 };
6672 self.state.push_log(format!(
6673 "[{label}{clarity}] {}: {}",
6674 msg.from_name, msg.text
6675 ));
6676 let now_ms = std::time::SystemTime::now()
6677 .duration_since(std::time::UNIX_EPOCH)
6678 .map(|d| d.as_millis() as u64)
6679 .unwrap_or(0);
6680 self.state
6681 .social_chat
6682 .note_speech(&msg, self.state.entity_id, now_ms);
6683 self.state
6684 .social_chat
6685 .push(crate::social::ChatLogEntry::from_message(
6686 msg,
6687 self.state.entity_id,
6688 ));
6689 }
6690 SessionEvent::TradeOpened(panel) => {
6691 self.state.social_chat.pending_trade = None;
6692 let peer = panel.peer_name.clone();
6693 self.state.trade_ui.open(panel);
6694 self.state
6695 .social_chat
6696 .push_system(format!("Trade open with {peer} — p present · r ready · Esc cancel"));
6697 self.state
6698 .social_chat
6699 .push_cue(crate::social::AudioCue::TradeOpened);
6700 }
6701 SessionEvent::TradeClosed { reason } => {
6702 self.state.push_log(reason.clone());
6703 self.state.social_chat.push_system(reason);
6704 self.state.trade_ui.close();
6705 }
6706 SessionEvent::HarvestResult(result) => {
6707 self.state.clear_harvest_state();
6708 crate::harvest_trace!(
6709 entity_id = self.state.entity_id,
6710 node_id = %result.node_id,
6711 template = %result.item_template,
6712 quantity = result.quantity,
6713 client_tick = self.state.tick,
6714 "client applied harvest result"
6715 );
6716 let msg = if result.quantity == 0 {
6717 format!(
6718 "Harvested {} x0 — nothing dropped (loot table rolled empty)",
6719 result.item_template
6720 )
6721 } else {
6722 format!(
6723 "Harvested {} x{} (on the ground — press P to pick up)",
6724 result.item_template, result.quantity
6725 )
6726 };
6727 self.state.push_log(msg);
6728 }
6729 SessionEvent::CraftResult(result) => {
6730 for stack in &result.consumed {
6731 if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
6732 *qty = qty.saturating_sub(stack.quantity);
6733 if *qty == 0 {
6734 self.state.inventory.remove(&stack.template_id);
6735 }
6736 }
6737 }
6738 for stack in &result.outputs {
6739 *self
6740 .state
6741 .inventory
6742 .entry(stack.template_id.clone())
6743 .or_insert(0) += stack.quantity;
6744 }
6745 if let Some(output) = result.outputs.first() {
6746 if result.batch_total > 1 {
6747 self.state.push_log(format!(
6748 "Crafted {} x{} ({}/{})",
6749 output.template_id,
6750 output.quantity,
6751 result.batch_index,
6752 result.batch_total
6753 ));
6754 } else {
6755 self.state.push_log(format!(
6756 "Crafted {} x{}",
6757 output.template_id, output.quantity
6758 ));
6759 }
6760 } else {
6761 self.state
6762 .push_log(format!("Craft finished: {}", result.blueprint_id));
6763 }
6764 }
6765 SessionEvent::Death(notice) => {
6766 self.state.clear_harvest_state();
6767 self.state.push_log(notice.message.clone());
6768 self.state.push_log(format!(
6769 "Respawned at ({:.1}, {:.1})",
6770 notice.respawn_x, notice.respawn_y
6771 ));
6772 }
6773 SessionEvent::Interaction(notice) => {
6774 if notice.message.starts_with("Harvest failed:") {
6775 self.state.clear_harvest_state();
6776 }
6777 if notice.message.starts_with("Can't do that:") {
6778 self.state.pending_craft_ack = None;
6779 if let Some(pending) = self.state.pending_worker_job_ack.take() {
6780 if let Some(w) = self
6781 .state
6782 .hired_workers
6783 .iter_mut()
6784 .find(|w| w.instance_id == pending.worker_instance_id)
6785 {
6786 w.route = pending.prev_route;
6787 w.mode = pending.prev_mode;
6788 w.step_label = pending.prev_step_label;
6789 w.last_error = pending.prev_last_error;
6790 }
6791 let reason = notice
6792 .message
6793 .strip_prefix("Can't do that:")
6794 .unwrap_or(¬ice.message)
6795 .trim();
6796 self.state.push_log(format!(
6797 "Route save failed for {}: {reason}",
6798 pending.worker_label
6799 ));
6800 }
6801 let reason = notice
6802 .message
6803 .strip_prefix("Can't do that:")
6804 .unwrap_or(¬ice.message)
6805 .trim();
6806 if reason.contains("already tilled") {
6807 if let Some(plot) = self.state.my_plot_under_player() {
6808 self.state.sell_plot_confirm = Some(plot.plot_id);
6809 self.state.sell_plot_armed_at = Some(Instant::now());
6810 }
6811 }
6812 }
6813 if notice.message.starts_with("Cast failed:") {
6814 self.state.cast_progress = None;
6815 }
6816 if notice.message.contains("slain the") {
6817 self.state.combat_target = None;
6818 self.state.combat_target_label = None;
6819 }
6820 if notice.message.contains("wants to trade") {
6822 if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
6823 let from_name = notice
6824 .message
6825 .split(" wants to trade")
6826 .next()
6827 .unwrap_or("Player")
6828 .to_string();
6829 self.state.social_chat.pending_trade =
6830 Some(crate::social::PendingTradeRequest {
6831 from_entity,
6832 from_name: from_name.clone(),
6833 });
6834 self.state.social_chat.push_system(format!(
6835 "{from_name} wants to trade — [Y] accept · [N] decline"
6836 ));
6837 self.state
6838 .social_chat
6839 .push_cue(crate::social::AudioCue::TradeOffer);
6840 }
6841 }
6842 if notice.message.starts_with("trade request declined") {
6843 self.state
6844 .social_chat
6845 .push_system(notice.message.clone());
6846 self.state
6847 .social_chat
6848 .push_cue(crate::social::AudioCue::TradeDeclined);
6849 }
6850 self.state.apply_interaction_notice(¬ice);
6851 self.state.push_log(notice.message.clone());
6852 }
6853 SessionEvent::ShopOpened(catalog) => {
6854 self.state.apply_shop_catalog(catalog);
6855 }
6856 SessionEvent::BankOpened(panel) => {
6857 self.state.apply_bank_panel(panel);
6858 }
6859 SessionEvent::StorageOpened(panel) => {
6860 self.state.apply_storage_panel(panel);
6861 }
6862 SessionEvent::MarketOpened(panel) => {
6863 self.state.apply_market_panel(panel);
6864 }
6865 SessionEvent::NpcTalkOpened(opened) => {
6866 self.state.show_npc_verb_menu = false;
6867 if self.state.npc_verb_target.is_none() {
6868 self.state.npc_verb_target = Some(opened.npc_id.clone());
6869 }
6870 let label = opened.npc_label.clone();
6871 let banner = if !opened.trade_allowed {
6872 Some("Trade is unavailable right now.".to_string())
6873 } else {
6874 None
6875 };
6876 self.state.show_npc_chat = true;
6877 self.state.npc_chat = Some(NpcChatState {
6878 npc_id: opened.npc_id,
6879 npc_label: opened.npc_label,
6880 lines: if opened.greeting.is_empty() {
6881 vec![]
6882 } else {
6883 vec![format!("{label}: {}", opened.greeting)]
6884 },
6885 input: String::new(),
6886 pending: opened.greeting.is_empty(),
6887 talk_depth: opened.talk_depth,
6888 trade_allowed: opened.trade_allowed,
6889 banner,
6890 });
6891 }
6892 SessionEvent::NpcTalkPending(_) => {
6893 if let Some(chat) = self.state.npc_chat.as_mut() {
6894 chat.pending = true;
6895 }
6896 }
6897 SessionEvent::NpcTalkReply(reply) => {
6898 if let Some(chat) = self.state.npc_chat.as_mut() {
6899 if chat.npc_id == reply.npc_id {
6900 chat.pending = false;
6901 if reply.trade_disabled {
6902 chat.trade_allowed = false;
6903 chat.banner = Some("Trade is unavailable right now.".to_string());
6904 }
6905 if reply.wind_down {
6906 chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
6907 if chat.banner.is_none() {
6908 chat.banner =
6909 Some("They're wrapping up — keep it brief.".to_string());
6910 }
6911 }
6912 chat.lines
6913 .push(format!("{}: {}", chat.npc_label, reply.line));
6914 }
6915 }
6916 }
6917 SessionEvent::NpcTalkClosed(closed) => {
6918 if self
6919 .state
6920 .npc_chat
6921 .as_ref()
6922 .is_some_and(|c| c.npc_id == closed.npc_id)
6923 {
6924 self.state.show_npc_chat = false;
6925 self.state.npc_chat = None;
6926 }
6927 }
6928 SessionEvent::NpcTalkError(err) => {
6929 self.state.push_log(format!("Talk failed: {}", err.reason));
6930 if let Some(chat) = self.state.npc_chat.as_mut() {
6931 chat.pending = false;
6932 }
6933 }
6934 SessionEvent::UseResult(result) => {
6935 if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
6938 *qty = qty.saturating_sub(1);
6939 if *qty == 0 {
6940 self.state.inventory.remove(&result.template_id);
6941 }
6942 }
6943 }
6944 SessionEvent::QuestOffer(offer) => {
6945 self.state.pending_quest_offer = Some(offer.clone());
6946 self.state.show_quest_offer = true;
6947 self.state
6948 .push_log(format!("Quest offered: {}", offer.title));
6949 }
6950 SessionEvent::QuestAccepted(notice) => {
6951 self.state.show_quest_offer = false;
6952 self.state.pending_quest_offer = None;
6953 self.state.push_log(notice.message);
6954 }
6955 SessionEvent::QuestWithdrawn(notice) => {
6956 self.state.show_quest_menu = false;
6957 self.state.quest_withdraw_confirm = false;
6958 self.state.push_log(notice.message);
6959 }
6960 SessionEvent::QuestStepCompleted(notice) => {
6961 self.state.push_log(notice.message);
6962 }
6963 SessionEvent::QuestCompleted(notice) => {
6964 self.state.push_log(notice.message);
6965 }
6966 SessionEvent::Disconnected { reason } => {
6967 self.state.clear_harvest_state();
6968 self.state.connected = false;
6969 self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
6970 if let Some(r) = &self.state.disconnect_reason {
6971 self.state.push_log(format!("Disconnected: {r}"));
6972 } else {
6973 self.state.push_log("Disconnected from server");
6974 }
6975 }
6976 }
6977 Ok(())
6978 }
6979
6980 pub fn is_connected(&self) -> bool {
6981 self.state.connected
6982 }
6983
6984 pub fn close_overlays(&mut self) {
6985 self.state.show_stats = false;
6986 self.state.show_craft_menu = false;
6987 self.state.show_plot_build_menu = false;
6988 self.state.show_shop_menu = false;
6989 self.state.shop_catalog = None;
6990 self.state.show_npc_verb_menu = false;
6991 self.state.npc_verb_target = None;
6992 self.state.show_npc_chat = false;
6993 self.state.npc_chat = None;
6994 self.state.show_inventory_menu = false;
6995 self.state.show_loadout_menu = false;
6996 self.state.show_rotation_editor = false;
6997 self.state.rotation_editor.reset();
6998 self.state.show_rename_prompt = false;
6999 self.state.show_worker_rename = false;
7000 self.state.rename_buffer.clear();
7001 self.state.show_move_picker = false;
7002 self.state.move_picker = None;
7003 self.state.show_destroy_picker = false;
7004 self.state.destroy_confirm_pending = false;
7005 self.state.destroy_picker = None;
7006 self.state.show_quest_offer = false;
7007 self.state.pending_quest_offer = None;
7008 self.state.show_quest_menu = false;
7009 self.state.quest_withdraw_confirm = false;
7010 self.state.show_workers_menu = false;
7011 self.close_worker_give_picker();
7012 self.close_worker_give_target_picker();
7013 self.close_worker_take_picker();
7014 self.close_worker_teach_picker();
7015 self.state.worker_route_editor = None;
7016 self.state.claim_mode = None;
7017 self.state.relocate_mode = None;
7018 self.state.sell_plot_confirm = None;
7019 self.state.sell_plot_armed_at = None;
7020 self.close_farm_access_panel();
7021 if self.state.show_plant_menu {
7022 self.close_plant_menu();
7023 }
7024 }
7025
7026 pub fn back_on_esc(&mut self) -> bool {
7028 if self.state.social_chat.composer_open() {
7029 self.state.social_chat.close_composer();
7030 return true;
7031 }
7032 if self.state.player_verbs.open {
7033 self.state.player_verbs.close();
7034 return true;
7035 }
7036 if self.state.whisper_pouch_ui.open {
7037 self.state.whisper_pouch_ui.open = false;
7038 return true;
7039 }
7040 if self.state.trade_ui.panel.is_some() {
7041 self.state.trade_ui.close();
7043 return true;
7044 }
7045 if self.state.show_rename_prompt {
7046 self.cancel_rename_prompt();
7047 return true;
7048 }
7049 if self.state.show_worker_rename {
7050 self.cancel_worker_rename();
7051 return true;
7052 }
7053 if self.state.show_destroy_picker {
7054 if self.state.destroy_confirm_pending {
7055 self.cancel_destroy_confirm();
7056 } else {
7057 self.close_destroy_picker();
7058 }
7059 return true;
7060 }
7061 if self.state.claim_mode.is_some() {
7062 self.cancel_claim_mode();
7063 return true;
7064 }
7065 if self.state.relocate_mode.is_some() {
7066 self.cancel_relocate_mode();
7067 return true;
7068 }
7069 if self.state.show_plant_menu {
7070 self.close_plant_menu();
7071 return true;
7072 }
7073 if self.state.show_farm_access {
7074 self.close_farm_access_panel();
7075 return true;
7076 }
7077 if self.state.sell_plot_confirm.is_some() {
7078 self.state.sell_plot_confirm = None;
7079 self.state.sell_plot_armed_at = None;
7080 self.state.push_log("Sell cancelled");
7081 return true;
7082 }
7083 if self.state.show_move_picker {
7084 self.close_move_picker();
7085 return true;
7086 }
7087 if self.state.show_rotation_editor {
7088 match self.state.rotation_editor.mode {
7089 RotationEditorMode::List => {
7090 self.state.show_rotation_editor = false;
7091 self.state.rotation_editor.reset();
7092 }
7093 RotationEditorMode::EditLabel => {
7094 self.state.rotation_editor.label_buffer.clear();
7095 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
7096 }
7097 RotationEditorMode::PickAbility => {
7098 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
7099 }
7100 RotationEditorMode::EditSequence => {
7101 self.state.rotation_editor.draft = None;
7102 self.state.rotation_editor.mode = RotationEditorMode::List;
7103 }
7104 }
7105 return true;
7106 }
7107 if self.state.show_inventory_menu {
7108 self.close_inventory_menu();
7109 return true;
7110 }
7111 if self.state.show_craft_menu {
7112 self.close_craft_menu();
7113 return true;
7114 }
7115 if self.state.show_plot_build_menu {
7116 self.close_plot_build_menu();
7117 return true;
7118 }
7119 if self.state.show_keychain_menu {
7120 self.close_keychain_menu();
7121 return true;
7122 }
7123 if self.state.show_quest_offer {
7124 self.quest_offer_decline();
7125 return true;
7126 }
7127 if self.state.show_shop_menu {
7128 return false;
7130 }
7131 if self.state.bank_panel.is_some() {
7132 return false;
7133 }
7134 if self.state.storage_panel.is_some() {
7135 return false;
7136 }
7137 if self.state.market_panel.is_some() {
7138 return false;
7139 }
7140 if self.state.show_npc_chat {
7141 return false;
7143 }
7144 if self.state.show_npc_verb_menu {
7145 self.state.show_npc_verb_menu = false;
7146 self.state.npc_verb_target = None;
7147 return true;
7148 }
7149 if self.state.show_quest_menu {
7150 if self.state.quest_withdraw_confirm {
7151 self.state.quest_withdraw_confirm = false;
7152 } else {
7153 self.state.show_quest_menu = false;
7154 }
7155 return true;
7156 }
7157 if self.state.worker_route_editor.is_some() {
7158 if self.re_at_root_sheet() {
7160 let reopen = self.state.attending_worker_instance_id.clone();
7161 self.close_worker_route_editor();
7162 if let Some(id) = reopen {
7163 if let Some(idx) = self
7164 .state
7165 .hired_workers
7166 .iter()
7167 .position(|w| w.instance_id == id)
7168 {
7169 self.state.workers_menu_index = idx;
7170 self.state.show_workers_menu = true;
7171 }
7172 }
7173 } else {
7174 self.re_sheet_back();
7175 }
7176 return true;
7177 }
7178 if self.state.show_worker_give_picker {
7179 self.close_worker_give_picker();
7180 return true;
7181 }
7182 if self.state.show_worker_give_target_picker {
7183 self.close_worker_give_target_picker();
7184 return true;
7185 }
7186 if self.state.show_worker_take_picker {
7187 self.close_worker_take_picker();
7188 return true;
7189 }
7190 if self.state.show_worker_teach_picker {
7191 self.close_worker_teach_picker();
7192 return true;
7193 }
7194 if self.state.show_workers_menu {
7195 self.close_workers_menu_ui();
7196 return true;
7197 }
7198 if self.state.show_loadout_menu {
7199 self.state.show_loadout_menu = false;
7200 return true;
7201 }
7202 if self.state.show_stats {
7203 self.state.show_stats = false;
7204 return true;
7205 }
7206 if self.state.show_equip_menu {
7207 self.state.show_equip_menu = false;
7208 return true;
7209 }
7210 false
7211 }
7212
7213 pub fn toggle_stats(&mut self) {
7214 self.state.show_stats = !self.state.show_stats;
7215 if self.state.show_stats {
7216 self.state.character_sheet_tab = CharacterSheetTab::Character;
7217 self.state.show_craft_menu = false;
7218 self.state.show_shop_menu = false;
7219 self.state.shop_catalog = None;
7220 self.state.show_inventory_menu = false;
7221 self.state.show_equip_menu = false;
7222 }
7223 }
7224
7225 pub fn toggle_equip_menu(&mut self) {
7226 self.state.show_equip_menu = !self.state.show_equip_menu;
7227 if self.state.show_equip_menu {
7228 self.state.show_stats = false;
7229 self.state.show_craft_menu = false;
7230 self.state.show_shop_menu = false;
7231 self.state.shop_catalog = None;
7232 self.state.show_inventory_menu = false;
7233 self.state.show_loadout_menu = false;
7234 }
7235 }
7236
7237 pub fn cycle_character_sheet_tab(&mut self) {
7238 if self.state.show_stats {
7239 self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
7240 }
7241 }
7242
7243 pub fn set_ledger_period_digit(&mut self, c: char) {
7244 if self.state.show_stats {
7245 if let Some(p) = LedgerPeriod::from_digit(c) {
7246 self.state.ledger_period = p;
7247 self.state.character_sheet_tab = CharacterSheetTab::Ledger;
7248 }
7249 }
7250 }
7251
7252 pub fn cycle_ledger_period(&mut self) {
7253 if self.state.show_stats
7254 && self.state.character_sheet_tab == CharacterSheetTab::Ledger
7255 {
7256 self.state.ledger_period = self.state.ledger_period.cycle();
7257 }
7258 }
7259
7260 pub fn open_inventory_menu(&mut self) {
7261 self.state.show_inventory_menu = true;
7262 self.state.show_craft_menu = false;
7263 self.state.show_shop_menu = false;
7264 self.state.shop_catalog = None;
7265 self.state.show_stats = false;
7266 self.state.show_move_picker = false;
7267 self.state.move_picker = None;
7268 self.state.show_destroy_picker = false;
7269 self.state.destroy_confirm_pending = false;
7270 self.state.destroy_picker = None;
7271 self.state.show_rename_prompt = false;
7272 self.state.rename_plot_id = None;
7273 self.state.rename_buffer.clear();
7274 self.state.inventory_filter_focused = false;
7275 self.state.clamp_inventory_indices();
7276 }
7277
7278 pub fn close_inventory_menu(&mut self) {
7279 self.state.show_inventory_menu = false;
7280 self.state.show_move_picker = false;
7281 self.state.move_picker = None;
7282 self.close_grant_picker();
7283 self.state.show_destroy_picker = false;
7284 self.state.destroy_confirm_pending = false;
7285 self.state.destroy_picker = None;
7286 self.state.show_rename_prompt = false;
7287 self.state.rename_plot_id = None;
7288 self.state.rename_buffer.clear();
7289 self.state.inventory_filter_focused = false;
7290 }
7291
7292 pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
7293 let Some(row) = self.state.inventory_selected_row() else {
7294 anyhow::bail!("inventory empty");
7295 };
7296 if GameState::is_property_deed_template(&row.stack.template_id) {
7297 let Some(plot_id) = GameState::deed_plot_id(&row.stack) else {
7298 anyhow::bail!("deed has no plot id");
7299 };
7300 let label = self
7301 .state
7302 .property_plots
7303 .iter()
7304 .find(|p| p.plot_id == plot_id)
7305 .map(|p| {
7306 if p.label.trim().is_empty() {
7307 p.plot_code.clone()
7308 } else {
7309 p.label.clone()
7310 }
7311 })
7312 .unwrap_or_else(|| {
7313 row.stack
7314 .display_name
7315 .clone()
7316 .unwrap_or_else(|| "plot".into())
7317 });
7318 self.state.rename_buffer = label;
7319 self.state.rename_plot_id = Some(plot_id);
7320 self.state.highlighted_plot_id = Some(plot_id);
7321 self.state.show_rename_prompt = true;
7322 self.state.show_worker_rename = false;
7323 self.state.show_move_picker = false;
7324 self.state.show_destroy_picker = false;
7325 self.state.destroy_confirm_pending = false;
7326 return Ok(());
7327 }
7328 if !self.state.row_is_renameable_container(&row) {
7329 anyhow::bail!("only storage containers or deeds can be renamed");
7330 }
7331 let current = row
7332 .stack
7333 .display_name
7334 .clone()
7335 .unwrap_or_else(|| row.stack.template_id.clone());
7336 self.state.rename_buffer = current;
7337 self.state.rename_plot_id = None;
7338 self.state.show_rename_prompt = true;
7339 self.state.show_worker_rename = false;
7340 self.state.show_move_picker = false;
7341 self.state.show_destroy_picker = false;
7342 self.state.destroy_confirm_pending = false;
7343 Ok(())
7344 }
7345
7346 pub fn open_plot_rename_under_player(&mut self) -> anyhow::Result<()> {
7348 let Some(plot) = self.state.my_plot_under_player().cloned() else {
7349 anyhow::bail!("stand on your plot to rename it");
7350 };
7351 let label = if plot.label.trim().is_empty() {
7352 plot.plot_code.clone()
7353 } else {
7354 plot.label.clone()
7355 };
7356 self.state.rename_buffer = label;
7357 self.state.rename_plot_id = Some(plot.plot_id);
7358 self.state.highlighted_plot_id = Some(plot.plot_id);
7359 self.state.show_rename_prompt = true;
7360 self.state.show_worker_rename = false;
7361 Ok(())
7362 }
7363
7364 pub fn cancel_rename_prompt(&mut self) {
7365 self.state.show_rename_prompt = false;
7366 self.state.rename_plot_id = None;
7367 self.state.rename_buffer.clear();
7368 }
7369
7370 pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
7371 let name = self.state.rename_buffer.trim().to_string();
7372 if name.is_empty() {
7373 anyhow::bail!("name cannot be empty");
7374 }
7375 if let Some(plot_id) = self.state.rename_plot_id {
7376 if name.chars().count() > 48 {
7377 anyhow::bail!("label must be 1–48 characters");
7378 }
7379 self.seq += 1;
7380 self.session
7381 .submit_intent(Intent::RenamePropertyPlot {
7382 entity_id: self.state.entity_id,
7383 plot_id,
7384 label: name,
7385 seq: self.seq,
7386 })
7387 .await?;
7388 self.state.intents_sent += 1;
7389 self.state.show_rename_prompt = false;
7390 self.state.rename_plot_id = None;
7391 self.state.rename_buffer.clear();
7392 return Ok(());
7393 }
7394 if name.chars().count() > 32 {
7395 anyhow::bail!("name must be 1–32 characters");
7396 }
7397 let Some(row) = self.state.inventory_selected_row() else {
7398 anyhow::bail!("inventory empty");
7399 };
7400 let Some(instance_id) = row.stack.item_instance_id else {
7401 anyhow::bail!("item has no instance id");
7402 };
7403 self.seq += 1;
7404 self.session
7405 .submit_intent(Intent::RenameContainer {
7406 entity_id: self.state.entity_id,
7407 item_instance_id: instance_id,
7408 location: row.from.clone(),
7409 name,
7410 seq: self.seq,
7411 })
7412 .await?;
7413 self.state.intents_sent += 1;
7414 self.state.show_rename_prompt = false;
7415 self.state.rename_buffer.clear();
7416 Ok(())
7417 }
7418
7419 pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
7420 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
7421 anyhow::bail!("no worker selected");
7422 };
7423 self.state.rename_buffer = worker.label.clone();
7424 self.state.show_worker_rename = true;
7425 self.state.show_rename_prompt = false;
7426 Ok(())
7427 }
7428
7429 pub fn cancel_worker_rename(&mut self) {
7430 self.state.show_worker_rename = false;
7431 self.state.rename_buffer.clear();
7432 }
7433
7434 pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
7435 let name = self.state.rename_buffer.trim().to_string();
7436 if name.is_empty() {
7437 anyhow::bail!("name cannot be empty");
7438 }
7439 if name.chars().count() > 32 {
7440 anyhow::bail!("name must be 1–32 characters");
7441 }
7442 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
7443 anyhow::bail!("no worker selected");
7444 };
7445 let worker_instance_id = worker.instance_id.clone();
7446 self.seq += 1;
7447 self.session
7448 .submit_intent(Intent::RenameHiredWorker {
7449 entity_id: self.state.entity_id,
7450 worker_instance_id: worker_instance_id.clone(),
7451 name: name.clone(),
7452 seq: self.seq,
7453 })
7454 .await?;
7455 self.state.intents_sent += 1;
7456 if let Some(w) = self
7457 .state
7458 .hired_workers
7459 .iter_mut()
7460 .find(|w| w.instance_id == worker_instance_id)
7461 {
7462 w.label = name.clone();
7463 }
7464 if let Some(ed) = self.state.worker_route_editor.as_mut() {
7465 if ed.worker_instance_id == worker_instance_id {
7466 ed.worker_label = name.clone();
7467 }
7468 }
7469 self.state.show_worker_rename = false;
7470 self.state.rename_buffer.clear();
7471 self.state.push_log(format!("Renamed worker to \"{name}\""));
7472 Ok(())
7473 }
7474
7475 pub fn toggle_inventory_menu(&mut self) {
7476 if self.state.show_inventory_menu {
7477 self.close_inventory_menu();
7478 } else {
7479 self.open_inventory_menu();
7480 }
7481 }
7482
7483 pub fn inventory_menu_move(&mut self, delta: i32) {
7485 if self.state.show_grant_picker {
7486 let Some(picker) = self.state.grant_picker.as_ref() else {
7487 return;
7488 };
7489 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7490 let filter = picker.filter.clone();
7491 let n = labels.len();
7492 if n == 0 {
7493 return;
7494 }
7495 self.state.grant_picker_index = step_filtered_index(
7496 self.state.grant_picker_index,
7497 delta,
7498 n,
7499 |i| list_label_matches(&labels[i], &filter),
7500 );
7501 return;
7502 }
7503 if self.state.show_move_picker {
7504 let Some(picker) = self.state.move_picker.as_ref() else {
7505 return;
7506 };
7507 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7508 let filter = picker.filter.clone();
7509 let n = labels.len();
7510 if n == 0 {
7511 return;
7512 }
7513 self.state.move_picker_index = step_filtered_index(
7514 self.state.move_picker_index,
7515 delta,
7516 n,
7517 |i| list_label_matches(&labels[i], &filter),
7518 );
7519 self.state.clamp_move_picker_quantity();
7520 return;
7521 }
7522 let n = self.state.inventory_selectable_rows().len();
7523 if n == 0 {
7524 return;
7525 }
7526 let idx = self.state.inventory_menu_index as i32;
7527 self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
7528 }
7529
7530 pub fn inventory_menu_page(&mut self, pages: i32) {
7532 if self.state.show_grant_picker {
7533 let Some(picker) = self.state.grant_picker.as_ref() else {
7534 return;
7535 };
7536 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7537 let filter = picker.filter.clone();
7538 let n = labels.len();
7539 self.state.grant_picker_index = page_filtered_index(
7540 self.state.grant_picker_index,
7541 pages,
7542 n,
7543 |i| list_label_matches(&labels[i], &filter),
7544 );
7545 return;
7546 }
7547 if self.state.show_move_picker {
7548 let Some(picker) = self.state.move_picker.as_ref() else {
7549 return;
7550 };
7551 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7552 let filter = picker.filter.clone();
7553 let n = labels.len();
7554 self.state.move_picker_index = page_filtered_index(
7555 self.state.move_picker_index,
7556 pages,
7557 n,
7558 |i| list_label_matches(&labels[i], &filter),
7559 );
7560 self.state.clamp_move_picker_quantity();
7561 return;
7562 }
7563 let n = self.state.inventory_selectable_rows().len();
7564 self.state.inventory_menu_index =
7565 page_list_index(self.state.inventory_menu_index, pages, n);
7566 }
7567
7568 pub fn cycle_inventory_tab(&mut self, forward: bool) {
7569 if self.state.show_move_picker
7570 || self.state.show_grant_picker
7571 || self.state.show_destroy_picker
7572 || self.state.show_rename_prompt
7573 || self.state.inventory_filter_focused
7574 {
7575 return;
7576 }
7577 self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
7578 self.state.inventory_menu_index = 0;
7579 self.state.clamp_inventory_indices();
7580 }
7581
7582 pub fn focus_inventory_filter(&mut self) {
7583 if self.state.show_grant_picker {
7584 if let Some(p) = self.state.grant_picker.as_mut() {
7585 p.filter_focused = true;
7586 }
7587 return;
7588 }
7589 if self.state.show_move_picker {
7590 if let Some(p) = self.state.move_picker.as_mut() {
7591 p.filter_focused = true;
7592 }
7593 return;
7594 }
7595 self.state.inventory_filter_focused = true;
7596 }
7597
7598 pub fn set_inventory_filter(&mut self, filter: String) {
7599 self.state.inventory_filter = filter;
7600 self.state.inventory_menu_index = 0;
7601 self.state.clamp_inventory_indices();
7602 }
7603
7604 pub fn append_inventory_filter_char(&mut self, ch: char) {
7605 if ch.is_control() {
7606 return;
7607 }
7608 if self.state.show_grant_picker {
7609 if let Some(p) = self.state.grant_picker.as_mut() {
7610 if p.filter_focused {
7611 p.filter.push(ch);
7612 self.state.grant_picker_index = 0;
7613 }
7614 }
7615 return;
7616 }
7617 if self.state.show_move_picker {
7618 if let Some(p) = self.state.move_picker.as_mut() {
7619 if p.filter_focused {
7620 p.filter.push(ch);
7621 self.state.move_picker_index = 0;
7622 self.state.clamp_move_picker_quantity();
7623 }
7624 }
7625 return;
7626 }
7627 if !self.state.inventory_filter_focused {
7628 return;
7629 }
7630 self.state.inventory_filter.push(ch);
7631 self.state.inventory_menu_index = 0;
7632 self.state.clamp_inventory_indices();
7633 }
7634
7635 pub fn inventory_filter_backspace(&mut self) {
7636 if self.state.show_grant_picker {
7637 if let Some(p) = self.state.grant_picker.as_mut() {
7638 if p.filter_focused {
7639 p.filter.pop();
7640 self.state.grant_picker_index = 0;
7641 }
7642 }
7643 return;
7644 }
7645 if self.state.show_move_picker {
7646 if let Some(p) = self.state.move_picker.as_mut() {
7647 if p.filter_focused {
7648 p.filter.pop();
7649 self.state.move_picker_index = 0;
7650 self.state.clamp_move_picker_quantity();
7651 }
7652 }
7653 return;
7654 }
7655 if !self.state.inventory_filter_focused {
7656 return;
7657 }
7658 self.state.inventory_filter.pop();
7659 self.state.inventory_menu_index = 0;
7660 self.state.clamp_inventory_indices();
7661 }
7662
7663 pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
7665 if self.state.show_grant_picker {
7666 if let Some(p) = self.state.grant_picker.as_mut() {
7667 if p.filter_focused {
7668 if !p.filter.is_empty() {
7669 p.filter.clear();
7670 self.state.grant_picker_index = 0;
7671 } else {
7672 p.filter_focused = false;
7673 }
7674 return true;
7675 }
7676 if !p.filter.is_empty() {
7677 p.filter.clear();
7678 self.state.grant_picker_index = 0;
7679 return true;
7680 }
7681 }
7682 return false;
7683 }
7684 if self.state.show_move_picker {
7685 if let Some(p) = self.state.move_picker.as_mut() {
7686 if p.filter_focused {
7687 if !p.filter.is_empty() {
7688 p.filter.clear();
7689 self.state.move_picker_index = 0;
7690 self.state.clamp_move_picker_quantity();
7691 } else {
7692 p.filter_focused = false;
7693 }
7694 return true;
7695 }
7696 if !p.filter.is_empty() {
7697 p.filter.clear();
7698 self.state.move_picker_index = 0;
7699 self.state.clamp_move_picker_quantity();
7700 return true;
7701 }
7702 }
7703 return false;
7704 }
7705 if self.state.inventory_filter_focused {
7706 if !self.state.inventory_filter.is_empty() {
7707 self.state.inventory_filter.clear();
7708 self.state.inventory_menu_index = 0;
7709 self.state.clamp_inventory_indices();
7710 } else {
7711 self.state.inventory_filter_focused = false;
7712 }
7713 return true;
7714 }
7715 if !self.state.inventory_filter.is_empty() {
7716 self.state.inventory_filter.clear();
7717 self.state.inventory_menu_index = 0;
7718 self.state.clamp_inventory_indices();
7719 return true;
7720 }
7721 false
7722 }
7723
7724 pub fn craft_menu_page(&mut self, pages: i32) {
7725 let n = self.state.blueprints.len();
7726 self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
7727 self.state.clamp_craft_batch_quantity();
7728 }
7729
7730 pub fn shop_menu_page(&mut self, pages: i32) {
7731 let n = self.state.shop_list_len();
7732 self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
7733 self.state.clamp_shop_quantity();
7734 }
7735
7736 pub fn workers_menu_page(&mut self, pages: i32) {
7737 let n = self.state.hired_workers.len();
7738 self.state.workers_menu_index =
7739 page_list_index(self.state.workers_menu_index, pages, n);
7740 }
7741
7742 pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
7747 if self.state.show_destroy_picker {
7748 if self.state.destroy_confirm_pending {
7749 return self.confirm_destroy_item().await;
7750 }
7751 return self.request_destroy_confirm();
7752 }
7753 if self.state.show_grant_picker {
7754 return self.confirm_grant_picker().await;
7755 }
7756 if self.state.show_move_picker {
7757 return self.confirm_move_picker().await;
7758 }
7759 let Some(row) = self.state.inventory_selected_row() else {
7760 anyhow::bail!("inventory empty");
7761 };
7762 if row.is_equip_shell {
7763 let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
7764 anyhow::bail!("not a worn item");
7765 };
7766 return self.equip_worn(slot, None).await;
7767 }
7768 if row.is_chest_shell {
7769 return self.open_chest_pickup_picker();
7770 }
7771 let template_id = row.stack.template_id.clone();
7772 let instance_id = row.stack.item_instance_id;
7773 let category = self.state.inventory_item_category(&template_id);
7774 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7775
7776 if category == Some("weapon") {
7777 return self.equip_mainhand(Some(template_id)).await;
7778 }
7779 if category == Some("lodging") && on_person {
7780 if let Some(inst) = instance_id {
7781 return self.place_container(inst).await;
7782 }
7783 }
7784 if (category == Some("container") || category == Some("armor")) && on_person {
7785 if let Some(inst) = instance_id {
7786 let world_placeable = row.stack.world_placeable == Some(true)
7787 || template_id.contains("chest");
7788 if world_placeable {
7789 return self.place_container(inst).await;
7790 }
7791 if let Some(slot) = guess_body_slot(&template_id) {
7795 return self.equip_worn(slot, Some(inst)).await;
7796 }
7797 }
7798 }
7799 self.open_move_picker()
7803 }
7804
7805 pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
7807 let Some(row) = self.state.inventory_selected_row() else {
7808 anyhow::bail!("inventory empty");
7809 };
7810 if row.from != flatland_protocol::InventoryLocation::Root {
7811 anyhow::bail!("select a consumable on your person");
7812 }
7813 if GameState::stack_is_item_grant(&row.stack) {
7814 return self.open_grant_target_picker();
7815 }
7816 if GameState::is_property_deed_template(&row.stack.template_id) {
7817 return self.open_move_picker();
7818 }
7819 let category = self
7820 .state
7821 .inventory_item_category(&row.stack.template_id);
7822 if category != Some("consumable") {
7823 anyhow::bail!("selected item is not consumable");
7824 }
7825 self.use_item(&row.stack.template_id).await
7826 }
7827
7828 pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
7830 let Some(row) = self.state.inventory_selected_row() else {
7831 anyhow::bail!("inventory empty");
7832 };
7833 if row.from != flatland_protocol::InventoryLocation::Root {
7834 anyhow::bail!("select a grant item on your person");
7835 }
7836 if !GameState::stack_is_item_grant(&row.stack) {
7837 anyhow::bail!("selected item does not grant onto gear");
7838 }
7839 let Some(grant_instance_id) = row.stack.item_instance_id else {
7840 anyhow::bail!("grant has no instance id");
7841 };
7842 let effect_id = GameState::grant_effect_id(&row.stack)
7843 .unwrap_or("?")
7844 .to_string();
7845 let mode = GameState::grant_mode(&row.stack).to_string();
7846 let options = self.state.grant_target_options(&row.stack);
7847 if options.is_empty() {
7848 anyhow::bail!("no valid gear to apply {effect_id} to");
7849 }
7850 let grant_label = row
7851 .stack
7852 .display_name
7853 .clone()
7854 .unwrap_or_else(|| row.stack.template_id.clone());
7855 self.state.show_grant_picker = true;
7856 self.state.grant_picker_index = 0;
7857 self.state.grant_picker = Some(GrantTargetPicker {
7858 grant_instance_id,
7859 grant_label,
7860 effect_id,
7861 mode,
7862 options,
7863 filter: String::new(),
7864 filter_focused: false,
7865 });
7866 Ok(())
7867 }
7868
7869 pub fn close_grant_picker(&mut self) {
7870 self.state.show_grant_picker = false;
7871 self.state.grant_picker = None;
7872 self.state.grant_picker_index = 0;
7873 }
7874
7875 pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
7876 let Some(picker) = self.state.grant_picker.clone() else {
7877 self.close_grant_picker();
7878 return Ok(());
7879 };
7880 let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
7881 self.close_grant_picker();
7882 return Ok(());
7883 };
7884 self.close_grant_picker();
7885 self.use_grant(picker.grant_instance_id, opt.target_instance_id)
7886 .await?;
7887 self.state.push_log(format!(
7888 "Applying {} onto {}…",
7889 picker.effect_id, opt.label
7890 ));
7891 Ok(())
7892 }
7893
7894 pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
7898 let Some(row) = self.state.inventory_selected_row() else {
7899 anyhow::bail!("inventory empty");
7900 };
7901 if row.is_equip_shell {
7902 anyhow::bail!("this is a worn bag — press Enter to unequip it");
7903 }
7904 if row.is_chest_shell {
7905 return self.open_chest_pickup_picker();
7906 }
7907 let Some(instance_id) = row.stack.item_instance_id else {
7908 anyhow::bail!("item has no instance id");
7909 };
7910 let mut options = self.state.move_destinations_for(
7911 &row.from,
7912 row.from_parent_instance_id,
7913 row.stack.item_instance_id,
7914 &row.stack.template_id,
7915 );
7916 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7917 let category = self.state.inventory_item_category(&row.stack.template_id);
7918 if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
7919 if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
7920 options.insert(
7921 0,
7922 MoveOption {
7923 label: "Sell plot to crown…".into(),
7924 kind: MoveOptionKind::SellPlotToCrown { plot_id },
7925 },
7926 );
7927 }
7928 }
7929 if on_person && category == Some("consumable") {
7930 if GameState::stack_is_item_grant(&row.stack) {
7931 options.insert(
7932 0,
7933 MoveOption {
7934 label: "Apply onto gear…".into(),
7935 kind: MoveOptionKind::GrantApply,
7936 },
7937 );
7938 } else {
7939 options.insert(
7940 0,
7941 MoveOption {
7942 label: "Use (eat / drink)".into(),
7943 kind: MoveOptionKind::Use,
7944 },
7945 );
7946 }
7947 }
7948 let item_label = row
7949 .stack
7950 .display_name
7951 .clone()
7952 .unwrap_or_else(|| row.stack.template_id.clone());
7953 let initial_qty = if row.stack.quantity > 1 { 1 } else { row.stack.quantity };
7956 self.state.move_picker = Some(MovePicker {
7957 item_instance_id: instance_id,
7958 from: row.from,
7959 item_label,
7960 template_id: row.stack.template_id.clone(),
7961 stack_quantity: row.stack.quantity,
7962 quantity: initial_qty.max(1),
7963 options,
7964 filter: String::new(),
7965 filter_focused: false,
7966 });
7967 self.state.move_picker_index = 0;
7968 self.state.show_move_picker = true;
7969 self.state.show_destroy_picker = false;
7970 self.state.destroy_confirm_pending = false;
7971 self.state.destroy_picker = None;
7972 self.state.clamp_move_picker_quantity();
7973 Ok(())
7974 }
7975
7976 pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
7978 let Some(row) = self.state.inventory_selected_row() else {
7979 anyhow::bail!("inventory empty");
7980 };
7981 if !row.is_chest_shell {
7982 anyhow::bail!("not a placed chest");
7983 }
7984 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
7985 anyhow::bail!("not a placed chest");
7986 };
7987 let Some(instance_id) = row.stack.item_instance_id else {
7988 anyhow::bail!("chest has no instance id");
7989 };
7990 let chest = self
7991 .state
7992 .placed_containers
7993 .iter()
7994 .find(|c| c.id == *container_id)
7995 .cloned()
7996 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
7997 let (px, py) = self.state.player_position();
7998 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
7999 anyhow::bail!("too far from {}", chest.display_name);
8000 }
8001 if chest.locked && !chest.accessible {
8002 anyhow::bail!(
8003 "need the matching key for {} before picking it up",
8004 chest.display_name
8005 );
8006 }
8007 let options = self.state.chest_pickup_destinations(container_id);
8008 let item_label = row
8009 .stack
8010 .display_name
8011 .clone()
8012 .unwrap_or_else(|| row.stack.template_id.clone());
8013 self.state.move_picker = Some(MovePicker {
8014 item_instance_id: instance_id,
8015 from: row.from.clone(),
8016 item_label,
8017 template_id: row.stack.template_id.clone(),
8018 stack_quantity: 1,
8019 quantity: 1,
8020 options,
8021 filter: String::new(),
8022 filter_focused: false,
8023 });
8024 self.state.move_picker_index = 0;
8025 self.state.show_move_picker = true;
8026 self.state.show_destroy_picker = false;
8027 self.state.destroy_confirm_pending = false;
8028 self.state.destroy_picker = None;
8029 Ok(())
8030 }
8031
8032 pub fn close_move_picker(&mut self) {
8033 self.state.show_move_picker = false;
8034 self.state.move_picker = None;
8035 }
8036
8037 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
8038 self.state.move_picker_adjust_quantity(delta);
8039 }
8040
8041 pub fn move_picker_set_quantity_max(&mut self) {
8042 self.state.move_picker_set_quantity_max();
8043 }
8044
8045 pub fn move_picker_set_quantity_min(&mut self) {
8046 self.state.move_picker_set_quantity_min();
8047 }
8048
8049 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
8050 self.state.destroy_picker_adjust_quantity(delta);
8051 }
8052
8053 pub fn destroy_picker_set_quantity_max(&mut self) {
8054 self.state.destroy_picker_set_quantity_max();
8055 }
8056
8057 pub fn destroy_picker_set_quantity_min(&mut self) {
8058 self.state.destroy_picker_set_quantity_min();
8059 }
8060
8061 async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
8062 let Some(picker) = self.state.move_picker.clone() else {
8063 self.close_move_picker();
8064 return Ok(());
8065 };
8066 let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
8067 self.close_move_picker();
8068 return Ok(());
8069 };
8070 match option.kind {
8071 MoveOptionKind::Cancel => {
8072 self.close_move_picker();
8073 }
8074 MoveOptionKind::Use => {
8075 self.close_move_picker();
8076 self.use_item(&picker.template_id).await?;
8077 }
8078 MoveOptionKind::GrantApply => {
8079 self.close_move_picker();
8080 self.open_grant_target_picker()?;
8081 }
8082 MoveOptionKind::SellPlotToCrown { plot_id } => {
8083 self.close_move_picker();
8084 self.confirm_sell_plot_to_crown(plot_id).await?;
8085 }
8086 MoveOptionKind::RelocatePlaced { container_id } => {
8087 self.close_move_picker();
8088 self.state.show_inventory_menu = false;
8089 self.begin_relocate_container(&container_id)?;
8090 }
8091 MoveOptionKind::Drop => {
8092 self.close_move_picker();
8093 if self
8094 .state
8095 .hand_equipped_instance_ids()
8096 .contains(&picker.item_instance_id)
8097 {
8098 anyhow::bail!("unequip that item first");
8099 }
8100 if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
8101 if self.state.deed_bound(&stack) {
8102 anyhow::bail!(
8103 "cannot drop a property deed — store it or trade it to another player"
8104 );
8105 }
8106 if self.state.key_drop_blocked(&stack) {
8107 anyhow::bail!("cannot drop the key while its chest is locked");
8108 }
8109 }
8110 self.drop_item(picker.item_instance_id, picker.from).await?;
8111 self.state
8112 .push_log(format!("Dropped {}", picker.item_label));
8113 }
8114 MoveOptionKind::PickupPlaced {
8115 container_id,
8116 nest_location,
8117 nest_parent_instance_id,
8118 } => {
8119 self.close_move_picker();
8120 self.pickup_container(container_id.clone()).await?;
8121 let nest_into_bag = nest_parent_instance_id.is_some()
8122 || !matches!(
8123 nest_location,
8124 flatland_protocol::InventoryLocation::Root
8125 );
8126 if nest_into_bag {
8127 self.move_item(
8128 picker.item_instance_id,
8129 flatland_protocol::InventoryLocation::Root,
8130 nest_location,
8131 nest_parent_instance_id,
8132 None,
8133 )
8134 .await?;
8135 self.state
8136 .push_log(format!("Picked up {} into bag", picker.item_label));
8137 } else {
8138 self.state
8139 .push_log(format!("Picked up {}", picker.item_label));
8140 }
8141 }
8142 MoveOptionKind::Move {
8143 location,
8144 parent_instance_id,
8145 } => {
8146 self.close_move_picker();
8147 let qty = if picker.quantity >= picker.stack_quantity {
8148 None
8149 } else {
8150 Some(picker.quantity)
8151 };
8152 self.move_item(
8153 picker.item_instance_id,
8154 picker.from,
8155 location,
8156 parent_instance_id,
8157 qty,
8158 )
8159 .await?;
8160 let moved = qty.unwrap_or(picker.stack_quantity);
8161 if moved >= picker.stack_quantity {
8162 self.state.push_log(format!("Moved {}", picker.item_label));
8163 } else {
8164 self.state.push_log(format!(
8165 "Moved {} ×{} of {}",
8166 picker.item_label, moved, picker.stack_quantity
8167 ));
8168 }
8169 }
8170 }
8171 Ok(())
8172 }
8173
8174 pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
8176 let Some(row) = self.state.inventory_selected_row() else {
8177 anyhow::bail!("inventory empty");
8178 };
8179 if row.is_equip_shell {
8180 anyhow::bail!("unequip the bag first (Enter), then drop from your person");
8181 }
8182 if row.is_chest_shell {
8183 anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
8184 }
8185 let Some(inst) = row.stack.item_instance_id else {
8186 anyhow::bail!("item has no instance id");
8187 };
8188 if self.state.hand_equipped_instance_ids().contains(&inst) {
8189 anyhow::bail!("unequip that item first");
8190 }
8191 if self.state.deed_bound(&row.stack) {
8192 anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
8193 }
8194 if self.state.key_drop_blocked(&row.stack) {
8195 anyhow::bail!("cannot drop the key while its chest is locked");
8196 }
8197 let label = row
8198 .stack
8199 .display_name
8200 .clone()
8201 .unwrap_or_else(|| row.stack.template_id.clone());
8202 self.drop_item(inst, row.from).await?;
8203 self.state.push_log(format!("Dropped {label}"));
8204 Ok(())
8205 }
8206
8207 pub async fn drop_item(
8208 &mut self,
8209 item_instance_id: uuid::Uuid,
8210 from: flatland_protocol::InventoryLocation,
8211 ) -> anyhow::Result<()> {
8212 self.seq += 1;
8213 self.session
8214 .submit_intent(Intent::DropItem {
8215 entity_id: self.state.entity_id,
8216 item_instance_id,
8217 from,
8218 seq: self.seq,
8219 })
8220 .await?;
8221 self.state.intents_sent += 1;
8222 Ok(())
8223 }
8224
8225 pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
8227 let Some(row) = self.state.inventory_selected_row() else {
8228 anyhow::bail!("inventory empty");
8229 };
8230 if row.is_equip_shell {
8231 anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
8232 }
8233 if row.is_chest_shell {
8234 anyhow::bail!("can't destroy a placed chest from the inventory list");
8235 }
8236 let Some(instance_id) = row.stack.item_instance_id else {
8237 anyhow::bail!("item has no instance id");
8238 };
8239 if self.state.hand_equipped_instance_ids().contains(&instance_id) {
8240 anyhow::bail!("unequip that item first");
8241 }
8242 if self.state.deed_bound(&row.stack) {
8243 anyhow::bail!(
8244 "cannot destroy a property deed — store it or trade it to another player"
8245 );
8246 }
8247 if self.state.key_drop_blocked(&row.stack) {
8248 anyhow::bail!("cannot destroy the key while its chest is locked");
8249 }
8250 let item_label = row
8251 .stack
8252 .display_name
8253 .clone()
8254 .unwrap_or_else(|| row.stack.template_id.clone());
8255 self.state.destroy_picker = Some(DestroyPicker {
8256 item_instance_id: instance_id,
8257 from: row.from,
8258 item_label,
8259 stack_quantity: row.stack.quantity,
8260 quantity: row.stack.quantity,
8261 });
8262 self.state.destroy_confirm_pending = false;
8263 self.state.show_destroy_picker = true;
8264 self.state.show_move_picker = false;
8265 self.state.move_picker = None;
8266 Ok(())
8267 }
8268
8269 pub fn close_destroy_picker(&mut self) {
8270 self.state.show_destroy_picker = false;
8271 self.state.destroy_confirm_pending = false;
8272 self.state.destroy_picker = None;
8273 }
8274
8275 pub fn cancel_destroy_confirm(&mut self) {
8276 self.state.destroy_confirm_pending = false;
8277 }
8278
8279 pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
8280 if self.state.destroy_picker.is_none() {
8281 self.close_destroy_picker();
8282 return Ok(());
8283 }
8284 self.state.destroy_confirm_pending = true;
8285 Ok(())
8286 }
8287
8288 pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
8289 let Some(picker) = self.state.destroy_picker.clone() else {
8290 self.close_destroy_picker();
8291 return Ok(());
8292 };
8293 let qty = if picker.quantity >= picker.stack_quantity {
8294 None
8295 } else {
8296 Some(picker.quantity)
8297 };
8298 self.destroy_item(picker.item_instance_id, picker.from, qty)
8299 .await?;
8300 let destroyed = qty.unwrap_or(picker.stack_quantity);
8301 if destroyed >= picker.stack_quantity {
8302 self.state
8303 .push_log(format!("Destroyed {}", picker.item_label));
8304 } else {
8305 self.state.push_log(format!(
8306 "Destroyed {} ×{} of {}",
8307 picker.item_label, destroyed, picker.stack_quantity
8308 ));
8309 }
8310 self.close_destroy_picker();
8311 Ok(())
8312 }
8313
8314 pub async fn destroy_item(
8315 &mut self,
8316 item_instance_id: uuid::Uuid,
8317 from: flatland_protocol::InventoryLocation,
8318 quantity: Option<u32>,
8319 ) -> anyhow::Result<()> {
8320 self.seq += 1;
8321 self.session
8322 .submit_intent(Intent::DestroyItem {
8323 entity_id: self.state.entity_id,
8324 item_instance_id,
8325 from,
8326 quantity,
8327 seq: self.seq,
8328 })
8329 .await?;
8330 self.state.intents_sent += 1;
8331 Ok(())
8332 }
8333
8334 pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
8336 if let Some(row) = self.state.inventory_selected_row() {
8337 if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
8338 return self.toggle_placed_chest_lock(container_id).await;
8339 }
8340 }
8341 self.toggle_nearby_chest_lock().await
8342 }
8343
8344 pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
8345 let chest = self
8346 .state
8347 .placed_containers
8348 .iter()
8349 .find(|c| c.id == container_id)
8350 .cloned()
8351 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
8352 let (px, py) = self.state.player_position();
8353 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
8354 anyhow::bail!("too far from {}", chest.display_name);
8355 }
8356 if !chest.accessible && chest.locked {
8357 anyhow::bail!(
8358 "need the matching key for {} (each crafted chest has its own key)",
8359 chest.display_name
8360 );
8361 }
8362 let lock = !chest.locked;
8363 self.set_container_locked(
8364 flatland_protocol::InventoryLocation::Placed {
8365 container_id: chest.id.clone(),
8366 },
8367 lock,
8368 )
8369 .await?;
8370 self.state.push_log(if lock {
8371 format!("Locked {}", chest.display_name)
8372 } else {
8373 format!("Unlocked {}", chest.display_name)
8374 });
8375 Ok(())
8376 }
8377
8378 pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
8380 let chest = self
8381 .state
8382 .nearest_placed_container(CONTAINER_RANGE_M)
8383 .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
8384 self.toggle_placed_chest_lock(&chest.id).await
8385 }
8386
8387 pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
8388 self.equip_mainhand(None).await
8389 }
8390
8391 pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
8392 if !self.state.is_alive() {
8393 anyhow::bail!("you are dead");
8394 }
8395 self.seq += 1;
8396 self.session
8397 .submit_intent(Intent::EquipOffhand {
8398 entity_id: self.state.entity_id,
8399 template_id,
8400 instance_id: None,
8401 seq: self.seq,
8402 })
8403 .await?;
8404 self.state.intents_sent += 1;
8405 Ok(())
8406 }
8407
8408 pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
8409 self.equip_offhand(None).await
8410 }
8411
8412 pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
8413 let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
8414 for slot in slots {
8415 self.equip_worn(slot, None).await?;
8416 }
8417 Ok(())
8418 }
8419
8420 pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
8421 let (px, py) = self.state.player_position();
8422 let nearest = self
8423 .state
8424 .placed_containers
8425 .iter()
8426 .min_by(|a, b| {
8427 let da = (a.x - px).hypot(a.y - py);
8428 let db = (b.x - px).hypot(b.y - py);
8429 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
8430 })
8431 .cloned();
8432 let Some(chest) = nearest else {
8433 anyhow::bail!("no chest nearby");
8434 };
8435 if (chest.x - px).hypot(chest.y - py) > 2.0 {
8436 anyhow::bail!("too far from chest");
8437 }
8438 self.pickup_container(chest.id).await
8439 }
8440
8441 pub async fn equip_worn(
8442 &mut self,
8443 slot: BodySlot,
8444 instance_id: Option<uuid::Uuid>,
8445 ) -> anyhow::Result<()> {
8446 self.seq += 1;
8447 self.session
8448 .submit_intent(Intent::EquipWorn {
8449 entity_id: self.state.entity_id,
8450 slot,
8451 instance_id,
8452 seq: self.seq,
8453 })
8454 .await?;
8455 self.state.intents_sent += 1;
8456 Ok(())
8457 }
8458
8459 pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
8460 self.seq += 1;
8461 self.session
8462 .submit_intent(Intent::PlaceContainer {
8463 entity_id: self.state.entity_id,
8464 item_instance_id,
8465 seq: self.seq,
8466 })
8467 .await?;
8468 self.state.intents_sent += 1;
8469 Ok(())
8470 }
8471
8472 pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
8473 self.seq += 1;
8474 self.session
8475 .submit_intent(Intent::PickupContainer {
8476 entity_id: self.state.entity_id,
8477 container_id,
8478 seq: self.seq,
8479 })
8480 .await?;
8481 self.state.intents_sent += 1;
8482 Ok(())
8483 }
8484
8485 pub async fn move_item(
8486 &mut self,
8487 item_instance_id: uuid::Uuid,
8488 from: flatland_protocol::InventoryLocation,
8489 to: flatland_protocol::InventoryLocation,
8490 to_parent_instance_id: Option<uuid::Uuid>,
8491 quantity: Option<u32>,
8492 ) -> anyhow::Result<()> {
8493 self.seq += 1;
8494 self.session
8495 .submit_intent(Intent::MoveItem {
8496 entity_id: self.state.entity_id,
8497 item_instance_id,
8498 from,
8499 to,
8500 to_parent_instance_id,
8501 quantity,
8502 seq: self.seq,
8503 })
8504 .await?;
8505 self.state.intents_sent += 1;
8506 Ok(())
8507 }
8508
8509 pub async fn set_container_locked(
8510 &mut self,
8511 location: flatland_protocol::InventoryLocation,
8512 locked: bool,
8513 ) -> anyhow::Result<()> {
8514 self.seq += 1;
8515 self.session
8516 .submit_intent(Intent::SetContainerLocked {
8517 entity_id: self.state.entity_id,
8518 location,
8519 locked,
8520 seq: self.seq,
8521 })
8522 .await?;
8523 self.state.intents_sent += 1;
8524 Ok(())
8525 }
8526
8527 pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
8528 if !self.state.is_alive() {
8529 anyhow::bail!("you are dead");
8530 }
8531 self.seq += 1;
8532 self.session
8533 .submit_intent(Intent::Use {
8534 entity_id: self.state.entity_id,
8535 template_id: template_id.to_string(),
8536 seq: self.seq,
8537 })
8538 .await?;
8539 self.state.intents_sent += 1;
8540 Ok(())
8541 }
8542
8543 pub async fn use_grant(
8545 &mut self,
8546 grant_instance_id: uuid::Uuid,
8547 target_instance_id: uuid::Uuid,
8548 ) -> anyhow::Result<()> {
8549 if !self.state.is_alive() {
8550 anyhow::bail!("you are dead");
8551 }
8552 self.seq += 1;
8553 self.session
8554 .submit_intent(Intent::UseGrant {
8555 entity_id: self.state.entity_id,
8556 grant_instance_id,
8557 target_instance_id,
8558 seq: self.seq,
8559 })
8560 .await?;
8561 self.state.intents_sent += 1;
8562 Ok(())
8563 }
8564
8565 pub fn open_craft_menu(&mut self) {
8566 self.state.show_craft_menu = true;
8567 self.state.show_shop_menu = false;
8568 self.state.shop_catalog = None;
8569 self.state.show_stats = false;
8570 self.state.show_inventory_menu = false;
8571 if self.state.blueprints.is_empty() {
8572 self.state.craft_menu_index = 0;
8573 self.state.craft_batch_quantity = 1;
8574 return;
8575 }
8576 self.state.craft_menu_index = self
8577 .state
8578 .craft_menu_index
8579 .min(self.state.blueprints.len() - 1);
8580 if let Some(idx) = self
8581 .state
8582 .blueprints
8583 .iter()
8584 .position(|bp| self.state.can_craft_blueprint(bp))
8585 {
8586 self.state.craft_menu_index = idx;
8587 }
8588 self.state.clamp_craft_batch_quantity();
8589 }
8590
8591 pub fn close_craft_menu(&mut self) {
8592 self.state.show_craft_menu = false;
8593 }
8594
8595 pub fn toggle_keychain_menu(&mut self) {
8596 if self.state.show_keychain_menu {
8597 self.close_keychain_menu();
8598 } else {
8599 self.state.show_keychain_menu = true;
8600 self.state.show_craft_menu = false;
8601 self.state.show_shop_menu = false;
8602 self.state.show_inventory_menu = false;
8603 let n = self.state.keychain_entries().len();
8604 if n == 0 {
8605 self.state.keychain_menu_index = 0;
8606 } else {
8607 self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
8608 }
8609 }
8610 }
8611
8612 pub fn close_keychain_menu(&mut self) {
8613 self.state.show_keychain_menu = false;
8614 }
8615
8616 pub fn keychain_menu_move(&mut self, delta: i32) {
8617 let n = self.state.keychain_entries().len();
8618 if n == 0 {
8619 self.state.keychain_menu_index = 0;
8620 return;
8621 }
8622 let idx = self.state.keychain_menu_index as i32 + delta;
8623 self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
8624 }
8625
8626 pub fn keychain_menu_page(&mut self, pages: i32) {
8627 let n = self.state.keychain_entries().len();
8628 self.state.keychain_menu_index =
8629 page_list_index(self.state.keychain_menu_index, pages, n);
8630 }
8631
8632 pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
8633 if !self.state.is_alive() {
8634 anyhow::bail!("you are dead");
8635 }
8636 let entries = self.state.keychain_entries();
8637 let Some(entry) = entries.get(self.state.keychain_menu_index) else {
8638 anyhow::bail!("nothing selected");
8639 };
8640 let Some(instance_id) = entry.stack.item_instance_id else {
8641 anyhow::bail!("key has no instance id");
8642 };
8643 if entry.stowed {
8644 self.move_item(
8645 instance_id,
8646 flatland_protocol::InventoryLocation::Keychain,
8647 flatland_protocol::InventoryLocation::Root,
8648 None,
8649 Some(1),
8650 )
8651 .await
8652 } else {
8653 self.move_item(
8654 instance_id,
8655 flatland_protocol::InventoryLocation::Root,
8656 flatland_protocol::InventoryLocation::Keychain,
8657 None,
8658 Some(1),
8659 )
8660 .await
8661 }
8662 }
8663
8664 pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
8665 let npc_id = self
8666 .state
8667 .shop_catalog
8668 .as_ref()
8669 .map(|c| c.npc_id.clone());
8670 self.state.show_shop_menu = false;
8671 self.state.shop_catalog = None;
8672 self.state.clear_shop_trade_log();
8673 if let Some(npc_id) = npc_id {
8674 self.seq += 1;
8675 self.session
8676 .submit_intent(Intent::ShopClose {
8677 entity_id: self.state.entity_id,
8678 npc_id,
8679 seq: self.seq,
8680 })
8681 .await?;
8682 self.state.intents_sent += 1;
8683 }
8684 Ok(())
8685 }
8686
8687 pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8688 let Some(panel) = self.state.bank_panel.clone() else {
8689 return Ok(());
8690 };
8691 self.seq += 1;
8692 self.session
8693 .submit_intent(Intent::BankDeposit {
8694 entity_id: self.state.entity_id,
8695 npc_id: panel.npc_id,
8696 amount_copper,
8697 seq: self.seq,
8698 })
8699 .await?;
8700 self.state.intents_sent += 1;
8701 Ok(())
8702 }
8703
8704 pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8705 let Some(panel) = self.state.bank_panel.clone() else {
8706 return Ok(());
8707 };
8708 self.seq += 1;
8709 self.session
8710 .submit_intent(Intent::BankWithdraw {
8711 entity_id: self.state.entity_id,
8712 npc_id: panel.npc_id,
8713 amount_copper,
8714 seq: self.seq,
8715 })
8716 .await?;
8717 self.state.intents_sent += 1;
8718 Ok(())
8719 }
8720
8721 pub async fn bank_transfer(
8722 &mut self,
8723 to_character_id: Option<uuid::Uuid>,
8724 to_name: String,
8725 amount_copper: u64,
8726 ) -> anyhow::Result<()> {
8727 let Some(panel) = self.state.bank_panel.clone() else {
8728 return Ok(());
8729 };
8730 self.seq += 1;
8731 self.session
8732 .submit_intent(Intent::BankTransfer {
8733 entity_id: self.state.entity_id,
8734 npc_id: panel.npc_id,
8735 to_character_id,
8736 to_name,
8737 amount_copper,
8738 seq: self.seq,
8739 })
8740 .await?;
8741 self.state.intents_sent += 1;
8742 Ok(())
8743 }
8744
8745 pub fn bank_menu_move(&mut self, delta: i32) {
8746 let n = self.state.bank_menu_options().len();
8747 if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
8748 return;
8749 }
8750 let idx = self.state.bank_menu_index as i32;
8751 self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8752 }
8753
8754 pub fn storage_menu_move(&mut self, delta: i32) {
8755 let n = self.state.storage_menu_options().len();
8756 if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
8757 return;
8758 }
8759 let idx = self.state.storage_menu_index as i32;
8760 self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8761 }
8762
8763 pub fn storage_pick_move(&mut self, delta: i32) {
8764 let n = match &self.state.storage_ui_mode {
8765 StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
8766 StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
8767 self.state.storage_vault_options().len()
8768 }
8769 StorageUiMode::Menu
8770 | StorageUiMode::StoreAmount { .. }
8771 | StorageUiMode::TakeAmount { .. }
8772 | StorageUiMode::ShipAmount { .. } => 0,
8773 };
8774 if n == 0 {
8775 return;
8776 }
8777 match &mut self.state.storage_ui_mode {
8778 StorageUiMode::StorePick { index }
8779 | StorageUiMode::TakePick { index }
8780 | StorageUiMode::ShipPick { index, .. } => {
8781 *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8782 }
8783 StorageUiMode::Menu
8784 | StorageUiMode::StoreAmount { .. }
8785 | StorageUiMode::TakeAmount { .. }
8786 | StorageUiMode::ShipAmount { .. } => {}
8787 }
8788 }
8789
8790 pub fn storage_ui_back(&mut self) {
8791 self.state.storage_ui_mode = match &self.state.storage_ui_mode {
8792 StorageUiMode::StoreAmount { pick_index, .. } => StorageUiMode::StorePick {
8793 index: *pick_index,
8794 },
8795 StorageUiMode::TakeAmount { pick_index, .. } => StorageUiMode::TakePick {
8796 index: *pick_index,
8797 },
8798 StorageUiMode::ShipAmount {
8799 dest_building_id,
8800 dest_label,
8801 pick_index,
8802 ..
8803 } => StorageUiMode::ShipPick {
8804 dest_building_id: dest_building_id.clone(),
8805 dest_label: dest_label.clone(),
8806 index: *pick_index,
8807 },
8808 StorageUiMode::StorePick { .. }
8809 | StorageUiMode::TakePick { .. }
8810 | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
8811 StorageUiMode::Menu => StorageUiMode::Menu,
8812 };
8813 }
8814
8815 pub fn storage_amount_append_char(&mut self, c: char) {
8816 match &mut self.state.storage_ui_mode {
8817 StorageUiMode::StoreAmount { input, .. }
8818 | StorageUiMode::TakeAmount { input, .. }
8819 | StorageUiMode::ShipAmount { input, .. } => {
8820 if c.is_ascii_digit() && input.len() < 8 {
8821 input.push(c);
8822 }
8823 }
8824 _ => {}
8825 }
8826 }
8827
8828 pub fn storage_amount_backspace(&mut self) {
8829 match &mut self.state.storage_ui_mode {
8830 StorageUiMode::StoreAmount { input, .. }
8831 | StorageUiMode::TakeAmount { input, .. }
8832 | StorageUiMode::ShipAmount { input, .. } => {
8833 input.pop();
8834 }
8835 _ => {}
8836 }
8837 }
8838
8839 pub fn storage_ui_typing(&self) -> bool {
8840 matches!(
8841 self.state.storage_ui_mode,
8842 StorageUiMode::StoreAmount { .. }
8843 | StorageUiMode::TakeAmount { .. }
8844 | StorageUiMode::ShipAmount { .. }
8845 )
8846 }
8847
8848 pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
8849 match self.state.storage_ui_mode.clone() {
8850 StorageUiMode::Menu => {
8851 let index = self.state.storage_menu_index;
8852 match index {
8853 0 => {
8854 let opts = self.state.storage_store_options();
8855 if opts.is_empty() {
8856 self.state.push_log("Nothing loose to store.");
8857 return Ok(());
8858 }
8859 self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
8860 }
8861 1 => {
8862 let opts = self.state.storage_vault_options();
8863 if opts.is_empty() {
8864 self.state.push_log("Vault is empty.");
8865 return Ok(());
8866 }
8867 self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
8868 }
8869 n => {
8870 let dest = self
8871 .state
8872 .storage_panel
8873 .as_ref()
8874 .and_then(|p| p.ship_destinations.get(n - 2))
8875 .cloned();
8876 let Some(dest) = dest else {
8877 return Ok(());
8878 };
8879 let opts = self.state.storage_vault_options();
8880 if opts.is_empty() {
8881 self.state
8882 .push_log("Vault is empty — nothing to ship.");
8883 return Ok(());
8884 }
8885 self.state.storage_ui_mode = StorageUiMode::ShipPick {
8886 dest_building_id: dest.building_id,
8887 dest_label: dest.label,
8888 index: 0,
8889 };
8890 }
8891 }
8892 }
8893 StorageUiMode::StorePick { index } => {
8894 let opts = self.state.storage_store_options();
8895 let Some(opt) = opts.get(index) else {
8896 self.state.push_log("Nothing loose to store.");
8897 self.state.storage_ui_mode = StorageUiMode::Menu;
8898 return Ok(());
8899 };
8900 self.state.storage_ui_mode = StorageUiMode::StoreAmount {
8901 pick_index: index,
8902 item_instance_id: opt.item_instance_id,
8903 label: opt.label.clone(),
8904 max_qty: opt.quantity.max(1),
8905 input: String::new(),
8906 };
8907 }
8908 StorageUiMode::TakePick { index } => {
8909 let opts = self.state.storage_vault_options();
8910 let Some(opt) = opts.get(index) else {
8911 self.state.push_log("Vault is empty.");
8912 self.state.storage_ui_mode = StorageUiMode::Menu;
8913 return Ok(());
8914 };
8915 self.state.storage_ui_mode = StorageUiMode::TakeAmount {
8916 pick_index: index,
8917 item_instance_id: opt.item_instance_id,
8918 label: opt.label.clone(),
8919 max_qty: opt.quantity.max(1),
8920 input: String::new(),
8921 };
8922 }
8923 StorageUiMode::ShipPick {
8924 dest_building_id,
8925 dest_label,
8926 index,
8927 } => {
8928 let opts = self.state.storage_vault_options();
8929 let Some(opt) = opts.get(index) else {
8930 self.state
8931 .push_log("Vault is empty — nothing to ship.");
8932 self.state.storage_ui_mode = StorageUiMode::Menu;
8933 return Ok(());
8934 };
8935 self.state.storage_ui_mode = StorageUiMode::ShipAmount {
8936 dest_building_id,
8937 dest_label,
8938 pick_index: index,
8939 item_instance_id: opt.item_instance_id,
8940 label: opt.label.clone(),
8941 max_qty: opt.quantity.max(1),
8942 input: String::new(),
8943 };
8944 }
8945 StorageUiMode::StoreAmount {
8946 item_instance_id,
8947 max_qty,
8948 input,
8949 ..
8950 } => {
8951 let Some(qty) = parse_storage_quantity(&input) else {
8952 self.state
8953 .push_log("Enter a quantity (blank or 0 = all).");
8954 return Ok(());
8955 };
8956 let qty = qty.map(|n| n.min(max_qty).max(1));
8957 self.storage_store(item_instance_id, qty).await?;
8958 self.state.storage_ui_mode = StorageUiMode::Menu;
8959 }
8960 StorageUiMode::TakeAmount {
8961 item_instance_id,
8962 max_qty,
8963 input,
8964 ..
8965 } => {
8966 let Some(qty) = parse_storage_quantity(&input) else {
8967 self.state
8968 .push_log("Enter a quantity (blank or 0 = all).");
8969 return Ok(());
8970 };
8971 let qty = qty.map(|n| n.min(max_qty).max(1));
8972 self.storage_take(item_instance_id, qty).await?;
8973 self.state.storage_ui_mode = StorageUiMode::Menu;
8974 }
8975 StorageUiMode::ShipAmount {
8976 dest_building_id,
8977 item_instance_id,
8978 max_qty,
8979 input,
8980 ..
8981 } => {
8982 let Some(qty) = parse_storage_quantity(&input) else {
8983 self.state
8984 .push_log("Enter a quantity (blank or 0 = all).");
8985 return Ok(());
8986 };
8987 let qty = qty.map(|n| n.min(max_qty).max(1));
8988 self.storage_ship(dest_building_id, item_instance_id, qty)
8989 .await?;
8990 self.state.storage_ui_mode = StorageUiMode::Menu;
8991 }
8992 }
8993 Ok(())
8994 }
8995
8996 pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
8997 match self.state.bank_ui_mode.clone() {
8998 BankUiMode::Menu => {
8999 let choice = self
9000 .state
9001 .bank_menu_options()
9002 .get(self.state.bank_menu_index)
9003 .copied()
9004 .unwrap_or("Deposit…");
9005 match choice {
9006 "Withdraw…" => {
9007 self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
9008 input: String::new(),
9009 };
9010 }
9011 "Deposit all" => self.bank_deposit(0).await?,
9012 "Withdraw all" => self.bank_withdraw(0).await?,
9013 "Transfer…" => {
9014 self.state.bank_ui_mode = BankUiMode::TransferName {
9015 input: String::new(),
9016 };
9017 }
9018 _ => {
9019 self.state.bank_ui_mode = BankUiMode::DepositAmount {
9020 input: String::new(),
9021 };
9022 }
9023 }
9024 }
9025 BankUiMode::DepositAmount { input } => {
9026 let Some(amount) = parse_bank_copper_amount(&input) else {
9027 self.state
9028 .push_log("Enter a copper amount (blank or 0 = everything on person).");
9029 return Ok(());
9030 };
9031 self.bank_deposit(amount).await?;
9032 self.state.bank_ui_mode = BankUiMode::Menu;
9033 }
9034 BankUiMode::WithdrawAmount { input } => {
9035 let Some(amount) = parse_bank_copper_amount(&input) else {
9036 self.state
9037 .push_log("Enter a copper amount (blank or 0 = full ledger).");
9038 return Ok(());
9039 };
9040 self.bank_withdraw(amount).await?;
9041 self.state.bank_ui_mode = BankUiMode::Menu;
9042 }
9043 BankUiMode::TransferName { input } => {
9044 let name = input.trim().to_string();
9045 if name.is_empty() {
9046 self.state.push_log("Enter the recipient character name.");
9047 return Ok(());
9048 }
9049 self.state.bank_ui_mode = BankUiMode::TransferAmount {
9050 to_name: name,
9051 input: String::new(),
9052 };
9053 }
9054 BankUiMode::TransferAmount { to_name, input } => {
9055 let amount: u64 = match input.trim().parse() {
9056 Ok(v) if v > 0 => v,
9057 _ => {
9058 self.state
9059 .push_log("Enter a positive copper amount to transfer.");
9060 return Ok(());
9061 }
9062 };
9063 self.bank_transfer(None, to_name, amount).await?;
9064 self.state.bank_ui_mode = BankUiMode::Menu;
9065 }
9066 }
9067 Ok(())
9068 }
9069
9070 pub fn bank_transfer_back(&mut self) {
9071 match &self.state.bank_ui_mode {
9072 BankUiMode::TransferAmount { to_name, .. } => {
9073 self.state.bank_ui_mode = BankUiMode::TransferName {
9074 input: to_name.clone(),
9075 };
9076 }
9077 BankUiMode::TransferName { .. }
9078 | BankUiMode::DepositAmount { .. }
9079 | BankUiMode::WithdrawAmount { .. } => {
9080 self.state.bank_ui_mode = BankUiMode::Menu;
9081 }
9082 BankUiMode::Menu => {}
9083 }
9084 }
9085
9086 pub fn bank_transfer_append_char(&mut self, c: char) {
9087 match &mut self.state.bank_ui_mode {
9088 BankUiMode::TransferName { input } => {
9089 if input.len() < 32 && !c.is_control() {
9090 input.push(c);
9091 }
9092 }
9093 BankUiMode::DepositAmount { input }
9094 | BankUiMode::WithdrawAmount { input }
9095 | BankUiMode::TransferAmount { input, .. } => {
9096 if c.is_ascii_digit() && input.len() < 12 {
9097 input.push(c);
9098 }
9099 }
9100 BankUiMode::Menu => {}
9101 }
9102 }
9103
9104 pub fn bank_transfer_backspace(&mut self) {
9105 match &mut self.state.bank_ui_mode {
9106 BankUiMode::TransferName { input }
9107 | BankUiMode::DepositAmount { input }
9108 | BankUiMode::WithdrawAmount { input }
9109 | BankUiMode::TransferAmount { input, .. } => {
9110 input.pop();
9111 }
9112 BankUiMode::Menu => {}
9113 }
9114 }
9115
9116 pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
9117 let npc_id = self
9118 .state
9119 .bank_panel
9120 .as_ref()
9121 .map(|p| p.npc_id.clone());
9122 self.state.clear_bank_panel();
9123 if let Some(npc_id) = npc_id {
9124 self.seq += 1;
9125 self.session
9126 .submit_intent(Intent::BankClose {
9127 entity_id: self.state.entity_id,
9128 npc_id,
9129 seq: self.seq,
9130 })
9131 .await?;
9132 self.state.intents_sent += 1;
9133 }
9134 Ok(())
9135 }
9136
9137 pub async fn storage_store(
9138 &mut self,
9139 item_instance_id: uuid::Uuid,
9140 quantity: Option<u32>,
9141 ) -> anyhow::Result<()> {
9142 let Some(panel) = self.state.storage_panel.clone() else {
9143 return Ok(());
9144 };
9145 self.seq += 1;
9146 self.session
9147 .submit_intent(Intent::StorageStore {
9148 entity_id: self.state.entity_id,
9149 npc_id: panel.npc_id,
9150 item_instance_id,
9151 quantity,
9152 seq: self.seq,
9153 })
9154 .await?;
9155 self.state.intents_sent += 1;
9156 Ok(())
9157 }
9158
9159 pub async fn storage_take(
9160 &mut self,
9161 item_instance_id: uuid::Uuid,
9162 quantity: Option<u32>,
9163 ) -> anyhow::Result<()> {
9164 let Some(panel) = self.state.storage_panel.clone() else {
9165 return Ok(());
9166 };
9167 self.seq += 1;
9168 self.session
9169 .submit_intent(Intent::StorageTake {
9170 entity_id: self.state.entity_id,
9171 npc_id: panel.npc_id,
9172 item_instance_id,
9173 quantity,
9174 seq: self.seq,
9175 })
9176 .await?;
9177 self.state.intents_sent += 1;
9178 Ok(())
9179 }
9180
9181 pub async fn storage_ship(
9182 &mut self,
9183 dest_building_id: String,
9184 item_instance_id: uuid::Uuid,
9185 quantity: Option<u32>,
9186 ) -> anyhow::Result<()> {
9187 let Some(panel) = self.state.storage_panel.clone() else {
9188 return Ok(());
9189 };
9190 self.seq += 1;
9191 self.session
9192 .submit_intent(Intent::StorageShip {
9193 entity_id: self.state.entity_id,
9194 npc_id: panel.npc_id,
9195 dest_building_id,
9196 item_instance_id,
9197 quantity,
9198 seq: self.seq,
9199 })
9200 .await?;
9201 self.state.intents_sent += 1;
9202 Ok(())
9203 }
9204
9205 pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
9206 let npc_id = self
9207 .state
9208 .storage_panel
9209 .as_ref()
9210 .map(|p| p.npc_id.clone());
9211 self.state.clear_storage_panel();
9212 if let Some(npc_id) = npc_id {
9213 self.seq += 1;
9214 self.session
9215 .submit_intent(Intent::StorageClose {
9216 entity_id: self.state.entity_id,
9217 npc_id,
9218 seq: self.seq,
9219 })
9220 .await?;
9221 self.state.intents_sent += 1;
9222 }
9223 Ok(())
9224 }
9225
9226 pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
9227 let npc_id = self
9228 .state
9229 .market_panel
9230 .as_ref()
9231 .map(|p| p.npc_id.clone());
9232 self.state.clear_market_panel();
9233 if let Some(npc_id) = npc_id {
9234 self.seq += 1;
9235 self.session
9236 .submit_intent(Intent::MarketClose {
9237 entity_id: self.state.entity_id,
9238 npc_id,
9239 seq: self.seq,
9240 })
9241 .await?;
9242 self.state.intents_sent += 1;
9243 }
9244 Ok(())
9245 }
9246
9247 pub fn market_move_selection(&mut self, delta: i32) {
9248 let indices = self.state.market_filtered_listing_indices();
9249 let n = indices.len();
9250 if n == 0 {
9251 self.state.market_menu_index = 0;
9252 return;
9253 }
9254 let cur = self.state.market_menu_index as i32;
9255 self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
9256 }
9257
9258 pub fn market_page_selection(&mut self, pages: i32) {
9259 let indices = self.state.market_filtered_listing_indices();
9260 let n = indices.len();
9261 if n == 0 {
9262 self.state.market_menu_index = 0;
9263 return;
9264 }
9265 self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
9266 }
9267
9268 pub fn market_list_page(&mut self, pages: i32) {
9269 match &self.state.market_ui_mode {
9270 MarketUiMode::ListSource { index } => {
9271 let n = self.state.market_list_source_options().len();
9272 if n == 0 {
9273 return;
9274 }
9275 let next = page_list_index(*index, pages, n);
9276 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
9277 }
9278 MarketUiMode::ListPricingMode { index, .. } => {
9279 let next = page_list_index(*index, pages, 2);
9280 if let MarketUiMode::ListPricingMode { index, .. } =
9281 &mut self.state.market_ui_mode
9282 {
9283 *index = next;
9284 }
9285 }
9286 MarketUiMode::ListPick { source, index } => {
9287 let opts = self.state.market_list_item_options(source);
9288 let n = opts.len();
9289 if n == 0 {
9290 return;
9291 }
9292 let next = page_list_index(*index, pages, n);
9293 self.state.market_ui_mode = MarketUiMode::ListPick {
9294 source: source.clone(),
9295 index: next,
9296 };
9297 }
9298 _ => {}
9299 }
9300 }
9301
9302 pub fn market_cycle_category(&mut self, delta: i32) {
9303 let groups = self.state.market_available_category_groups();
9304 let mut labels: Vec<Option<&'static str>> = vec![None];
9306 labels.extend(groups.into_iter().map(Some));
9307 let n = labels.len() as i32;
9308 let cur = labels
9309 .iter()
9310 .position(|g| *g == self.state.market_category_filter)
9311 .unwrap_or(0) as i32;
9312 let next = (cur + delta).rem_euclid(n) as usize;
9313 self.state.market_category_filter = labels[next];
9314 self.state.market_menu_index = 0;
9315 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9316 let source = source.clone();
9317 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9318 }
9319 }
9320
9321 pub fn focus_market_filter(&mut self) {
9322 self.state.market_filter_focused = true;
9323 }
9324
9325 pub fn append_market_filter_char(&mut self, ch: char) {
9326 if !self.state.market_filter_focused {
9327 return;
9328 }
9329 if ch.is_control() {
9330 return;
9331 }
9332 if self.state.market_filter.len() < 48 {
9333 self.state.market_filter.push(ch);
9334 self.state.market_menu_index = 0;
9335 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9336 let source = source.clone();
9337 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9338 }
9339 }
9340 }
9341
9342 pub fn market_filter_backspace(&mut self) {
9343 if !self.state.market_filter_focused {
9344 return;
9345 }
9346 self.state.market_filter.pop();
9347 self.state.market_menu_index = 0;
9348 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9349 let source = source.clone();
9350 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9351 }
9352 }
9353
9354 pub fn clear_or_blur_market_filter(&mut self) -> bool {
9356 if self.state.market_filter_focused {
9357 if !self.state.market_filter.is_empty() {
9358 self.state.market_filter.clear();
9359 self.state.market_menu_index = 0;
9360 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9361 let source = source.clone();
9362 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9363 }
9364 return true;
9365 }
9366 self.state.market_filter_focused = false;
9367 return true;
9368 }
9369 if !self.state.market_filter.is_empty() {
9370 self.state.market_filter.clear();
9371 self.state.market_menu_index = 0;
9372 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9373 let source = source.clone();
9374 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9375 }
9376 return true;
9377 }
9378 false
9379 }
9380
9381 pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
9382 if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
9383 return self.market_confirm_buy(listing_id, qty).await;
9384 }
9385 let Some(panel) = self.state.market_panel.clone() else {
9386 return Ok(());
9387 };
9388 let indices = self.state.market_filtered_listing_indices();
9389 let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
9390 return Ok(());
9391 };
9392 let Some(listing) = panel.listings.get(raw_idx) else {
9393 return Ok(());
9394 };
9395 if listing.mine {
9396 self.seq += 1;
9397 self.session
9398 .submit_intent(Intent::MarketDelist {
9399 entity_id: self.state.entity_id,
9400 npc_id: panel.npc_id.clone(),
9401 listing_id: listing.listing_id,
9402 dest: flatland_protocol::GoodsLocation::Person,
9403 seq: self.seq,
9404 })
9405 .await?;
9406 self.state.intents_sent += 1;
9407 return Ok(());
9408 }
9409 if listing.npc_price {
9410 self.state
9411 .push_log("NPC-price listings are bought by merchants only.");
9412 return Ok(());
9413 }
9414 let qty = 1u32.min(listing.quantity).max(1);
9415 let line = listing.unit_price_copper.saturating_mul(qty as u64);
9416 self.state.market_buy_confirm = Some((
9417 listing.listing_id,
9418 qty,
9419 listing.unit_price_copper,
9420 line,
9421 listing.display_name.clone(),
9422 ));
9423 Ok(())
9424 }
9425
9426 pub async fn market_confirm_buy(
9427 &mut self,
9428 listing_id: uuid::Uuid,
9429 quantity: u32,
9430 ) -> anyhow::Result<()> {
9431 let Some(panel) = self.state.market_panel.clone() else {
9432 self.state.market_buy_confirm = None;
9433 return Ok(());
9434 };
9435 self.state.market_buy_confirm = None;
9436 self.seq += 1;
9437 self.session
9438 .submit_intent(Intent::MarketBuy {
9439 entity_id: self.state.entity_id,
9440 npc_id: panel.npc_id,
9441 listing_id,
9442 quantity,
9443 dest: flatland_protocol::GoodsLocation::Person,
9444 seq: self.seq,
9445 })
9446 .await?;
9447 self.state.intents_sent += 1;
9448 Ok(())
9449 }
9450
9451 pub fn market_begin_list(&mut self) {
9453 if self.state.market_panel.is_none() {
9454 return;
9455 }
9456 let sources = self.state.market_list_source_options();
9457 if sources.is_empty() {
9458 self.state.push_log("Nothing to list from.");
9459 return;
9460 }
9461 if sources.len() == 1 {
9463 let (source, _) = sources[0].clone();
9464 let opts = self.state.market_list_item_options(&source);
9465 if opts.is_empty() {
9466 self.state.push_log("Nothing loose to list.");
9467 return;
9468 }
9469 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9470 self.state.market_buy_confirm = None;
9471 return;
9472 }
9473 self.state.market_buy_confirm = None;
9474 self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
9475 }
9476
9477 pub fn market_ui_back(&mut self) {
9478 self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
9479 MarketUiMode::Browse => MarketUiMode::Browse,
9480 MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
9481 MarketUiMode::ListPick { .. } => {
9482 if self.state.market_list_source_options().len() <= 1 {
9483 MarketUiMode::Browse
9484 } else {
9485 MarketUiMode::ListSource { index: 0 }
9486 }
9487 }
9488 MarketUiMode::ListAmount {
9489 source,
9490 pick_index,
9491 ..
9492 } => MarketUiMode::ListPick {
9493 source,
9494 index: pick_index,
9495 },
9496 MarketUiMode::ListPricingMode {
9497 source,
9498 item_instance_id,
9499 label,
9500 max_qty,
9501 quantity,
9502 ..
9503 } => {
9504 let input = quantity.map(|q| q.to_string()).unwrap_or_default();
9505 MarketUiMode::ListAmount {
9506 source,
9507 pick_index: 0,
9508 item_instance_id,
9509 label,
9510 max_qty,
9511 input,
9512 }
9513 }
9514 MarketUiMode::ListPrice {
9515 source,
9516 item_instance_id,
9517 label,
9518 max_qty,
9519 quantity,
9520 ..
9521 } => MarketUiMode::ListPricingMode {
9522 source,
9523 item_instance_id,
9524 label,
9525 quantity,
9526 max_qty,
9527 index: 1,
9528 },
9529 };
9530 }
9531
9532 pub fn market_list_move(&mut self, delta: i32) {
9533 match &self.state.market_ui_mode {
9534 MarketUiMode::ListSource { index } => {
9535 let n = self.state.market_list_source_options().len();
9536 if n == 0 {
9537 return;
9538 }
9539 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9540 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
9541 }
9542 MarketUiMode::ListPricingMode { index, .. } => {
9543 let next = (*index as i32 + delta).rem_euclid(2) as usize;
9544 if let MarketUiMode::ListPricingMode { index, .. } =
9545 &mut self.state.market_ui_mode
9546 {
9547 *index = next;
9548 }
9549 }
9550 MarketUiMode::ListPick { source, index } => {
9551 let opts = self.state.market_list_item_options(source);
9552 let n = opts.len();
9553 if n == 0 {
9554 return;
9555 }
9556 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9557 self.state.market_ui_mode = MarketUiMode::ListPick {
9558 source: source.clone(),
9559 index: next,
9560 };
9561 }
9562 _ => {}
9563 }
9564 }
9565
9566 pub fn market_list_amount_append_char(&mut self, c: char) {
9567 if !c.is_ascii_digit() {
9568 return;
9569 }
9570 match &mut self.state.market_ui_mode {
9571 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
9572 if input.len() < 12 {
9573 input.push(c);
9574 }
9575 }
9576 _ => {}
9577 }
9578 }
9579
9580 pub fn market_list_amount_backspace(&mut self) {
9581 match &mut self.state.market_ui_mode {
9582 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
9583 input.pop();
9584 }
9585 _ => {}
9586 }
9587 }
9588
9589 pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
9590 match self.state.market_ui_mode.clone() {
9591 MarketUiMode::Browse => Ok(()),
9592 MarketUiMode::ListSource { index } => {
9593 let sources = self.state.market_list_source_options();
9594 let Some((source, _)) = sources.get(index).cloned() else {
9595 return Ok(());
9596 };
9597 let opts = self.state.market_list_item_options(&source);
9598 if opts.is_empty() {
9599 self.state.push_log("Nothing to list from that source.");
9600 return Ok(());
9601 }
9602 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9603 Ok(())
9604 }
9605 MarketUiMode::ListPick { source, index } => {
9606 let opts = self.state.market_list_item_options(&source);
9607 let Some(opt) = opts.get(index) else {
9608 self.state.push_log("Nothing to list.");
9609 self.state.market_ui_mode = MarketUiMode::Browse;
9610 return Ok(());
9611 };
9612 self.state.market_ui_mode = MarketUiMode::ListAmount {
9613 source,
9614 pick_index: index,
9615 item_instance_id: opt.item_instance_id,
9616 label: opt.label.clone(),
9617 max_qty: opt.quantity.max(1),
9618 input: String::new(),
9619 };
9620 Ok(())
9621 }
9622 MarketUiMode::ListAmount {
9623 source,
9624 item_instance_id,
9625 label,
9626 max_qty,
9627 input,
9628 ..
9629 } => {
9630 let Some(qty_opt) = parse_storage_quantity(&input) else {
9631 self.state.push_log("Enter a quantity (blank = all).");
9632 return Ok(());
9633 };
9634 if let Some(q) = qty_opt {
9635 if q > max_qty {
9636 self.state
9637 .push_log(format!("Only {max_qty} available."));
9638 return Ok(());
9639 }
9640 }
9641 self.state.market_ui_mode = MarketUiMode::ListPricingMode {
9642 source,
9643 item_instance_id,
9644 label,
9645 quantity: qty_opt,
9646 max_qty,
9647 index: 0,
9648 };
9649 Ok(())
9650 }
9651 MarketUiMode::ListPricingMode {
9652 source,
9653 item_instance_id,
9654 label,
9655 quantity,
9656 max_qty,
9657 index,
9658 } => {
9659 if index == 0 {
9660 return self
9661 .submit_market_list_intent(
9662 source,
9663 item_instance_id,
9664 quantity,
9665 0,
9666 true,
9667 &label,
9668 )
9669 .await;
9670 }
9671 self.state.market_ui_mode = MarketUiMode::ListPrice {
9672 source,
9673 item_instance_id,
9674 label,
9675 quantity,
9676 max_qty,
9677 input: String::new(),
9678 };
9679 Ok(())
9680 }
9681 MarketUiMode::ListPrice {
9682 source,
9683 item_instance_id,
9684 label,
9685 quantity,
9686 input,
9687 ..
9688 } => {
9689 let price = input.trim().parse::<u64>().unwrap_or(0);
9690 if price == 0 {
9691 self.state.push_log("Enter a unit price of at least 1 copper.");
9692 return Ok(());
9693 }
9694 self.submit_market_list_intent(
9695 source,
9696 item_instance_id,
9697 quantity,
9698 price,
9699 false,
9700 &label,
9701 )
9702 .await
9703 }
9704 }
9705 }
9706
9707 async fn submit_market_list_intent(
9708 &mut self,
9709 source: MarketListSourceKind,
9710 item_instance_id: uuid::Uuid,
9711 quantity: Option<u32>,
9712 unit_price_copper: u64,
9713 npc_price: bool,
9714 label: &str,
9715 ) -> anyhow::Result<()> {
9716 let Some(panel) = self.state.market_panel.clone() else {
9717 self.state.market_ui_mode = MarketUiMode::Browse;
9718 return Ok(());
9719 };
9720 let goods = match source {
9721 MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
9722 MarketListSourceKind::TownStorage { building_id } => {
9723 flatland_protocol::GoodsLocation::TownStorage { building_id }
9724 }
9725 };
9726 self.seq += 1;
9727 self.session
9728 .submit_intent(Intent::MarketList {
9729 entity_id: self.state.entity_id,
9730 npc_id: panel.npc_id,
9731 source: goods,
9732 item_instance_id,
9733 quantity,
9734 unit_price_copper,
9735 npc_price,
9736 seq: self.seq,
9737 })
9738 .await?;
9739 self.state.intents_sent += 1;
9740 if npc_price {
9741 self.state.push_log(format!("Listing {label} at NPC price…"));
9742 } else {
9743 self.state
9744 .push_log(format!("Listing {label} @ {unit_price_copper} cp…"));
9745 }
9746 self.state.market_ui_mode = MarketUiMode::Browse;
9747 Ok(())
9748 }
9749
9750 pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
9752 let return_to_verbs = self.state.npc_verb_target.is_some();
9753 self.close_shop_menu().await?;
9754 if return_to_verbs {
9755 self.state.show_npc_verb_menu = true;
9756 }
9757 Ok(())
9758 }
9759
9760 pub fn shop_tab_toggle(&mut self) {
9761 self.state.shop_tab = match self.state.shop_tab {
9762 ShopTab::Buy => ShopTab::Sell,
9763 ShopTab::Sell => ShopTab::Buy,
9764 };
9765 self.state.shop_menu_index = 0;
9766 if self.state.shop_tab == ShopTab::Sell {
9767 self.state.shop_quantity_set_max();
9768 }
9769 self.state.clamp_shop_selection();
9770 }
9771
9772 pub fn shop_menu_move(&mut self, delta: i32) {
9773 self.state.shop_menu_move(delta);
9774 }
9775
9776 pub fn shop_quantity_adjust(&mut self, delta: i32) {
9777 self.state.shop_quantity_adjust(delta);
9778 }
9779
9780 pub fn shop_quantity_set_max(&mut self) {
9781 self.state.shop_quantity_set_max();
9782 }
9783
9784 pub fn shop_quantity_set_min(&mut self) {
9785 self.state.shop_quantity_set_min();
9786 }
9787
9788 pub fn toggle_quest_menu(&mut self) {
9789 self.state.show_quest_menu = !self.state.show_quest_menu;
9790 if self.state.show_quest_menu {
9791 self.state.quest_menu_index = 0;
9792 self.state.quest_withdraw_confirm = false;
9793 self.state.show_workers_menu = false;
9794 }
9795 }
9796
9797 pub fn toggle_workers_menu(&mut self) {
9798 if self.state.show_workers_menu {
9799 self.close_workers_menu_ui();
9800 } else {
9801 self.state.show_workers_menu = true;
9802 self.state.workers_menu_index = 0;
9803 self.state.show_quest_menu = false;
9804 self.close_worker_give_picker();
9805 self.close_worker_give_target_picker();
9806 self.close_worker_take_picker();
9807 self.close_worker_teach_picker();
9808 self.cancel_worker_rename();
9809 }
9810 }
9811
9812 pub fn close_workers_menu_ui(&mut self) {
9814 self.state.show_workers_menu = false;
9815 self.close_worker_give_picker();
9816 self.close_worker_give_target_picker();
9817 self.close_worker_take_picker();
9818 self.close_worker_teach_picker();
9819 self.cancel_worker_rename();
9820 }
9821
9822 pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
9824 let Some(idx) = self
9825 .state
9826 .hired_workers
9827 .iter()
9828 .position(|w| w.instance_id == instance_id)
9829 else {
9830 anyhow::bail!("worker not found");
9831 };
9832 let label = self.state.hired_workers[idx].label.clone();
9833 self.state.show_workers_menu = true;
9834 self.state.workers_menu_index = idx;
9835 self.state.show_quest_menu = false;
9836 self.close_worker_give_picker();
9837 self.close_worker_give_target_picker();
9838 self.close_worker_take_picker();
9839 self.close_worker_teach_picker();
9840 self.cancel_worker_rename();
9841 self.set_worker_attending(instance_id, true).await?;
9842 self.state
9843 .push_log(format!("Managing {label} — job paused while menu is open"));
9844 Ok(())
9845 }
9846
9847 pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
9849 self.close_workers_menu_ui();
9850 self.release_worker_attend().await
9851 }
9852
9853 async fn set_worker_attending(
9854 &mut self,
9855 instance_id: &str,
9856 attending: bool,
9857 ) -> anyhow::Result<()> {
9858 if attending {
9859 if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9860 return Ok(());
9861 }
9862 if let Some(prev) = self.state.attending_worker_instance_id.clone() {
9864 if prev != instance_id {
9865 self.send_attend_hired_worker(&prev, false).await?;
9866 }
9867 }
9868 self.send_attend_hired_worker(instance_id, true).await?;
9869 self.state.attending_worker_instance_id = Some(instance_id.to_string());
9870 } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9871 self.send_attend_hired_worker(instance_id, false).await?;
9872 self.state.attending_worker_instance_id = None;
9873 }
9874 Ok(())
9875 }
9876
9877 pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
9878 let Some(id) = self.state.attending_worker_instance_id.take() else {
9879 return Ok(());
9880 };
9881 self.send_attend_hired_worker(&id, false).await
9882 }
9883
9884 async fn send_attend_hired_worker(
9885 &mut self,
9886 worker_instance_id: &str,
9887 attending: bool,
9888 ) -> anyhow::Result<()> {
9889 self.seq += 1;
9890 self.session
9891 .submit_intent(Intent::AttendHiredWorker {
9892 entity_id: self.state.entity_id,
9893 worker_instance_id: worker_instance_id.to_string(),
9894 attending,
9895 seq: self.seq,
9896 })
9897 .await?;
9898 self.state.intents_sent += 1;
9899 Ok(())
9900 }
9901
9902 pub fn workers_menu_move(&mut self, delta: i32) {
9903 let n = self.state.hired_workers.len();
9904 if n == 0 {
9905 return;
9906 }
9907 let idx = self.state.workers_menu_index as i32;
9908 self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9909 }
9910
9911 pub fn toggle_workers_menu_compact(&mut self) {
9912 self.state.workers_menu_compact = !self.state.workers_menu_compact;
9913 let mut cfg = crate::client_config::ClientConfig::load();
9914 let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
9915 }
9916
9917 pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
9918 let Some(worker) = self
9919 .state
9920 .hired_workers
9921 .get(self.state.workers_menu_index)
9922 .cloned()
9923 else {
9924 anyhow::bail!("no worker selected");
9925 };
9926 self.seq += 1;
9927 self.session
9928 .submit_intent(Intent::DismissWorker {
9929 entity_id: self.state.entity_id,
9930 worker_instance_id: worker.instance_id.clone(),
9931 seq: self.seq,
9932 })
9933 .await?;
9934 self.state.intents_sent += 1;
9935 self.state
9936 .hired_workers
9937 .retain(|w| w.instance_id != worker.instance_id);
9938 if self.state.workers_menu_index >= self.state.hired_workers.len() {
9939 self.state.workers_menu_index = self
9940 .state
9941 .hired_workers
9942 .len()
9943 .saturating_sub(1);
9944 }
9945 self.state.push_log(format!("Dismissed {}", worker.label));
9946 Ok(())
9947 }
9948
9949 pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
9950 let Some(worker) = self
9951 .state
9952 .hired_workers
9953 .get(self.state.workers_menu_index)
9954 .cloned()
9955 else {
9956 anyhow::bail!("no worker selected");
9957 };
9958 let mode = match worker.mode {
9959 flatland_protocol::WorkerModeView::Companion => "job_loop",
9960 flatland_protocol::WorkerModeView::JobLoop => "idle",
9961 flatland_protocol::WorkerModeView::Idle => "companion",
9962 };
9963 self.seq += 1;
9964 self.session
9965 .submit_intent(Intent::SetWorkerMode {
9966 entity_id: self.state.entity_id,
9967 worker_instance_id: worker.instance_id,
9968 mode: mode.into(),
9969 seq: self.seq,
9970 })
9971 .await?;
9972 self.state.intents_sent += 1;
9973 Ok(())
9974 }
9975
9976 pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
9977 if self.state.hired_workers.is_empty() {
9978 return self.hire_worker_laborer().await;
9979 }
9980 self.workers_toggle_mode_selected().await
9981 }
9982
9983 pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
9986 let row = self
9987 .state
9988 .inventory_selected_row()
9989 .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
9990 .clone();
9991 if row.from != flatland_protocol::InventoryLocation::Root {
9992 anyhow::bail!("select a carried item to give");
9993 }
9994 let Some(instance_id) = row.stack.item_instance_id else {
9995 anyhow::bail!("that stack can't be given");
9996 };
9997 let options = self.nearby_worker_give_targets();
9998 if options.is_empty() {
9999 anyhow::bail!(
10000 "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
10001 );
10002 }
10003 let item_label = row
10004 .stack
10005 .display_name
10006 .as_deref()
10007 .unwrap_or(&row.stack.template_id)
10008 .to_string();
10009 self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
10010 item_instance_id: instance_id,
10011 item_label,
10012 quantity: None,
10013 options,
10014 });
10015 self.state.worker_give_target_picker_index = 0;
10016 self.state.show_worker_give_target_picker = true;
10017 self.state.show_inventory_menu = false;
10019 Ok(())
10020 }
10021
10022 pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
10024 let (px, py, _) = self.state.player_position_with_z();
10025 let mut options: Vec<WorkerGiveTargetOption> = self
10026 .state
10027 .hired_workers
10028 .iter()
10029 .filter_map(|w| {
10030 let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
10031 if dist > WORKER_GIVE_RANGE_M {
10032 return None;
10033 }
10034 Some(WorkerGiveTargetOption {
10035 instance_id: w.instance_id.clone(),
10036 label: w.label.clone(),
10037 distance_m: dist,
10038 })
10039 })
10040 .collect();
10041 options.sort_by(|a, b| {
10042 a.distance_m
10043 .partial_cmp(&b.distance_m)
10044 .unwrap_or(std::cmp::Ordering::Equal)
10045 });
10046 options
10047 }
10048
10049 pub fn close_worker_give_target_picker(&mut self) {
10050 self.state.show_worker_give_target_picker = false;
10051 self.state.worker_give_target_picker = None;
10052 self.state.worker_give_target_picker_index = 0;
10053 }
10054
10055 pub fn worker_give_target_picker_move(&mut self, delta: i32) {
10056 let Some(picker) = &self.state.worker_give_target_picker else {
10057 return;
10058 };
10059 let n = picker.options.len();
10060 if n == 0 {
10061 return;
10062 }
10063 let idx = self.state.worker_give_target_picker_index as i32;
10064 self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10065 }
10066
10067 pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
10068 let Some(picker) = self.state.worker_give_target_picker.clone() else {
10069 anyhow::bail!("give target picker not open");
10070 };
10071 let Some(opt) = picker
10072 .options
10073 .get(self.state.worker_give_target_picker_index)
10074 .cloned()
10075 else {
10076 anyhow::bail!("no worker selected");
10077 };
10078 let Some(worker) = self
10079 .state
10080 .hired_workers
10081 .iter()
10082 .find(|w| w.instance_id == opt.instance_id)
10083 .cloned()
10084 else {
10085 self.close_worker_give_target_picker();
10086 anyhow::bail!("worker no longer hired");
10087 };
10088 self.give_item_to_worker(
10089 &worker.instance_id,
10090 &worker.label,
10091 worker.x,
10092 worker.y,
10093 picker.item_instance_id,
10094 &picker.item_label,
10095 picker.quantity,
10096 )
10097 .await?;
10098 self.close_worker_give_target_picker();
10099 Ok(())
10100 }
10101
10102 pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
10104 self.open_worker_give_target_picker()
10105 }
10106
10107 pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
10109 let Some(worker) = self
10110 .state
10111 .hired_workers
10112 .get(self.state.workers_menu_index)
10113 .cloned()
10114 else {
10115 anyhow::bail!("select a hired worker first");
10116 };
10117 let (px, py, _) = self.state.player_position_with_z();
10118 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10119 if dist > WORKER_GIVE_RANGE_M {
10120 anyhow::bail!(
10121 "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
10122 worker.label
10123 );
10124 }
10125 let options = self.state.giveable_inventory_options();
10126 if options.is_empty() {
10127 anyhow::bail!("nothing in inventory to give");
10128 }
10129 self.state.worker_give_picker = Some(WorkerGivePicker {
10130 worker_instance_id: worker.instance_id,
10131 worker_label: worker.label,
10132 options,
10133 });
10134 self.state.worker_give_picker_index = 0;
10135 self.state.show_worker_give_picker = true;
10136 Ok(())
10137 }
10138
10139 pub fn close_worker_give_picker(&mut self) {
10140 self.state.show_worker_give_picker = false;
10141 self.state.worker_give_picker = None;
10142 self.state.worker_give_picker_index = 0;
10143 }
10144
10145 pub fn worker_give_picker_move(&mut self, delta: i32) {
10146 let Some(picker) = &self.state.worker_give_picker else {
10147 return;
10148 };
10149 let n = picker.options.len();
10150 if n == 0 {
10151 return;
10152 }
10153 let idx = self.state.worker_give_picker_index as i32;
10154 self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10155 }
10156
10157 pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
10159 let Some(picker) = self.state.worker_give_picker.clone() else {
10160 anyhow::bail!("give picker not open");
10161 };
10162 let Some(opt) = picker.options.get(self.state.worker_give_picker_index).cloned() else {
10163 anyhow::bail!("no item selected");
10164 };
10165 let Some(worker) = self
10166 .state
10167 .hired_workers
10168 .iter()
10169 .find(|w| w.instance_id == picker.worker_instance_id)
10170 .cloned()
10171 else {
10172 self.close_worker_give_picker();
10173 anyhow::bail!("worker no longer hired");
10174 };
10175 self.give_item_to_worker(
10176 &worker.instance_id,
10177 &worker.label,
10178 worker.x,
10179 worker.y,
10180 opt.item_instance_id,
10181 &opt.label,
10182 None,
10183 )
10184 .await?;
10185 let options = self.state.giveable_inventory_options();
10187 if options.is_empty() {
10188 self.close_worker_give_picker();
10189 } else {
10190 self.state.worker_give_picker = Some(WorkerGivePicker {
10191 worker_instance_id: picker.worker_instance_id,
10192 worker_label: picker.worker_label,
10193 options,
10194 });
10195 if self.state.worker_give_picker_index
10196 >= self
10197 .state
10198 .worker_give_picker
10199 .as_ref()
10200 .map(|p| p.options.len())
10201 .unwrap_or(0)
10202 {
10203 self.state.worker_give_picker_index = self
10204 .state
10205 .worker_give_picker
10206 .as_ref()
10207 .map(|p| p.options.len().saturating_sub(1))
10208 .unwrap_or(0);
10209 }
10210 }
10211 Ok(())
10212 }
10213
10214 pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
10216 let Some(worker) = self
10217 .state
10218 .hired_workers
10219 .get(self.state.workers_menu_index)
10220 .cloned()
10221 else {
10222 anyhow::bail!("select a hired worker first");
10223 };
10224 let (px, py, _) = self.state.player_position_with_z();
10225 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10226 if dist > WORKER_GIVE_RANGE_M {
10227 anyhow::bail!(
10228 "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
10229 worker.label
10230 );
10231 }
10232 let options = self.state.teachable_blueprint_options(&worker);
10233 if options.is_empty() {
10234 anyhow::bail!("no recipes you know that {} still needs", worker.label);
10235 }
10236 self.state.worker_teach_picker = Some(WorkerTeachPicker {
10237 worker_instance_id: worker.instance_id,
10238 worker_label: worker.label,
10239 worker_level: worker.level,
10240 options,
10241 });
10242 self.state.worker_teach_picker_index = 0;
10243 self.state.show_worker_teach_picker = true;
10244 Ok(())
10245 }
10246
10247 pub fn close_worker_teach_picker(&mut self) {
10248 self.state.show_worker_teach_picker = false;
10249 self.state.worker_teach_picker = None;
10250 self.state.worker_teach_picker_index = 0;
10251 }
10252
10253 pub fn worker_teach_picker_move(&mut self, delta: i32) {
10254 let Some(picker) = &self.state.worker_teach_picker else {
10255 return;
10256 };
10257 let n = picker.options.len();
10258 if n == 0 {
10259 return;
10260 }
10261 let idx = self.state.worker_teach_picker_index as i32;
10262 self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10263 }
10264
10265 pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
10266 let Some(picker) = self.state.worker_teach_picker.clone() else {
10267 anyhow::bail!("teach picker not open");
10268 };
10269 let Some(opt) = picker.options.get(self.state.worker_teach_picker_index).cloned() else {
10270 anyhow::bail!("nothing selected");
10271 };
10272 if !opt.level_ok {
10273 anyhow::bail!(
10274 "{} needs level {} (is level {})",
10275 picker.worker_label,
10276 opt.min_level,
10277 opt.worker_level
10278 );
10279 }
10280 if !opt.can_afford {
10281 anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
10282 }
10283 let Some(worker) = self
10284 .state
10285 .hired_workers
10286 .iter()
10287 .find(|w| w.instance_id == picker.worker_instance_id)
10288 .cloned()
10289 else {
10290 anyhow::bail!("worker gone");
10291 };
10292 let (px, py, _) = self.state.player_position_with_z();
10293 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10294 if dist > WORKER_GIVE_RANGE_M {
10295 anyhow::bail!("worker {} too far — stand next to them", worker.label);
10296 }
10297 self.seq += 1;
10298 self.session
10299 .submit_intent(Intent::TeachWorkerBlueprint {
10300 entity_id: self.state.entity_id,
10301 worker_instance_id: picker.worker_instance_id.clone(),
10302 blueprint_id: opt.blueprint_id.clone(),
10303 seq: self.seq,
10304 })
10305 .await?;
10306 self.state.intents_sent += 1;
10307 self.state.push_log(format!(
10308 "Teaching {} to {} ({} cp)",
10309 opt.label, picker.worker_label, opt.cost_copper
10310 ));
10311 self.close_worker_teach_picker();
10312 Ok(())
10313 }
10314
10315 async fn give_item_to_worker(
10316 &mut self,
10317 worker_instance_id: &str,
10318 worker_label: &str,
10319 worker_x: f32,
10320 worker_y: f32,
10321 item_instance_id: uuid::Uuid,
10322 item_label: &str,
10323 quantity: Option<u32>,
10324 ) -> anyhow::Result<()> {
10325 let (px, py, _) = self.state.player_position_with_z();
10326 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
10327 if dist > WORKER_GIVE_RANGE_M {
10328 anyhow::bail!("worker {worker_label} too far — stand next to them");
10329 }
10330 self.seq += 1;
10331 self.session
10332 .submit_intent(Intent::GiveWorkerItem {
10333 entity_id: self.state.entity_id,
10334 worker_instance_id: worker_instance_id.to_string(),
10335 item_instance_id,
10336 quantity,
10337 seq: self.seq,
10338 })
10339 .await?;
10340 self.state.intents_sent += 1;
10341 self.state
10342 .push_log(format!("Gave {item_label} to {worker_label}"));
10343 Ok(())
10344 }
10345
10346 pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
10348 let Some(worker) = self
10349 .state
10350 .hired_workers
10351 .get(self.state.workers_menu_index)
10352 .cloned()
10353 else {
10354 anyhow::bail!("select a hired worker first");
10355 };
10356 let (px, py, _) = self.state.player_position_with_z();
10357 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10358 if dist > WORKER_GIVE_RANGE_M {
10359 anyhow::bail!(
10360 "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
10361 worker.label
10362 );
10363 }
10364 let options = Self::worker_inventory_options(&worker);
10365 if options.is_empty() {
10366 anyhow::bail!("{} isn't carrying anything", worker.label);
10367 }
10368 let initial_qty = options
10369 .first()
10370 .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
10371 .unwrap_or(1);
10372 self.state.worker_take_picker = Some(WorkerTakePicker {
10373 worker_instance_id: worker.instance_id,
10374 worker_label: worker.label,
10375 options,
10376 quantity: initial_qty,
10377 });
10378 self.state.worker_take_picker_index = 0;
10379 self.state.show_worker_take_picker = true;
10380 Ok(())
10381 }
10382
10383 fn worker_inventory_options(
10384 worker: &flatland_protocol::HiredWorkerView,
10385 ) -> Vec<WorkerGiveOption> {
10386 worker
10387 .inventory
10388 .iter()
10389 .filter_map(|stack| {
10390 let item_instance_id = stack.item_instance_id?;
10391 let label = stack
10392 .display_name
10393 .clone()
10394 .unwrap_or_else(|| stack.template_id.clone());
10395 let label = if stack.quantity > 1 {
10396 format!("{label} ×{}", stack.quantity)
10397 } else {
10398 label
10399 };
10400 Some(WorkerGiveOption {
10401 item_instance_id,
10402 label,
10403 quantity: stack.quantity,
10404 template_id: stack.template_id.clone(),
10405 })
10406 })
10407 .collect()
10408 }
10409
10410 pub fn close_worker_take_picker(&mut self) {
10411 self.state.show_worker_take_picker = false;
10412 self.state.worker_take_picker = None;
10413 self.state.worker_take_picker_index = 0;
10414 }
10415
10416 pub fn worker_take_picker_move(&mut self, delta: i32) {
10417 let Some(picker) = &self.state.worker_take_picker else {
10418 return;
10419 };
10420 let n = picker.options.len();
10421 if n == 0 {
10422 return;
10423 }
10424 let idx = self.state.worker_take_picker_index as i32;
10425 self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10426 self.clamp_worker_take_quantity();
10427 }
10428
10429 pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
10430 let Some(picker) = &mut self.state.worker_take_picker else {
10431 return;
10432 };
10433 let max = picker
10434 .options
10435 .get(self.state.worker_take_picker_index)
10436 .map(|o| o.quantity.max(1))
10437 .unwrap_or(1);
10438 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
10439 picker.quantity = next as u32;
10440 }
10441
10442 pub fn worker_take_picker_set_quantity_max(&mut self) {
10443 let Some(picker) = &mut self.state.worker_take_picker else {
10444 return;
10445 };
10446 let max = picker
10447 .options
10448 .get(self.state.worker_take_picker_index)
10449 .map(|o| o.quantity.max(1))
10450 .unwrap_or(1);
10451 picker.quantity = max;
10452 }
10453
10454 pub fn worker_take_picker_set_quantity_min(&mut self) {
10455 let Some(picker) = &mut self.state.worker_take_picker else {
10456 return;
10457 };
10458 picker.quantity = 1;
10459 self.clamp_worker_take_quantity();
10460 }
10461
10462 fn clamp_worker_take_quantity(&mut self) {
10463 let Some(picker) = &mut self.state.worker_take_picker else {
10464 return;
10465 };
10466 let max = picker
10467 .options
10468 .get(self.state.worker_take_picker_index)
10469 .map(|o| o.quantity.max(1))
10470 .unwrap_or(1);
10471 if picker.quantity == 0 || picker.quantity > max {
10472 picker.quantity = if max > 1 { 1 } else { max };
10473 }
10474 }
10475
10476 pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
10477 let Some(picker) = self.state.worker_take_picker.clone() else {
10478 anyhow::bail!("take picker not open");
10479 };
10480 let Some(opt) = picker.options.get(self.state.worker_take_picker_index).cloned() else {
10481 anyhow::bail!("no item selected");
10482 };
10483 let Some(worker) = self
10484 .state
10485 .hired_workers
10486 .iter()
10487 .find(|w| w.instance_id == picker.worker_instance_id)
10488 .cloned()
10489 else {
10490 self.close_worker_take_picker();
10491 anyhow::bail!("worker no longer hired");
10492 };
10493 let qty = picker.quantity.clamp(1, opt.quantity.max(1));
10494 let intent_qty = if qty >= opt.quantity {
10495 None
10496 } else {
10497 Some(qty)
10498 };
10499 self.take_item_from_worker(
10500 &worker.instance_id,
10501 &worker.label,
10502 worker.x,
10503 worker.y,
10504 opt.item_instance_id,
10505 &opt.label,
10506 intent_qty,
10507 )
10508 .await?;
10509 Ok(())
10512 }
10513
10514 async fn take_item_from_worker(
10515 &mut self,
10516 worker_instance_id: &str,
10517 worker_label: &str,
10518 worker_x: f32,
10519 worker_y: f32,
10520 item_instance_id: uuid::Uuid,
10521 item_label: &str,
10522 quantity: Option<u32>,
10523 ) -> anyhow::Result<()> {
10524 let (px, py, _) = self.state.player_position_with_z();
10525 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
10526 if dist > WORKER_GIVE_RANGE_M {
10527 anyhow::bail!("worker {worker_label} too far — stand next to them");
10528 }
10529 self.seq += 1;
10530 self.session
10531 .submit_intent(Intent::TakeWorkerItem {
10532 entity_id: self.state.entity_id,
10533 worker_instance_id: worker_instance_id.to_string(),
10534 item_instance_id,
10535 quantity,
10536 seq: self.seq,
10537 })
10538 .await?;
10539 self.state.intents_sent += 1;
10540 let qty_note = quantity
10541 .map(|q| format!(" ×{q}"))
10542 .unwrap_or_default();
10543 self.state
10544 .push_log(format!("Taking {item_label}{qty_note} from {worker_label}…"));
10545 Ok(())
10546 }
10547
10548 pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
10549 if !self.state.has_worker_lodging() {
10550 anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
10551 }
10552 self.seq += 1;
10553 self.session
10554 .submit_intent(Intent::HireWorker {
10555 entity_id: self.state.entity_id,
10556 def_id: "worker_laborer".into(),
10557 wage_copper_per_interval: 8,
10558 lodging_container_id: None,
10559 job_yaml: None,
10560 seq: self.seq,
10561 })
10562 .await?;
10563 self.state.intents_sent += 1;
10564 Ok(())
10565 }
10566
10567 pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
10568 let Some(worker) = self
10569 .state
10570 .hired_workers
10571 .get(self.state.workers_menu_index)
10572 .cloned()
10573 else {
10574 anyhow::bail!("select a hired worker first");
10575 };
10576 let lodging = worker.lodging_container_id.clone().or_else(|| {
10577 crate::worker_route_editor::owned_lodging_container_ids(
10578 &self.state.placed_containers,
10579 self.state.character_id,
10580 )
10581 .into_iter()
10582 .next()
10583 .map(|(id, _)| id)
10584 });
10585 let label = worker.label.clone();
10586 let editor = if let Some(route) = &worker.route {
10587 crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
10588 worker.instance_id,
10589 worker.label,
10590 route,
10591 lodging,
10592 )
10593 } else {
10594 crate::worker_route_editor::WorkerRouteEditorState::new(
10595 worker.instance_id,
10596 worker.label,
10597 lodging,
10598 )
10599 };
10600 self.state.worker_route_editor = Some(editor);
10601 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10602 if let Some(collapsed) =
10603 crate::client_config::ClientConfig::load().worker_route_panel_collapsed
10604 {
10605 ed.panel_collapsed = collapsed;
10606 }
10607 }
10608 self.state.show_workers_menu = false;
10609 self.state.push_log(format!(
10610 "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
10611 ));
10612 Ok(())
10613 }
10614
10615 pub fn close_worker_route_editor(&mut self) {
10616 self.state.worker_route_editor = None;
10617 }
10618
10619 pub fn worker_route_editor_toggle_panel(&mut self) {
10620 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10621 ed.toggle_panel_collapsed();
10622 let collapsed = ed.panel_collapsed;
10623 let mut cfg = crate::client_config::ClientConfig::load();
10624 let _ = cfg.save_worker_route_panel_collapsed(collapsed);
10625 }
10626 }
10627
10628 pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
10629 let n = {
10630 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10631 return;
10632 };
10633 ed.append_waypoint(x, y, z);
10634 ed.stop_count()
10635 };
10636 self.state
10637 .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
10638 }
10639
10640 fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
10643 let (px, py, _) = self.state.player_position_with_z();
10644 let inside = self.state.effective_inside_building();
10645 crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
10646 &self.state.placed_containers,
10647 &self.state.buildings,
10648 self.state.character_id,
10649 px,
10650 py,
10651 &self.state.hired_workers,
10652 inside.as_deref(),
10653 )
10654 }
10655
10656 fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
10657 self.state.route_editor_node_candidates()
10658 }
10659
10660 fn re_open_harvest_picker(
10661 &mut self,
10662 index: usize,
10663 picked: std::collections::BTreeSet<String>,
10664 ) {
10665 use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
10666 let nodes = self.state.route_editor_node_candidates();
10667 let index = if nodes.is_empty() {
10668 ROUTE_PICKER_DONE_ROW
10669 } else {
10670 index.max(1).min(nodes.len())
10671 };
10672 self.re_open_sheet(S::HarvestPicker {
10673 index,
10674 picked,
10675 nodes,
10676 });
10677 }
10678
10679 fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
10680 let (px, py, _) = self.state.player_position_with_z();
10681 crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
10682 }
10683
10684 fn re_template_candidates(&self) -> Vec<String> {
10685 let mut extra = Vec::new();
10686 if let Some(ed) = self.state.worker_route_editor.as_ref() {
10687 for stop in &ed.stops {
10688 match stop {
10689 crate::worker_route_editor::WorkerRouteStop::DepositAt {
10690 filter: Some(filter),
10691 ..
10692 } => extra.extend(filter.iter().cloned()),
10693 crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
10694 extra.push(template.clone());
10695 }
10696 crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
10697 if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint) {
10698 extra.push(bp.output.clone());
10699 for input in &bp.inputs {
10700 extra.push(input.template_id.clone());
10701 }
10702 }
10703 }
10704 crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
10705 for it in items {
10706 extra.push(it.template.clone());
10707 }
10708 }
10709 _ => {}
10710 }
10711 }
10712 if let Some(worker) = self
10714 .state
10715 .hired_workers
10716 .iter()
10717 .find(|w| w.instance_id == ed.worker_instance_id)
10718 {
10719 for recipe in &worker.known_blueprint_ids {
10720 if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
10721 extra.push(bp.output.clone());
10722 }
10723 }
10724 }
10725 }
10726 crate::worker_route_editor::route_item_template_candidates(
10727 &self.state.placed_containers,
10728 self.state.character_id,
10729 &self.state.inventory,
10730 &self.state.blueprints,
10731 &self.state.resource_nodes,
10732 &extra,
10733 )
10734 }
10735
10736 fn re_blueprint_ids(&self) -> Vec<String> {
10737 let worker_known: Option<&[String]> = self
10738 .state
10739 .worker_route_editor
10740 .as_ref()
10741 .and_then(|ed| {
10742 self.state
10743 .hired_workers
10744 .iter()
10745 .find(|w| w.instance_id == ed.worker_instance_id)
10746 })
10747 .map(|w| w.known_blueprint_ids.as_slice());
10748 crate::worker_route_editor::worker_craft_blueprint_ids(
10749 &self.state.blueprints,
10750 worker_known,
10751 )
10752 }
10753
10754 fn re_bed_candidates(&self) -> Vec<(String, String)> {
10755 crate::worker_route_editor::owned_lodging_container_ids(
10756 &self.state.placed_containers,
10757 self.state.character_id,
10758 )
10759 }
10760
10761 fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
10762 self.state
10763 .placed_containers
10764 .iter()
10765 .find(|c| c.id == container_id)
10766 .map(|c| c.contents.clone())
10767 .unwrap_or_default()
10768 }
10769
10770 fn re_sheet_supports_filter(&self) -> bool {
10773 use crate::worker_route_editor::RouteEditorSheet as S;
10774 self.state
10775 .worker_route_editor
10776 .as_ref()
10777 .is_some_and(|ed| {
10778 matches!(
10779 ed.sheet,
10780 S::HarvestPicker { .. }
10781 | S::SellItem { .. }
10782 | S::DepositFilter { .. }
10783 | S::WithdrawItems { .. }
10784 | S::WithdrawContainers { .. }
10785 | S::DepositContainers { .. }
10786 | S::SellNpcs { .. }
10787 | S::CraftBlueprint { .. }
10788 | S::BedPicker { .. }
10789 )
10790 })
10791 }
10792
10793 pub fn re_sheet_row_visible(&self, row: usize) -> bool {
10795 use crate::worker_route_editor::{
10796 harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
10797 ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
10798 };
10799 let Some(ed) = self.state.worker_route_editor.as_ref() else {
10800 return false;
10801 };
10802 let filter = &ed.sheet_filter;
10803 match &ed.sheet {
10804 S::HarvestPicker { nodes, .. } => {
10805 harvest_picker_row_matches(nodes, row, filter)
10806 }
10807 S::SellItem { templates, .. } => {
10808 if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
10809 return true;
10810 }
10811 let slot = row.saturating_sub(2);
10812 templates.get(slot).is_some_and(|t| {
10813 let label = self.state.template_display_name(t);
10814 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10815 })
10816 }
10817 S::DepositFilter { rows, .. } => {
10818 if row >= rows.len() {
10819 return true;
10820 }
10821 rows.get(row).is_some_and(|(t, _)| {
10822 let label = self.state.template_display_name(t);
10823 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10824 })
10825 }
10826 S::WithdrawItems { lines, .. } => {
10827 if row >= lines.len() {
10828 return true;
10829 }
10830 lines.get(row).is_some_and(|l| {
10831 let label = self.state.template_display_name(&l.template);
10832 list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
10833 })
10834 }
10835 S::WithdrawContainers { .. } | S::DepositContainers { .. } => self
10836 .re_container_candidates()
10837 .get(row)
10838 .is_some_and(|c| {
10839 list_filter_row_matches(
10840 filter,
10841 Some(c.dist),
10842 &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
10843 )
10844 }),
10845 S::SellNpcs { .. } => {
10846 if row == 0 {
10847 return true;
10848 }
10849 self.re_npc_candidates().get(row - 1).is_some_and(|n| {
10850 list_filter_row_matches(filter, Some(n.dist), &[n.label.as_str(), n.id.as_str()])
10851 })
10852 }
10853 S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
10854 let label = self
10855 .state
10856 .blueprints
10857 .iter()
10858 .find(|b| &b.id == id)
10859 .map(|b| {
10860 if b.label.is_empty() {
10861 id.as_str()
10862 } else {
10863 b.label.as_str()
10864 }
10865 })
10866 .unwrap_or(id.as_str());
10867 list_filter_row_matches(filter, None, &[id.as_str(), label])
10868 }),
10869 S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
10870 list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
10871 }),
10872 _ => true,
10873 }
10874 }
10875
10876 fn re_sheet_clamp_index(&mut self) {
10877 let count = self.re_sheet_row_count();
10878 if count == 0 {
10879 return;
10880 }
10881 let cur = self.re_sheet_index();
10882 if self.re_sheet_row_visible(cur) {
10883 return;
10884 }
10885 for offset in 1..count {
10886 if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
10887 self.re_sheet_set_index(cur + offset);
10888 return;
10889 }
10890 if cur >= offset && self.re_sheet_row_visible(cur - offset) {
10891 self.re_sheet_set_index(cur - offset);
10892 return;
10893 }
10894 }
10895 }
10896
10897 fn re_sheet_set_index(&mut self, index: usize) {
10898 use crate::worker_route_editor::RouteEditorSheet as S;
10899 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10900 return;
10901 };
10902 match &mut ed.sheet {
10903 S::AddMenu { index: slot }
10904 | S::WaypointMenu { index: slot }
10905 | S::HarvestPicker { index: slot, .. }
10906 | S::WithdrawContainers { index: slot }
10907 | S::DepositContainers { index: slot }
10908 | S::SellNpcs { index: slot }
10909 | S::CraftBlueprint { index: slot }
10910 | S::BedPicker { index: slot }
10911 | S::FarmPlotPicker { index: slot, .. }
10912 | S::FarmPlantSeed { index: slot, .. }
10913 | S::WithdrawItems { index: slot, .. }
10914 | S::DepositFilter { index: slot, .. }
10915 | S::SellItem { index: slot, .. } => *slot = index,
10916 _ => {}
10917 }
10918 }
10919
10920 pub fn re_focus_sheet_filter(&mut self) {
10921 if !self.re_sheet_supports_filter() {
10922 return;
10923 }
10924 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10925 ed.sheet_filter_focused = true;
10926 }
10927 }
10928
10929 pub fn re_blur_sheet_filter_keep_text(&mut self) {
10930 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10931 return;
10932 };
10933 if !ed.sheet_filter_focused {
10934 return;
10935 }
10936 ed.sheet_filter_focused = false;
10937 self.re_sheet_clamp_index();
10938 }
10939
10940 pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
10941 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10942 return false;
10943 };
10944 if ed.sheet_filter_focused {
10945 ed.sheet_filter_focused = false;
10946 self.re_sheet_clamp_index();
10947 return true;
10948 }
10949 if !ed.sheet_filter.is_empty() {
10950 ed.sheet_filter.clear();
10951 self.re_sheet_clamp_index();
10952 return true;
10953 }
10954 false
10955 }
10956
10957 pub fn re_append_sheet_filter_char(&mut self, ch: char) {
10958 if ch.is_control() {
10959 return;
10960 }
10961 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10962 return;
10963 };
10964 if !ed.sheet_filter_focused {
10965 return;
10966 }
10967 ed.sheet_filter.push(ch);
10968 self.re_sheet_set_index(0);
10969 self.re_sheet_clamp_index();
10970 }
10971
10972 pub fn re_sheet_filter_backspace(&mut self) {
10973 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10974 return;
10975 };
10976 if !ed.sheet_filter_focused {
10977 return;
10978 }
10979 ed.sheet_filter.pop();
10980 self.re_sheet_set_index(0);
10981 self.re_sheet_clamp_index();
10982 }
10983
10984 pub fn re_sheet_row_count(&self) -> usize {
10986 use crate::worker_route_editor::{
10987 harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
10988 };
10989 let Some(ed) = self.state.worker_route_editor.as_ref() else {
10990 return 0;
10991 };
10992 match &ed.sheet {
10993 S::Stops => ed.stops.len(),
10994 S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
10995 S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
10996 S::WaypointMapPick => 0,
10997 S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
10998 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
10999 self.re_container_candidates().len()
11000 }
11001 S::WithdrawItems { lines, .. } => lines.len() + 1, S::DepositFilter { rows, .. } => rows.len() + 1, S::SellNpcs { .. } => self.re_npc_candidates().len() + 1, S::SellItem { templates, .. } => sell_item_picker_row_count(templates.len()),
11005 S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
11006 S::WaitEntry { .. } => 1,
11007 S::BedPicker { .. } => self.re_bed_candidates().len(),
11008 S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
11009 S::FarmPlantSeed { seeds, .. } => seeds.len(),
11010 }
11011 }
11012
11013 pub fn re_sheet_index(&self) -> usize {
11015 use crate::worker_route_editor::RouteEditorSheet as S;
11016 let Some(ed) = self.state.worker_route_editor.as_ref() else {
11017 return 0;
11018 };
11019 match &ed.sheet {
11020 S::AddMenu { index }
11021 | S::WaypointMenu { index }
11022 | S::HarvestPicker { index, .. }
11023 | S::WithdrawContainers { index }
11024 | S::DepositContainers { index }
11025 | S::SellNpcs { index }
11026 | S::CraftBlueprint { index }
11027 | S::BedPicker { index }
11028 | S::FarmPlotPicker { index, .. }
11029 | S::FarmPlantSeed { index, .. }
11030 | S::WithdrawItems { index, .. }
11031 | S::DepositFilter { index, .. }
11032 | S::SellItem { index, .. } => *index,
11033 _ => 0,
11034 }
11035 }
11036
11037 pub fn re_sheet_move(&mut self, delta: i32) {
11039 let count = self.re_sheet_row_count();
11040 if count == 0 {
11041 return;
11042 }
11043 let cur = self.re_sheet_index();
11044 let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
11045 self.re_sheet_set_index(next);
11046 }
11047
11048 pub fn re_sheet_page(&mut self, pages: i32) {
11049 let count = self.re_sheet_row_count();
11050 if count == 0 {
11051 return;
11052 }
11053 let cur = self.re_sheet_index();
11054 let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
11055 self.re_sheet_set_index(next);
11056 }
11057
11058 pub fn re_sheet_adjust(&mut self, delta: i32) {
11060 use crate::worker_route_editor::RouteEditorSheet as S;
11061 let index = self.re_sheet_index();
11062 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11063 return;
11064 };
11065 match &mut ed.sheet {
11066 S::WithdrawItems { lines, .. } => {
11067 if let Some(line) = lines.get_mut(index) {
11068 line.adjust_qty(delta);
11069 }
11070 }
11071 S::WaitEntry { ticks } => {
11072 *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
11073 }
11074 _ => {}
11075 }
11076 }
11077
11078 pub fn re_sheet_back(&mut self) {
11079 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11080 return;
11081 };
11082 use crate::worker_route_editor::RouteEditorSheet as S;
11083 let was_editing = ed.editing_index.is_some();
11084 let from_top_picker = matches!(
11085 ed.sheet,
11086 S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
11087 );
11088 ed.sheet_back();
11089 if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
11090 self.state
11092 .push_log("Route: left edit sheet — press s to save current stops".to_string());
11093 }
11094 }
11095
11096 pub fn re_at_root_sheet(&self) -> bool {
11098 self.state
11099 .worker_route_editor
11100 .as_ref()
11101 .is_some_and(|ed| matches!(ed.sheet, crate::worker_route_editor::RouteEditorSheet::Stops))
11102 }
11103
11104 pub fn re_open_add_menu(&mut self) {
11105 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11106 ed.open_add_menu();
11107 }
11108 }
11109
11110 pub fn re_open_bed_picker(&mut self) {
11111 let beds = self.re_bed_candidates();
11112 if beds.is_empty() {
11113 self.state
11114 .push_log("Route: place a camp bed first".to_string());
11115 return;
11116 }
11117 let current = self
11118 .state
11119 .worker_route_editor
11120 .as_ref()
11121 .and_then(|ed| ed.lodging_container_id.clone());
11122 let index = current
11123 .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
11124 .unwrap_or(0);
11125 self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
11126 }
11127
11128 fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
11129 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11130 ed.open_sheet(sheet);
11131 }
11132 }
11133
11134 fn re_confirm_stop(
11136 &mut self,
11137 stop: crate::worker_route_editor::WorkerRouteStop,
11138 what: String,
11139 ) {
11140 let appended = self
11141 .state
11142 .worker_route_editor
11143 .as_mut()
11144 .is_some_and(|ed| ed.confirm_stop(stop));
11145 if appended {
11146 self.state.push_log(format!("Route: + {what}"));
11147 } else {
11148 self.state
11149 .push_log(format!("Route: {what} already in route — selected it"));
11150 }
11151 }
11152
11153 fn re_open_withdraw_items(&mut self, container_id: String) {
11154 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
11155 let contents = self.re_container_contents(&container_id);
11156 let existing = self
11160 .state
11161 .worker_route_editor
11162 .as_ref()
11163 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11164 .and_then(|stop| match stop {
11165 WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
11166 _ => None,
11167 })
11168 .unwrap_or_default();
11169 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
11170 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11173 let _ = ed.retarget_withdraw_container(container_id.clone());
11174 }
11175 self.re_open_sheet(S::WithdrawItems {
11176 container_id,
11177 lines,
11178 index: 0,
11179 });
11180 }
11181
11182 fn re_withdraw_items_activate(&mut self, index: usize) {
11183 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
11184 enum Outcome {
11185 Cycled,
11186 Confirmed(String),
11187 Empty,
11188 }
11189 let outcome = {
11190 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11191 return;
11192 };
11193 let S::WithdrawItems {
11194 container_id,
11195 lines,
11196 index: sheet_index,
11197 } = &mut ed.sheet
11198 else {
11199 return;
11200 };
11201 *sheet_index = index;
11202 if index < lines.len() {
11203 lines[index].cycle();
11204 Outcome::Cycled
11205 } else {
11206 let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
11207 if items.is_empty() {
11208 Outcome::Empty
11209 } else {
11210 let stop = WorkerRouteStop::WithdrawFrom {
11211 container_id: container_id.clone(),
11212 items,
11213 };
11214 let summary = stop.summary();
11215 ed.confirm_stop(stop);
11216 Outcome::Confirmed(summary)
11217 }
11218 }
11219 };
11220 match outcome {
11221 Outcome::Cycled => {}
11222 Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
11223 Outcome::Empty => self
11224 .state
11225 .push_log("Route: pick at least one item (Space/Enter toggles All/qty)".to_string()),
11226 }
11227 }
11228
11229 fn re_open_deposit_filter(&mut self, container_id: String) {
11230 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11231 let existing_filter = self
11233 .state
11234 .worker_route_editor
11235 .as_ref()
11236 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11237 .and_then(|stop| match stop {
11238 WorkerRouteStop::DepositAt { filter, .. } => {
11239 Some(filter.clone().unwrap_or_default())
11240 }
11241 _ => None,
11242 });
11243 let mut candidates = self.re_template_candidates();
11244 if let Some(ref chosen) = existing_filter {
11245 for t in chosen {
11246 if !candidates.iter().any(|c| c == t) {
11247 candidates.push(t.clone());
11248 }
11249 }
11250 candidates.sort();
11251 candidates.dedup();
11252 }
11253 let rows: Vec<(String, bool)> = match existing_filter {
11254 Some(chosen) => candidates
11255 .iter()
11256 .map(|t| (t.clone(), chosen.contains(t)))
11257 .collect(),
11258 None => candidates.into_iter().map(|t| (t, false)).collect(),
11259 };
11260 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11261 let _ = ed.retarget_deposit_container(container_id.clone());
11262 }
11263 self.re_open_sheet(S::DepositFilter {
11264 container_id,
11265 rows,
11266 index: 0,
11267 });
11268 }
11269
11270 fn re_deposit_filter_activate(&mut self, index: usize) {
11271 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11272 let mut confirmed: Option<String> = None;
11273 {
11274 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11275 return;
11276 };
11277 let S::DepositFilter {
11278 container_id,
11279 rows,
11280 index: sheet_index,
11281 } = &mut ed.sheet
11282 else {
11283 return;
11284 };
11285 *sheet_index = index;
11286 if index < rows.len() {
11287 rows[index].1 = !rows[index].1;
11288 } else {
11289 let chosen: Vec<String> = rows
11291 .iter()
11292 .filter(|(_, on)| *on)
11293 .map(|(t, _)| t.clone())
11294 .collect();
11295 let filter = if chosen.is_empty() { None } else { Some(chosen) };
11296 let stop = WorkerRouteStop::DepositAt {
11297 container_id: container_id.clone(),
11298 filter,
11299 };
11300 confirmed = Some(stop.summary());
11301 ed.confirm_stop(stop);
11302 }
11303 }
11304 if let Some(what) = confirmed {
11305 self.state.push_log(format!("Route: + {what}"));
11306 }
11307 }
11308
11309 fn re_open_sell_item(&mut self, npc_id: Option<String>) {
11310 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11311 let templates = self.re_template_candidates();
11312 if templates.is_empty() {
11313 self.state.push_log(
11314 "Route: no item templates available — learn a craft recipe or place a harvest node first"
11315 .to_string(),
11316 );
11317 return;
11318 }
11319 let (pre_npc, pre_template, pre_all) = self
11321 .state
11322 .worker_route_editor
11323 .as_ref()
11324 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11325 .and_then(|stop| match stop {
11326 WorkerRouteStop::TradeWith {
11327 npc_id,
11328 template,
11329 sell_all,
11330 } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
11331 _ => None,
11332 })
11333 .unwrap_or((None, None, true));
11334 let npc_id = npc_id.or(pre_npc);
11335 let mut picked = std::collections::BTreeSet::new();
11336 if let Some(t) = pre_template {
11337 picked.insert(t);
11338 }
11339 self.re_open_sheet(S::SellItem {
11340 npc_id,
11341 templates,
11342 index: if picked.is_empty() {
11343 crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
11344 } else {
11345 2
11346 },
11347 sell_all: pre_all,
11348 picked,
11349 });
11350 }
11351
11352 fn re_sell_item_activate(&mut self, index: usize) {
11353 use crate::worker_route_editor::{
11354 RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
11355 };
11356 let mut batch_log: Option<String> = None;
11357 {
11358 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11359 return;
11360 };
11361 let S::SellItem {
11362 npc_id,
11363 templates,
11364 index: sheet_index,
11365 sell_all,
11366 picked,
11367 } = &mut ed.sheet
11368 else {
11369 return;
11370 };
11371 *sheet_index = index;
11372 if index == ROUTE_PICKER_DONE_ROW {
11373 if picked.is_empty() {
11374 batch_log = Some(
11375 "Route: pick at least one item (Space toggles, Done confirms)".into(),
11376 );
11377 } else {
11378 let picks: Vec<String> = picked.iter().cloned().collect();
11379 let npc = npc_id.clone();
11380 let all = *sell_all;
11381 let added = ed.confirm_trade_picks(npc, &picks, all);
11382 batch_log = Some(format!("Route: + {added} sell stop(s)"));
11383 }
11384 } else if index == SELL_ITEM_TOGGLE_ROW {
11385 *sell_all = !*sell_all;
11386 } else if let Some(template) = templates.get(index.saturating_sub(2)) {
11387 if picked.contains(template) {
11388 picked.remove(template);
11389 } else {
11390 picked.insert(template.clone());
11391 }
11392 }
11393 }
11394 if let Some(msg) = batch_log {
11395 self.state.push_log(msg);
11396 }
11397 }
11398
11399 pub fn re_edit_selected_stop(&mut self) {
11401 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11402 let Some(stop) = self
11403 .state
11404 .worker_route_editor
11405 .as_ref()
11406 .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
11407 else {
11408 self.state
11409 .push_log("Route: no stop selected — press a to add one".to_string());
11410 return;
11411 };
11412 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11413 ed.begin_edit_selected();
11414 }
11415 match stop {
11416 WorkerRouteStop::Waypoint { .. } => {
11417 self.re_open_sheet(S::WaypointMenu { index: 0 });
11418 }
11419 WorkerRouteStop::HarvestNode { node_id } => {
11420 let nodes = self.state.route_editor_node_candidates();
11421 if nodes.is_empty() {
11422 self.re_cancel_edit();
11423 self.state
11424 .push_log("Route: no harvestable nodes visible to retarget".to_string());
11425 } else {
11426 let mut picked = std::collections::BTreeSet::new();
11427 picked.insert(node_id.clone());
11428 let index = nodes
11429 .iter()
11430 .position(|n| n.id == node_id)
11431 .map(|i| i + 1)
11432 .unwrap_or(1);
11433 self.re_open_harvest_picker(index, picked);
11434 }
11435 }
11436 WorkerRouteStop::WithdrawFrom { container_id, .. } => {
11437 let containers = self.re_container_candidates();
11440 if containers.is_empty() {
11441 self.re_cancel_edit();
11442 self.state
11443 .push_log("Route: place a storage chest first".to_string());
11444 } else {
11445 let index = containers
11446 .iter()
11447 .position(|c| c.id == container_id)
11448 .unwrap_or(0);
11449 self.re_open_sheet(S::WithdrawContainers { index });
11450 }
11451 }
11452 WorkerRouteStop::DepositAt { container_id, .. } => {
11453 let containers = self.re_container_candidates();
11454 if containers.is_empty() {
11455 self.re_cancel_edit();
11456 self.state
11457 .push_log("Route: place a storage chest first".to_string());
11458 } else {
11459 let index = containers
11460 .iter()
11461 .position(|c| c.id == container_id)
11462 .unwrap_or(0);
11463 self.re_open_sheet(S::DepositContainers { index });
11464 }
11465 }
11466 WorkerRouteStop::TradeWith { npc_id, .. } => {
11467 let npcs = self.re_npc_candidates();
11468 let index = npc_id
11470 .as_ref()
11471 .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
11472 .unwrap_or(0);
11473 self.re_open_sheet(S::SellNpcs { index });
11474 }
11475 WorkerRouteStop::CraftAt { blueprint, .. } => {
11476 let bps = self.re_blueprint_ids();
11477 let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
11478 if bps.is_empty() {
11479 self.re_cancel_edit();
11480 self.state
11481 .push_log("Route: no known blueprints to retarget".to_string());
11482 } else {
11483 self.re_open_sheet(S::CraftBlueprint { index });
11484 }
11485 }
11486 WorkerRouteStop::CultivatePlot { .. } => {
11487 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate);
11488 }
11489 WorkerRouteStop::PlantPlot { .. } => {
11490 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
11491 }
11492 WorkerRouteStop::HarvestPlot { .. } => {
11493 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
11494 }
11495 WorkerRouteStop::RestIfNeeded => {
11496 self.re_cancel_edit();
11497 self.state
11498 .push_log("Route: rest has no settings (change the bed with l)".to_string());
11499 }
11500 WorkerRouteStop::Wait { wait_ticks } => {
11501 self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
11502 }
11503 }
11504 }
11505
11506 fn re_cancel_edit(&mut self) {
11507 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11508 ed.editing_index = None;
11509 }
11510 }
11511
11512 pub fn worker_route_editor_ui_click(
11515 &mut self,
11516 click: crate::worker_route_editor::RouteEditorClick,
11517 ) {
11518 use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
11519 match click {
11520 RouteEditorClick::SelectStop(i) => {
11521 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11522 ed.sheet = S::Stops;
11523 ed.select_stop(i);
11524 }
11525 }
11526 RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
11527 RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
11528 RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
11529 }
11530 }
11531
11532 pub fn re_sheet_row_activate(&mut self, row: usize) {
11534 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11535 let Some(sheet) = self
11536 .state
11537 .worker_route_editor
11538 .as_ref()
11539 .map(|ed| ed.sheet.clone())
11540 else {
11541 return;
11542 };
11543 match sheet {
11544 S::Stops => {
11545 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11546 ed.select_stop(row);
11547 }
11548 }
11549 S::AddMenu { .. } => match row {
11550 0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
11551 1 => {
11552 if self.re_node_candidates().is_empty() {
11553 self.state
11554 .push_log("Route: no harvestable nodes visible in this region".to_string());
11555 } else {
11556 self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
11557 }
11558 }
11559 2 | 3 => {
11560 if self.re_container_candidates().is_empty() {
11561 self.state
11562 .push_log("Route: place a storage chest first".to_string());
11563 } else if row == 2 {
11564 self.re_open_sheet(S::WithdrawContainers { index: 0 });
11565 } else {
11566 self.re_open_sheet(S::DepositContainers { index: 0 });
11567 }
11568 }
11569 4 => {
11570 if self.re_template_candidates().is_empty() {
11571 self.state.push_log(
11572 "Route: no item templates available — learn a craft recipe or place a harvest node first"
11573 .to_string(),
11574 );
11575 } else {
11576 self.re_open_sheet(S::SellNpcs { index: 0 });
11577 }
11578 }
11579 5 => {
11580 if self.re_blueprint_ids().is_empty() {
11581 self.state.push_log(
11582 "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
11583 .to_string(),
11584 );
11585 } else {
11586 self.re_open_sheet(S::CraftBlueprint { index: 0 });
11587 }
11588 }
11589 6 => self.re_confirm_stop(
11590 WorkerRouteStop::RestIfNeeded,
11591 "rest at lodging (if needed)".into(),
11592 ),
11593 7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
11594 8 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate),
11595 9 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant),
11596 10 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
11597 _ => {}
11598 },
11599 S::WaypointMenu { .. } => match row {
11600 0 => {
11601 let (x, y, z) = self.state.player_position_with_z();
11602 let stop = WorkerRouteStop::Waypoint { x, y, z };
11603 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
11604 }
11605 1 => {
11606 self.re_open_sheet(S::WaypointMapPick);
11607 self.state.push_log("Route: click the map to place the waypoint (Esc to finish)".to_string());
11608 }
11609 _ => {}
11610 },
11611 S::HarvestPicker { .. } => {
11612 let mut log: Option<String> = None;
11613 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11614 let S::HarvestPicker {
11615 index: sheet_index,
11616 picked,
11617 nodes,
11618 } = &mut ed.sheet
11619 else {
11620 return;
11621 };
11622 *sheet_index = row;
11623 if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
11624 if picked.is_empty() {
11625 log = Some(
11626 "Route: pick at least one node (Space toggles, Done confirms)"
11627 .into(),
11628 );
11629 } else {
11630 let ids: Vec<String> = picked.iter().cloned().collect();
11631 let added = ed.confirm_harvest_picks(&ids);
11632 log = Some(format!("Route: + {added} harvest stop(s)"));
11633 }
11634 } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
11635 if picked.contains(&n.id) {
11636 picked.remove(&n.id);
11637 } else {
11638 picked.insert(n.id.clone());
11639 }
11640 }
11641 }
11642 if let Some(msg) = log {
11643 self.state.push_log(msg);
11644 }
11645 }
11646 S::WithdrawContainers { .. } => {
11647 let containers = self.re_container_candidates();
11648 if let Some(c) = containers.get(row) {
11649 let id = c.id.clone();
11650 self.re_open_withdraw_items(id);
11651 }
11652 }
11653 S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
11654 S::DepositContainers { .. } => {
11655 let containers = self.re_container_candidates();
11656 if let Some(c) = containers.get(row) {
11657 let id = c.id.clone();
11658 self.re_open_deposit_filter(id);
11659 }
11660 }
11661 S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
11662 S::SellNpcs { .. } => {
11663 let npcs = self.re_npc_candidates();
11664 let npc_id = if row == 0 {
11665 None
11666 } else {
11667 npcs.get(row - 1).map(|n| n.id.clone())
11668 };
11669 if row == 0 || npc_id.is_some() {
11670 self.re_open_sell_item(npc_id);
11671 }
11672 }
11673 S::SellItem { .. } => self.re_sell_item_activate(row),
11674 S::CraftBlueprint { .. } => {
11675 let bps = self.re_blueprint_ids();
11676 if let Some(bp) = bps.get(row) {
11677 let stop = WorkerRouteStop::CraftAt {
11678 device: "hand".into(),
11679 blueprint: bp.clone(),
11680 qty: None,
11681 };
11682 self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
11683 }
11684 }
11685 S::WaitEntry { ticks } => {
11686 let stop = WorkerRouteStop::Wait {
11687 wait_ticks: ticks,
11688 };
11689 self.re_confirm_stop(stop, format!("wait {ticks}t"));
11690 }
11691 S::BedPicker { .. } => {
11692 let beds = self.re_bed_candidates();
11693 if let Some((id, name)) = beds.get(row) {
11694 let (id, name) = (id.clone(), name.clone());
11695 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11696 ed.lodging_container_id = Some(id.clone());
11697 ed.sheet = S::Stops;
11698 }
11699 self.state
11700 .push_log(format!("Route: rest bed set to {name}"));
11701 }
11702 }
11703 S::FarmPlotPicker { action, .. } => {
11704 let plots = self.re_farm_plot_candidates();
11705 let Some(plot) = plots.get(row).cloned() else {
11706 return;
11707 };
11708 match action {
11709 crate::worker_route_editor::FarmPlotAction::Cultivate => {
11710 let label = plot_route_label(&plot);
11711 self.re_confirm_stop(
11712 WorkerRouteStop::CultivatePlot {
11713 plot_id: plot.plot_id,
11714 },
11715 format!("cultivate {label}"),
11716 );
11717 }
11718 crate::worker_route_editor::FarmPlotAction::Harvest => {
11719 let label = plot_route_label(&plot);
11720 self.re_confirm_stop(
11721 WorkerRouteStop::HarvestPlot {
11722 plot_id: plot.plot_id,
11723 },
11724 format!("harvest {label}"),
11725 );
11726 }
11727 crate::worker_route_editor::FarmPlotAction::Plant => {
11728 let seeds = self.re_farm_seed_candidates();
11729 if seeds.is_empty() {
11730 self.state.push_log(
11731 "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
11732 );
11733 return;
11734 }
11735 self.re_open_sheet(S::FarmPlantSeed {
11736 plot_id: plot.plot_id,
11737 seeds,
11738 index: 0,
11739 });
11740 }
11741 }
11742 }
11743 S::FarmPlantSeed { plot_id, seeds, .. } => {
11744 if let Some(seed) = seeds.get(row).cloned() {
11745 self.re_confirm_stop(
11746 WorkerRouteStop::PlantPlot {
11747 plot_id,
11748 seed_template: seed.clone(),
11749 },
11750 format!("plant {seed}"),
11751 );
11752 }
11753 }
11754 S::WaypointMapPick => {}
11755 }
11756 }
11757
11758 fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
11759 use crate::worker_route_editor::RouteEditorSheet as S;
11760 if self.re_farm_plot_candidates().is_empty() {
11761 self.state.push_log(
11762 "Route: no farmable plots visible — claim land or get farm access first",
11763 );
11764 return;
11765 }
11766 self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
11767 }
11768
11769 fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
11770 self.state
11771 .property_plots
11772 .iter()
11773 .filter(|p| p.is_mine || p.may_farm)
11774 .cloned()
11775 .collect()
11776 }
11777
11778 fn re_farm_seed_candidates(&self) -> Vec<String> {
11782 let mut set = std::collections::BTreeSet::new();
11783 let looks_like_seed = |id: &str| {
11784 id.ends_with("_seed") || id == "potato_seed" || id == "carrot_seed"
11785 };
11786 for (id, _, _) in self.state.farm_seed_entries() {
11787 set.insert(id);
11788 }
11789 for c in &self.state.placed_containers {
11790 let mine = match (self.state.character_id, c.owner_character_id) {
11791 (Some(a), Some(b)) => a == b,
11792 _ => false,
11793 };
11794 if !mine {
11795 continue;
11796 }
11797 for s in &c.contents {
11798 if s.quantity > 0
11799 && (s.props.contains_key("seed_for") || looks_like_seed(&s.template_id))
11800 {
11801 set.insert(s.template_id.clone());
11802 }
11803 }
11804 }
11805 if let Some(ed) = self.state.worker_route_editor.as_ref() {
11806 for stop in &ed.stops {
11807 if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } = stop
11808 {
11809 for it in items {
11810 if looks_like_seed(&it.template) {
11811 set.insert(it.template.clone());
11812 }
11813 }
11814 }
11815 if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
11816 seed_template, ..
11817 } = stop
11818 {
11819 if !seed_template.is_empty() {
11820 set.insert(seed_template.clone());
11821 }
11822 }
11823 }
11824 }
11825 for id in self.state.inventory_hints.keys() {
11826 if looks_like_seed(id) {
11827 set.insert(id.clone());
11828 }
11829 }
11830 for id in ["potato_seed", "carrot_seed"] {
11832 set.insert(id.to_string());
11833 }
11834 set.into_iter().collect()
11835 }
11836
11837 pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
11844 use crate::worker_route_editor as wre;
11845 use wre::RouteEditorSheet as S;
11846 if self.state.worker_route_editor.is_none() {
11847 return;
11848 }
11849 let sheet = self
11850 .state
11851 .worker_route_editor
11852 .as_ref()
11853 .map(|ed| ed.sheet.clone())
11854 .unwrap_or(S::Stops);
11855 match sheet {
11856 S::WaypointMapPick => {
11857 let (_, _, z) = self.state.player_position_with_z();
11858 let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
11859 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
11860 let editing = self
11862 .state
11863 .worker_route_editor
11864 .as_ref()
11865 .is_some_and(|ed| ed.editing_index.is_some());
11866 if !editing {
11867 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11868 ed.sheet = S::WaypointMapPick;
11869 }
11870 }
11871 }
11872 S::HarvestPicker { .. } => {
11873 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
11874 let mut log: Option<String> = None;
11875 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11876 let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
11877 return;
11878 };
11879 let selected = if picked.contains(&node.id) {
11880 picked.remove(&node.id);
11881 false
11882 } else {
11883 picked.insert(node.id.clone());
11884 true
11885 };
11886 log = Some(format!(
11887 "Route: {} {}",
11888 if selected { "selected" } else { "deselected" },
11889 resource_node_route_label(node)
11890 ));
11891 }
11892 if let Some(msg) = log {
11893 self.state.push_log(msg);
11894 }
11895 }
11896 }
11897 S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
11898 let inside = self.state.effective_inside_building();
11900 if let Some(cid) = wre::pick_storage_container_at(
11901 &self.state.placed_containers,
11902 self.state.character_id,
11903 x,
11904 y,
11905 inside.as_deref(),
11906 ) {
11907 self.re_open_withdraw_items(cid);
11908 }
11909 }
11910 S::DepositContainers { .. } | S::DepositFilter { .. } => {
11911 let inside = self.state.effective_inside_building();
11912 if let Some(cid) = wre::pick_storage_container_at(
11913 &self.state.placed_containers,
11914 self.state.character_id,
11915 x,
11916 y,
11917 inside.as_deref(),
11918 ) {
11919 self.re_open_deposit_filter(cid);
11920 }
11921 }
11922 S::SellNpcs { .. } => {
11923 if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11924 self.re_open_sell_item(Some(npc_id));
11925 }
11926 }
11927 S::SellItem { .. } => {
11928 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11929 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11930 if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
11931 *slot = Some(npc_id.clone());
11932 }
11933 }
11934 self.state
11935 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
11936 }
11937 }
11938 _ => self.worker_route_editor_quick_add_click(x, y),
11940 }
11941 }
11942
11943 fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
11947 use crate::worker_route_editor as wre;
11948 let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
11949 let dx = ax - bx;
11950 let dy = ay - by;
11951 (dx * dx + dy * dy).sqrt()
11952 };
11953
11954 let selected_stop_kind = self
11957 .state
11958 .worker_route_editor
11959 .as_ref()
11960 .and_then(|ed| ed.stops.get(ed.selected_stop_index))
11961 .map(|s| match s {
11962 wre::WorkerRouteStop::TradeWith { .. } => 1,
11963 wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
11964 _ => 0,
11965 })
11966 .unwrap_or(0);
11967 if selected_stop_kind == 1 {
11968 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11969 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11970 ed.set_selected_trade_npc(npc_id.clone());
11971 }
11972 self.state
11973 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
11974 return;
11975 }
11976 }
11977 if selected_stop_kind == 2 {
11978 let inside = self.state.effective_inside_building();
11979 if let Some(cid) = wre::pick_storage_container_at(
11980 &self.state.placed_containers,
11981 self.state.character_id,
11982 x,
11983 y,
11984 inside.as_deref(),
11985 ) {
11986 let name = self
11987 .state
11988 .placed_containers
11989 .iter()
11990 .find(|c| c.id == cid)
11991 .map(|c| c.display_name.clone())
11992 .unwrap_or_else(|| "container".into());
11993 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11994 ed.set_selected_withdraw_container(cid.clone());
11995 }
11996 self.state
11997 .push_log(format!("Route: withdraw source → {name}"));
11998 return;
11999 }
12000 }
12001
12002 enum Target {
12005 Bed(String),
12006 Container(String),
12007 Npc(String, String),
12008 Node(String, String),
12009 }
12010 let mut best: Option<(f32, u8, Target)> = None;
12011 let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
12012 let better = match best {
12013 None => true,
12014 Some((bd, brank, _)) => d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank),
12015 };
12016 if better {
12017 *best = Some((d, rank, t));
12018 }
12019 };
12020 let inside = self.state.effective_inside_building();
12021 if let Some(bed_id) = wre::pick_lodging_container_at(
12022 &self.state.placed_containers,
12023 self.state.character_id,
12024 x,
12025 y,
12026 inside.as_deref(),
12027 ) {
12028 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
12029 let already_bed = self
12032 .state
12033 .worker_route_editor
12034 .as_ref()
12035 .is_some_and(|ed| ed.lodging_container_id.as_deref() == Some(bed_id.as_str()));
12036 if already_bed {
12037 consider(dist(x, y, c.x, c.y), 1, Target::Container(bed_id), &mut best);
12038 } else {
12039 consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
12040 }
12041 }
12042 }
12043 if let Some(cid) = wre::pick_storage_container_at(
12044 &self.state.placed_containers,
12045 self.state.character_id,
12046 x,
12047 y,
12048 inside.as_deref(),
12049 ) {
12050 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
12051 consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
12052 }
12053 }
12054 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
12055 if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
12056 consider(
12057 dist(x, y, n.x, n.y),
12058 2,
12059 Target::Npc(npc_id, label),
12060 &mut best,
12061 );
12062 }
12063 }
12064 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
12065 let d = dist(x, y, node.x, node.y);
12066 let label = resource_node_route_label(node);
12067 consider(
12068 d,
12069 3,
12070 Target::Node(node.id.clone(), label),
12071 &mut best,
12072 );
12073 }
12074
12075 match best.map(|(_, _, t)| t) {
12076 Some(Target::Bed(bed_id)) => {
12077 let name = self
12078 .state
12079 .placed_containers
12080 .iter()
12081 .find(|c| c.id == bed_id)
12082 .map(|c| c.display_name.clone())
12083 .unwrap_or_else(|| "camp bed".into());
12084 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12085 ed.lodging_container_id = Some(bed_id.clone());
12086 }
12087 self.state
12088 .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
12089 }
12090 Some(Target::Container(cid)) => {
12091 let name = self
12092 .state
12093 .placed_containers
12094 .iter()
12095 .find(|c| c.id == cid)
12096 .map(|c| c.display_name.clone())
12097 .unwrap_or_else(|| "container".into());
12098 let added = self
12099 .state
12100 .worker_route_editor
12101 .as_mut()
12102 .is_some_and(|ed| ed.append_deposit_at(&cid));
12103 if added {
12104 self.state
12105 .push_log(format!("Route: + deposit at {name} ({cid})"));
12106 } else {
12107 self.state.push_log(format!(
12108 "Route: {name} already in route — selected it (d to remove)"
12109 ));
12110 }
12111 }
12112 Some(Target::Npc(npc_id, label)) => {
12113 let template = self.re_template_candidates().into_iter().next();
12116 let Some(template) = template else {
12117 self.state.push_log("Route: no items in your storage to sell — stock a chest first".to_string());
12118 return;
12119 };
12120 let added = self
12121 .state
12122 .worker_route_editor
12123 .as_mut()
12124 .is_some_and(|ed| ed.append_trade_with(template.clone(), Some(npc_id.clone()), true));
12125 if added {
12126 self.state
12127 .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
12128 } else {
12129 self.state.push_log(format!(
12130 "Route: {label} already sells {template} — selected it (d to remove)"
12131 ));
12132 }
12133 }
12134 Some(Target::Node(id, label)) => {
12135 let added = self
12136 .state
12137 .worker_route_editor
12138 .as_mut()
12139 .is_some_and(|ed| ed.append_harvest_node(&id));
12140 if added {
12141 self.state
12142 .push_log(format!("Route: + harvest node {label}"));
12143 } else {
12144 self.state.push_log(format!(
12145 "Route: {label} already in route — selected it (d to remove)"
12146 ));
12147 }
12148 }
12149 None => {}
12150 }
12151 }
12152
12153 pub fn worker_route_editor_select(&mut self, delta: i32) {
12154 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12155 return;
12156 };
12157 if ed.stops.is_empty() {
12158 return;
12159 }
12160 let n = ed.stops.len() as i32;
12161 let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
12162 ed.selected_stop_index = next;
12163 }
12164
12165 pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
12166 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12167 return;
12168 };
12169 if delta < 0 {
12170 ed.move_selected_up();
12171 } else if delta > 0 {
12172 ed.move_selected_down();
12173 }
12174 }
12175
12176 pub fn worker_route_editor_delete_selected(&mut self) {
12177 let removed = self
12178 .state
12179 .worker_route_editor
12180 .as_mut()
12181 .is_some_and(|ed| {
12182 let before = ed.stop_count();
12183 ed.remove_selected_stop();
12184 ed.stop_count() < before
12185 });
12186 if removed {
12187 self.state.push_log("Route: removed selected stop");
12188 }
12189 }
12190
12191 pub fn worker_route_editor_clear_stops(&mut self) {
12194 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12195 return;
12196 };
12197 if ed.stops.is_empty() {
12198 self.state.push_log("Route: already empty — s saves an idle worker".to_string());
12199 return;
12200 }
12201 ed.stops.clear();
12202 ed.selected_stop_index = 0;
12203 self.state
12204 .push_log("Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string());
12205 }
12206
12207 pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
12208 if self.state.pending_worker_job_ack.is_some() {
12209 anyhow::bail!("route save still pending — wait for server ack");
12210 }
12211 let Some(ed) = self.state.worker_route_editor.clone() else {
12212 anyhow::bail!("route editor not open");
12213 };
12214 let (job_yaml, idle) = if ed.stops.is_empty() {
12217 (ed.build_idle_job_yaml(), true)
12218 } else {
12219 (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
12220 };
12221 let worker_id = ed.worker_instance_id.clone();
12222 let route_view = if idle {
12223 None
12224 } else {
12225 Some(ed.to_route_view())
12226 };
12227 let mode = if idle {
12228 flatland_protocol::WorkerModeView::Idle
12229 } else {
12230 flatland_protocol::WorkerModeView::JobLoop
12231 };
12232 let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
12233 .state
12234 .hired_workers
12235 .iter()
12236 .find(|w| w.instance_id == worker_id)
12237 .map(|w| {
12238 (
12239 w.route.clone(),
12240 w.mode,
12241 w.step_label.clone(),
12242 w.last_error.clone(),
12243 )
12244 })
12245 .unwrap_or((
12246 None,
12247 flatland_protocol::WorkerModeView::Idle,
12248 String::new(),
12249 None,
12250 ));
12251 self.seq += 1;
12252 let seq = self.seq;
12253 self.session
12254 .submit_intent(Intent::SetWorkerJob {
12255 entity_id: self.state.entity_id,
12256 worker_instance_id: worker_id.clone(),
12257 job_yaml,
12258 seq,
12259 })
12260 .await?;
12261 self.state.intents_sent += 1;
12262 if let Some(w) = self
12263 .state
12264 .hired_workers
12265 .iter_mut()
12266 .find(|w| w.instance_id == worker_id)
12267 {
12268 w.route = route_view;
12269 w.mode = mode;
12270 w.last_error = None;
12271 if idle {
12272 w.step_label.clear();
12273 w.route_stop_index = None;
12274 }
12275 }
12276 self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
12277 seq,
12278 worker_instance_id: worker_id,
12279 worker_label: ed.worker_label.clone(),
12280 idle,
12281 stop_count: ed.stops.len(),
12282 prev_route,
12283 prev_mode,
12284 prev_step_label,
12285 prev_last_error,
12286 });
12287 self.state.push_log(format!(
12288 "Route: saving for {}… (waiting for server)",
12289 ed.worker_label
12290 ));
12291 Ok(())
12293 }
12294 pub fn quest_menu_move(&mut self, delta: i32) {
12295 let n = self.state.active_quest_entries().len();
12296 if n == 0 {
12297 return;
12298 }
12299 let idx = self.state.quest_menu_index as i32;
12300 self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
12301 }
12302
12303 pub fn quest_menu_page(&mut self, pages: i32) {
12304 let n = self.state.active_quest_entries().len();
12305 self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
12306 }
12307
12308 pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
12309 let Some(offer) = self.state.pending_quest_offer.clone() else {
12310 anyhow::bail!("no quest offer");
12311 };
12312 self.seq += 1;
12313 let seq = self.seq;
12314 self.session
12315 .submit_intent(Intent::AcceptQuest {
12316 entity_id: self.state.entity_id,
12317 quest_id: offer.quest_id,
12318 seq,
12319 })
12320 .await?;
12321 self.state.intents_sent += 1;
12322 Ok(())
12323 }
12324
12325 pub fn quest_offer_decline(&mut self) {
12326 self.state.show_quest_offer = false;
12327 self.state.pending_quest_offer = None;
12328 if !self.state.show_npc_chat
12329 && !self.state.show_shop_menu
12330 && self.state.npc_verb_target.is_some()
12331 {
12332 self.state.show_npc_verb_menu = true;
12333 }
12334 }
12335
12336 pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
12337 if !self.state.show_quest_menu {
12338 return Ok(());
12339 }
12340 let active: Vec<_> = self
12341 .state
12342 .active_quest_entries()
12343 .into_iter()
12344 .cloned()
12345 .collect();
12346 let Some(entry) = active.get(self.state.quest_menu_index) else {
12347 return Ok(());
12348 };
12349 if self.state.quest_withdraw_confirm {
12350 if !entry.can_withdraw {
12351 anyhow::bail!("quest cannot be withdrawn");
12352 }
12353 self.seq += 1;
12354 let seq = self.seq;
12355 self.session
12356 .submit_intent(Intent::WithdrawQuest {
12357 entity_id: self.state.entity_id,
12358 quest_id: entry.quest_id.clone(),
12359 seq,
12360 })
12361 .await?;
12362 self.state.intents_sent += 1;
12363 self.state.quest_withdraw_confirm = false;
12364 return Ok(());
12365 }
12366 self.seq += 1;
12367 let seq = self.seq;
12368 self.session
12369 .submit_intent(Intent::TrackQuest {
12370 entity_id: self.state.entity_id,
12371 quest_id: entry.quest_id.clone(),
12372 seq,
12373 })
12374 .await?;
12375 self.state.intents_sent += 1;
12376 Ok(())
12377 }
12378
12379 pub fn quest_request_withdraw(&mut self) {
12380 if self.state.show_quest_menu {
12381 self.state.quest_withdraw_confirm = true;
12382 }
12383 }
12384
12385 pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
12386 if !self.state.is_alive() {
12387 anyhow::bail!("you are dead");
12388 }
12389 let Some(catalog) = self.state.shop_catalog.clone() else {
12390 anyhow::bail!("no shop open");
12391 };
12392 self.seq += 1;
12393 let seq = self.seq;
12394 match self.state.shop_tab {
12395 ShopTab::Buy => {
12396 let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
12397 anyhow::bail!("nothing selected");
12398 };
12399 if offer.already_owned {
12400 anyhow::bail!("already owned");
12401 }
12402 self.session
12403 .submit_intent(Intent::ShopBuy {
12404 entity_id: self.state.entity_id,
12405 npc_id: catalog.npc_id.clone(),
12406 offer_id: offer.offer_id.clone(),
12407 quantity: self.state.shop_quantity,
12408 seq,
12409 })
12410 .await?;
12411 }
12412 ShopTab::Sell => {
12413 let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
12414 anyhow::bail!("nothing to sell");
12415 };
12416 if line.quantity == 0 {
12417 anyhow::bail!("you have no {}", line.label);
12418 }
12419 let quantity = self.state.shop_quantity.min(line.quantity).max(1);
12420 self.session
12421 .submit_intent(Intent::ShopSell {
12422 entity_id: self.state.entity_id,
12423 npc_id: catalog.npc_id.clone(),
12424 template_id: line.template_id.clone(),
12425 quantity,
12426 seq,
12427 })
12428 .await?;
12429 }
12430 }
12431 self.state.intents_sent += 1;
12432 Ok(())
12433 }
12434
12435 pub fn craft_menu_move(&mut self, delta: i32) {
12436 let n = self.state.blueprints.len();
12437 if n == 0 {
12438 return;
12439 }
12440 let idx = self.state.craft_menu_index as i32;
12441 let next = (idx + delta).rem_euclid(n as i32);
12442 self.state.craft_menu_index = next as usize;
12443 self.state.clamp_craft_batch_quantity();
12444 }
12445
12446 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
12447 self.state.craft_batch_adjust_quantity(delta);
12448 }
12449
12450 pub fn craft_batch_set_max(&mut self) {
12451 self.state.craft_batch_set_max();
12452 }
12453
12454 pub fn craft_batch_set_min(&mut self) {
12455 self.state.craft_batch_set_min();
12456 }
12457
12458 pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
12459 let Some(blueprint) = self
12460 .state
12461 .blueprints
12462 .get(self.state.craft_menu_index)
12463 .cloned()
12464 else {
12465 anyhow::bail!("no blueprints known");
12466 };
12467 if !self.state.can_craft_blueprint(&blueprint) {
12468 let hint = self
12469 .state
12470 .craft_missing_hint(&blueprint)
12471 .unwrap_or_else(|| "missing materials".into());
12472 anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
12473 }
12474 let count = self.state.craft_batch_quantity;
12475 let max = self.state.max_craft_batches(&blueprint);
12476 if max == 0 {
12477 anyhow::bail!("cannot craft {}", blueprint.label);
12478 }
12479 let batches = count.min(max);
12480 self.craft(&blueprint.id, Some(batches)).await?;
12481 self.state.show_craft_menu = false;
12482 Ok(())
12483 }
12484
12485 pub async fn move_by(
12486 &mut self,
12487 forward: f32,
12488 strafe: f32,
12489 vertical: f32,
12490 sprint: bool,
12491 ) -> anyhow::Result<()> {
12492 if !self.state.is_alive() {
12493 anyhow::bail!("you are dead");
12494 }
12495 if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
12496 self.last_move_forward = forward;
12497 self.last_move_strafe = strafe;
12498 }
12499 self.seq += 1;
12500 self.session
12501 .submit_intent(Intent::Move {
12502 entity_id: self.state.entity_id,
12503 forward,
12504 strafe,
12505 vertical,
12506 sprint,
12507 seq: self.seq,
12508 })
12509 .await?;
12510 self.state.intents_sent += 1;
12511 Ok(())
12512 }
12513
12514 pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
12515 if !self.state.connected {
12516 crate::harvest_trace!("harvest_nearest rejected: not connected");
12517 anyhow::bail!("not connected");
12518 }
12519 if !self.state.is_alive() {
12520 crate::harvest_trace!("harvest_nearest rejected: player dead");
12521 anyhow::bail!("you are dead");
12522 }
12523 if self.state.harvest_in_progress {
12524 if self.state.harvest_state_stale() {
12525 self.state.clear_harvest_state();
12526 } else {
12527 anyhow::bail!("already harvesting");
12528 }
12529 }
12530 let (px, py) = self
12531 .state
12532 .player
12533 .as_ref()
12534 .map(|p| (p.transform.position.x, p.transform.position.y))
12535 .unwrap_or((0.0, 0.0));
12536
12537 let available = self
12538 .state
12539 .resource_nodes
12540 .iter()
12541 .filter(|n| !n.harvest_off)
12542 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
12543 .count();
12544 let node_id = self
12545 .state
12546 .resource_nodes
12547 .iter()
12548 .filter(|n| !n.harvest_off)
12549 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
12550 .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
12551 .min_by(|a, b| {
12552 let da = distance(px, py, a.x, a.y);
12553 let db = distance(px, py, b.x, b.y);
12554 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
12555 })
12556 .map(|n| n.id.clone());
12557
12558 let Some(node_id) = node_id else {
12559 let has_loot = self
12560 .state
12561 .ground_drops
12562 .iter()
12563 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
12564 if has_loot {
12565 return self.pickup_nearest().await;
12566 }
12567 anyhow::bail!(
12568 "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
12569 );
12570 };
12571
12572 self.seq += 1;
12573 let seq = self.seq;
12574 crate::harvest_trace!(
12575 entity_id = self.state.entity_id,
12576 node_id = %node_id,
12577 seq,
12578 px,
12579 py,
12580 available_nodes = available,
12581 "submitting harvest intent"
12582 );
12583 self.session
12584 .submit_intent(Intent::Harvest {
12585 entity_id: self.state.entity_id,
12586 node_id,
12587 seq,
12588 })
12589 .await?;
12590 self.state.intents_sent += 1;
12591 self.state.harvest_in_progress = true;
12592 self.state.harvest_started_at = Some(Instant::now());
12593 self.state.push_log("Harvesting…");
12594 crate::harvest_trace!(
12595 entity_id = self.state.entity_id,
12596 seq,
12597 "harvest intent queued to session"
12598 );
12599 Ok(())
12600 }
12601
12602 pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
12603 if !self.state.is_alive() {
12604 anyhow::bail!("you are dead");
12605 }
12606 let blueprint_id = self
12607 .state
12608 .blueprints
12609 .iter()
12610 .find(|bp| self.state.can_craft_blueprint(bp))
12611 .map(|bp| bp.id.clone())
12612 .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
12613 self.craft(&blueprint_id, None).await
12614 }
12615
12616 pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
12617 if !self.state.is_alive() {
12618 anyhow::bail!("you are dead");
12619 }
12620 self.seq += 1;
12621 self.session
12622 .submit_intent(Intent::Craft {
12623 entity_id: self.state.entity_id,
12624 blueprint_id: blueprint_id.to_string(),
12625 count,
12626 seq: self.seq,
12627 })
12628 .await?;
12629 self.state.intents_sent += 1;
12630 let (label, batches) = self
12631 .state
12632 .blueprints
12633 .iter()
12634 .find(|b| b.id == blueprint_id)
12635 .map(|b| {
12636 let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
12637 (b.label.as_str(), n)
12638 })
12639 .unwrap_or((blueprint_id, count.unwrap_or(1)));
12640 self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
12641 Ok(())
12642 }
12643
12644 pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
12645 if !self.state.is_alive() {
12646 anyhow::bail!("you are dead");
12647 }
12648 let target_id = match self.state.nearest_interact_target() {
12649 Some(id) => id,
12650 None => {
12651 anyhow::bail!("nothing to interact with nearby");
12652 }
12653 };
12654 if self.state.npcs.iter().any(|n| n.id == target_id) {
12655 self.state.show_npc_verb_menu = true;
12656 self.state.npc_verb_target = Some(target_id);
12657 self.state.npc_verb_index = 0;
12658 return Ok(());
12659 }
12660 if self
12661 .state
12662 .hired_workers
12663 .iter()
12664 .any(|w| w.instance_id == target_id)
12665 {
12666 return self.open_workers_menu_for(&target_id).await;
12667 }
12668 if let Ok(peer_id) = target_id.parse::<EntityId>() {
12669 if self
12670 .state
12671 .hired_workers
12672 .iter()
12673 .any(|w| w.entity_id == peer_id)
12674 {
12675 if let Some(w) = self
12676 .state
12677 .hired_workers
12678 .iter()
12679 .find(|w| w.entity_id == peer_id)
12680 {
12681 let id = w.instance_id.clone();
12682 return self.open_workers_menu_for(&id).await;
12683 }
12684 }
12685 if let Some(entity) = self
12686 .state
12687 .entities
12688 .iter()
12689 .find(|e| e.id == peer_id && e.id != self.state.entity_id)
12690 {
12691 self.state
12692 .player_verbs
12693 .open_for(peer_id, &entity.label);
12694 return Ok(());
12695 }
12696 }
12697 self.seq += 1;
12698 self.session
12699 .submit_intent(Intent::Interact {
12700 entity_id: self.state.entity_id,
12701 target_id: target_id.clone(),
12702 seq: self.seq,
12703 })
12704 .await?;
12705 self.state.intents_sent += 1;
12706 Ok(())
12707 }
12708
12709 pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
12711 if !self.state.is_alive() {
12712 anyhow::bail!("you are dead");
12713 }
12714 let (px, py) = self.state.player_position();
12715 let has_loot = self
12716 .state
12717 .ground_drops
12718 .iter()
12719 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
12720 if has_loot {
12721 return self.pickup_nearest().await;
12722 }
12723 if self
12724 .state
12725 .placed_containers
12726 .iter()
12727 .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
12728 {
12729 return self.pickup_nearest_container().await;
12730 }
12731
12732 if let Some(plot) = self.state.my_plot_under_player().cloned() {
12733 const SELL_WINDOW: Duration = Duration::from_millis(1200);
12735 let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
12736 && self
12737 .state
12738 .sell_plot_armed_at
12739 .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
12740 if sell_armed {
12741 return self.confirm_sell_plot_to_crown(plot.plot_id).await;
12742 }
12743 self.state.sell_plot_confirm = None;
12744 self.state.sell_plot_armed_at = None;
12745
12746 let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
12749 self.state.npcs.iter().any(|n| n.id == id)
12750 || self.state.hired_workers.iter().any(|w| w.instance_id == id)
12751 || self.state.doors.iter().any(|d| d.id == id)
12752 || self.state.interactables.iter().any(|i| {
12753 i.id == id
12754 && matches!(
12755 i.kind.as_str(),
12756 "quest_board" | "well" | "exit" | "enter"
12757 )
12758 })
12759 || id.parse::<EntityId>().is_ok_and(|eid| {
12760 self.state
12761 .entities
12762 .iter()
12763 .any(|e| e.id == eid && e.id != self.state.entity_id)
12764 })
12765 });
12766 if !blocking_interact {
12767 match self.harvest_nearest().await {
12769 Ok(()) => return Ok(()),
12770 Err(err) => {
12771 let msg = err.to_string();
12772 if !(msg.contains("no harvestable")
12773 || msg.contains("press p")
12774 || msg.contains("press f")
12775 || msg.contains("nothing"))
12776 {
12777 return Err(err);
12778 }
12779 }
12780 }
12781 return Ok(());
12782 }
12783 }
12784 if self.state.nearest_interact_target().is_some() {
12785 return self.interact_nearest().await;
12786 }
12787 if let Some((label, dist)) = self.state.nearest_quest_board() {
12790 if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
12791 anyhow::bail!(
12792 "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
12793 );
12794 }
12795 }
12796
12797 match self.harvest_nearest().await {
12798 Ok(()) => Ok(()),
12799 Err(err) => {
12800 let msg = err.to_string();
12801 if msg.contains("no harvestable")
12802 || msg.contains("press p")
12803 || msg.contains("press f")
12804 {
12805 anyhow::bail!(
12806 "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
12807 );
12808 }
12809 Err(err)
12810 }
12811 }
12812 }
12813
12814 pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
12816 if !self.state.is_alive() {
12817 anyhow::bail!("you are dead");
12818 }
12819 if self.state.claim_mode.is_some() {
12820 anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
12821 }
12822 let zone = self
12823 .state
12824 .free_property_zone_under_player()
12825 .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
12826 let zone_id = zone.id.clone();
12827 let label = zone
12828 .label
12829 .as_deref()
12830 .filter(|s| !s.trim().is_empty())
12831 .unwrap_or(zone.id.as_str())
12832 .to_string();
12833 self.enter_claim_mode(&zone_id);
12834 self.state
12835 .push_log(format!(
12836 "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
12837 ));
12838 Ok(())
12839 }
12840
12841 pub fn enter_claim_mode(&mut self, zone_id: &str) {
12843 let Some(zone) = self
12844 .state
12845 .property_zones
12846 .iter()
12847 .find(|z| z.id == zone_id)
12848 .cloned()
12849 else {
12850 self.state.push_log("unknown property zone");
12851 return;
12852 };
12853 self.state.sell_plot_confirm = None;
12854 self.state.sell_plot_armed_at = None;
12855 let min_area = self
12856 .state
12857 .property_plot_settings
12858 .as_ref()
12859 .map(|s| s.min_plot_area_m2)
12860 .unwrap_or(4.0)
12861 .max(1.0);
12862 let min_side = min_area.sqrt().ceil().max(1.0) as u32;
12863 let side = 4u32.max(min_side);
12864 let (px, py) = self.state.player_position();
12865 let anchor_x = px.floor();
12866 let anchor_y = py.floor();
12867 self.state.claim_mode = Some(ClaimModeState {
12868 zone_id: zone.id.clone(),
12869 width_m: side,
12870 height_m: side,
12871 anchor_x,
12872 anchor_y,
12873 });
12874 let label = zone
12875 .label
12876 .as_deref()
12877 .filter(|s| !s.trim().is_empty())
12878 .unwrap_or(zone.id.as_str());
12879 self.state.push_log(format!(
12880 "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
12881 ));
12882 }
12883
12884 pub fn cancel_claim_mode(&mut self) {
12885 if self.state.claim_mode.take().is_some() {
12886 self.state.push_log("Claim cancelled");
12887 }
12888 }
12889
12890 pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
12892 if !self.state.is_alive() {
12893 anyhow::bail!("you are dead");
12894 }
12895 if self.state.relocate_mode.is_some() {
12896 anyhow::bail!("already relocating — Enter confirm, Esc cancel");
12897 }
12898 if self.state.claim_mode.is_some() {
12899 anyhow::bail!("finish or cancel claim mode first");
12900 }
12901 let chest = self
12902 .state
12903 .placed_containers
12904 .iter()
12905 .find(|c| c.id == container_id)
12906 .cloned()
12907 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
12908 let (px, py) = self.state.player_position();
12909 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
12910 anyhow::bail!("too far from {}", chest.display_name);
12911 }
12912 if chest.locked && !chest.accessible {
12913 anyhow::bail!(
12914 "need the matching key for {} before moving it",
12915 chest.display_name
12916 );
12917 }
12918 let label = if chest.display_name.trim().is_empty() {
12919 chest.template_id.clone()
12920 } else {
12921 chest.display_name.clone()
12922 };
12923 self.state.relocate_mode = Some(RelocateModeState {
12924 container_id: chest.id.clone(),
12925 label: label.clone(),
12926 cursor_x: chest.x.floor() + 0.5,
12927 cursor_y: chest.y.floor() + 0.5,
12928 });
12929 self.state.push_log(format!(
12930 "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
12931 ));
12932 Ok(())
12933 }
12934
12935 pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
12937 let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
12938 anyhow::bail!("no chest nearby to relocate");
12939 };
12940 if chest.locked && !chest.accessible {
12941 anyhow::bail!(
12942 "need the matching key for {} before moving it",
12943 chest.display_name
12944 );
12945 }
12946 self.begin_relocate_container(&chest.id)
12949 }
12950
12951 pub fn cancel_relocate_mode(&mut self) {
12952 if self.state.relocate_mode.take().is_some() {
12953 self.state.push_log("Relocate cancelled");
12954 }
12955 }
12956
12957 pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
12958 let Some(mode) = self.state.relocate_mode.as_mut() else {
12959 return;
12960 };
12961 let max_x = self.state.world_width_m.max(1.0);
12962 let max_y = self.state.world_height_m.max(1.0);
12963 let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
12964 let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
12965 mode.cursor_x = nx.floor() + 0.5;
12966 mode.cursor_y = ny.floor() + 0.5;
12967 }
12968
12969 pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
12970 let Some(mode) = self.state.relocate_mode.as_mut() else {
12971 return;
12972 };
12973 let max_x = self.state.world_width_m.max(1.0);
12974 let max_y = self.state.world_height_m.max(1.0);
12975 mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
12976 mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
12977 }
12978
12979 pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
12980 if !self.state.is_alive() {
12981 anyhow::bail!("you are dead");
12982 }
12983 let Some(mode) = self.state.relocate_mode.clone() else {
12984 anyhow::bail!("not relocating");
12985 };
12986 let (px, py) = self.state.player_position();
12987 let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
12988 if dist > 8.0 {
12989 anyhow::bail!("destination too far (max 8 m)");
12990 }
12991 self.seq += 1;
12992 self.session
12993 .submit_intent(Intent::MovePlacedContainer {
12994 entity_id: self.state.entity_id,
12995 container_id: mode.container_id.clone(),
12996 x: mode.cursor_x,
12997 y: mode.cursor_y,
12998 seq: self.seq,
12999 })
13000 .await?;
13001 self.state.intents_sent += 1;
13002 self.state.relocate_mode = None;
13003 self.state
13004 .push_log(format!("Moving {}…", mode.label));
13005 Ok(())
13006 }
13007
13008 pub fn claim_set_preset(&mut self, w: u32, h: u32) {
13009 let Some(mode) = self.state.claim_mode.as_mut() else {
13010 return;
13011 };
13012 mode.width_m = w.max(1);
13013 mode.height_m = h.max(1);
13014 }
13015
13016 pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
13017 let Some(mode) = self.state.claim_mode.as_mut() else {
13018 return;
13019 };
13020 let w = (mode.width_m as i32 + dw).max(1) as u32;
13021 let h = (mode.height_m as i32 + dh).max(1) as u32;
13022 mode.width_m = w;
13023 mode.height_m = h;
13024 }
13025
13026 pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
13028 let Some(mode) = self.state.claim_mode.as_mut() else {
13029 return;
13030 };
13031 let max_x = self.state.world_width_m.max(1.0);
13032 let max_y = self.state.world_height_m.max(1.0);
13033 let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
13034 let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
13035 mode.anchor_x = nx.floor();
13036 mode.anchor_y = ny.floor();
13037 }
13038
13039 pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
13040 if !self.state.is_alive() {
13041 anyhow::bail!("you are dead");
13042 }
13043 let Some(mode) = self.state.claim_mode.clone() else {
13044 anyhow::bail!("not in claim mode");
13045 };
13046 let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
13047 self.state.claim_quote()
13048 else {
13049 anyhow::bail!("cannot quote claim");
13050 };
13051 if !valid {
13052 anyhow::bail!(reason);
13053 }
13054 if !can_afford {
13055 anyhow::bail!(
13056 "not enough copper (need {})",
13057 crate::currency::format_copper(purchase)
13058 );
13059 }
13060 let (x0, y0, x1, y1) = self
13061 .state
13062 .claim_footprint_rect()
13063 .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
13064 let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
13065 self.seq += 1;
13066 self.session
13067 .submit_intent(Intent::BuyPlot {
13068 entity_id: self.state.entity_id,
13069 zone_id: mode.zone_id,
13070 x0,
13071 y0,
13072 x1,
13073 y1,
13074 seq: self.seq,
13075 })
13076 .await?;
13077 self.state.intents_sent += 1;
13078 self.state.claim_mode = None;
13079 self.state
13080 .push_log(format!("Buying plot for {}", crate::currency::format_copper(purchase)));
13081 Ok(())
13082 }
13083
13084 pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
13085 if !self.state.is_alive() {
13086 anyhow::bail!("you are dead");
13087 }
13088 let zone_id = self
13089 .state
13090 .claim_mode
13091 .as_ref()
13092 .map(|m| m.zone_id.clone())
13093 .or_else(|| {
13094 self.state
13095 .free_property_zone_under_player()
13096 .map(|z| z.id.clone())
13097 })
13098 .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
13099 self.seq += 1;
13100 self.session
13101 .submit_intent(Intent::BuyPlotAllFree {
13102 entity_id: self.state.entity_id,
13103 zone_id,
13104 seq: self.seq,
13105 })
13106 .await?;
13107 self.state.intents_sent += 1;
13108 self.state.claim_mode = None;
13109 self.state.push_log("Claiming largest free plot…");
13110 Ok(())
13111 }
13112
13113 pub async fn confirm_sell_plot_to_crown(
13114 &mut self,
13115 plot_id: uuid::Uuid,
13116 ) -> anyhow::Result<()> {
13117 if !self.state.is_alive() {
13118 anyhow::bail!("you are dead");
13119 }
13120 self.seq += 1;
13121 self.session
13122 .submit_intent(Intent::SellPlotToCrown {
13123 entity_id: self.state.entity_id,
13124 plot_id,
13125 seq: self.seq,
13126 })
13127 .await?;
13128 self.state.intents_sent += 1;
13129 self.state.sell_plot_confirm = None;
13130 self.state.sell_plot_armed_at = None;
13131 self.state.push_log("Selling plot to the crown…");
13132 Ok(())
13133 }
13134
13135 pub async fn set_plot_farm_public(
13136 &mut self,
13137 plot_id: uuid::Uuid,
13138 public: bool,
13139 public_tax_discount_bps: u32,
13140 ) -> anyhow::Result<()> {
13141 self.seq += 1;
13142 self.session
13143 .submit_intent(Intent::SetPlotFarmPublic {
13144 entity_id: self.state.entity_id,
13145 plot_id,
13146 public,
13147 public_tax_discount_bps,
13148 seq: self.seq,
13149 })
13150 .await?;
13151 self.state.intents_sent += 1;
13152 Ok(())
13153 }
13154
13155 pub async fn plot_farm_allow_upsert(
13156 &mut self,
13157 plot_id: uuid::Uuid,
13158 character_id: Option<uuid::Uuid>,
13159 character_name: String,
13160 tax_discount_bps: u32,
13161 ) -> anyhow::Result<()> {
13162 self.seq += 1;
13163 self.session
13164 .submit_intent(Intent::PlotFarmAllowUpsert {
13165 entity_id: self.state.entity_id,
13166 plot_id,
13167 character_id,
13168 character_name,
13169 tax_discount_bps,
13170 seq: self.seq,
13171 })
13172 .await?;
13173 self.state.intents_sent += 1;
13174 Ok(())
13175 }
13176
13177 pub async fn plot_farm_allow_remove(
13178 &mut self,
13179 plot_id: uuid::Uuid,
13180 character_id: uuid::Uuid,
13181 ) -> anyhow::Result<()> {
13182 self.seq += 1;
13183 self.session
13184 .submit_intent(Intent::PlotFarmAllowRemove {
13185 entity_id: self.state.entity_id,
13186 plot_id,
13187 character_id,
13188 seq: self.seq,
13189 })
13190 .await?;
13191 self.state.intents_sent += 1;
13192 Ok(())
13193 }
13194
13195 pub fn open_farm_access_panel(&mut self) {
13196 let Some(plot) = self.state.my_plot_under_player() else {
13197 self.state
13198 .push_log("Stand on your deed plot to manage farm access");
13199 return;
13200 };
13201 self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
13202 self.state.farm_access_index = 0;
13203 self.state.show_farm_access = true;
13204 }
13205
13206 pub fn close_farm_access_panel(&mut self) {
13207 self.state.show_farm_access = false;
13208 self.state.farm_access_name_draft.clear();
13209 self.state.farm_access_index = 0;
13210 }
13211
13212 pub fn farm_access_move(&mut self, delta: i32) {
13213 let n = self.farm_access_row_count().max(1);
13214 let idx = self.state.farm_access_index as i32 + delta;
13215 self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
13216 }
13217
13218 pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
13219 let Some(plot) = self.state.my_plot_under_player() else {
13220 return vec![FarmAccessRow::PublicToggle];
13221 };
13222 let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
13223 for g in &plot.farm_allow {
13224 rows.push(FarmAccessRow::AllowRemove {
13225 character_id: g.character_id,
13226 label: if g.character_label.trim().is_empty() {
13227 g.character_id.to_string()[..8].to_string()
13228 } else {
13229 g.character_label.clone()
13230 },
13231 tax_discount_bps: g.tax_discount_bps,
13232 });
13233 }
13234 for e in &self.state.entities {
13235 if e.id == self.state.entity_id || e.label.trim().is_empty() {
13236 continue;
13237 }
13238 if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
13239 continue;
13240 }
13241 if self
13242 .state
13243 .npcs
13244 .iter()
13245 .any(|n| n.id == e.label || n.label == e.label)
13246 {
13247 continue;
13248 }
13249 if plot
13250 .farm_allow
13251 .iter()
13252 .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
13253 {
13254 continue;
13255 }
13256 rows.push(FarmAccessRow::NearbyAdd {
13257 name: e.label.clone(),
13258 });
13259 }
13260 rows
13261 }
13262
13263 pub fn farm_access_row_count(&self) -> usize {
13264 self.farm_access_rows().len().max(1)
13265 }
13266
13267 pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
13268 let Some(plot) = self.state.my_plot_under_player().cloned() else {
13269 self.close_farm_access_panel();
13270 return Ok(());
13271 };
13272 let rows = self.farm_access_rows();
13273 let Some(row) = rows.get(self.state.farm_access_index) else {
13274 return Ok(());
13275 };
13276 match row {
13277 FarmAccessRow::PublicToggle => {
13278 self.set_plot_farm_public(
13279 plot.plot_id,
13280 !plot.farm_public,
13281 plot.public_tax_discount_bps,
13282 )
13283 .await
13284 }
13285 FarmAccessRow::PublicDiscount => Ok(()),
13286 FarmAccessRow::AllowRemove { character_id, .. } => {
13287 self.plot_farm_allow_remove(plot.plot_id, *character_id)
13288 .await
13289 }
13290 FarmAccessRow::NearbyAdd { name } => {
13291 let disc = self
13292 .state
13293 .farm_access_discount_bps
13294 .max(plot.public_tax_discount_bps);
13295 self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
13296 .await
13297 }
13298 }
13299 }
13300
13301 pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
13302 let Some(plot) = self.state.my_plot_under_player().cloned() else {
13303 return Ok(());
13304 };
13305 let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
13306 self.state.farm_access_discount_bps = next;
13307 self.state.farm_access_index = 1;
13308 self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
13309 .await
13310 }
13311
13312 pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
13314 if self.state.farmable_plot_under_player().is_none() {
13315 anyhow::bail!("stand on a farmable plot to cultivate");
13316 }
13317 let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
13318 let (px, py) = self.state.player_position();
13319 if self
13320 .state
13321 .terrain_at(px, py)
13322 .is_some_and(|k| k == TerrainKindView::Tilled)
13323 {
13324 anyhow::bail!("already tilled — stand on bare soil and press c");
13325 }
13326 anyhow::bail!("cannot till this cell — move onto soil on your plot");
13327 };
13328 self.cultivate_at(tx, ty).await
13329 }
13330
13331 pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
13333 if self.state.farmable_plot_under_player().is_none() {
13334 anyhow::bail!("stand on a farmable plot to plant");
13335 }
13336 if !self.state.underfoot_free_tilled_plant_slot() {
13337 anyhow::bail!("stand on empty tilled soil and press p");
13338 }
13339 let seeds = self.state.farm_seed_entries();
13340 if seeds.is_empty() {
13341 anyhow::bail!("no seeds in inventory — buy seeds from Eli");
13342 }
13343 if seeds.len() == 1 {
13344 return self.plant_seeds(seeds[0].0.clone(), 1).await;
13345 }
13346 self.open_plant_menu();
13347 Ok(())
13348 }
13349
13350 pub fn open_plot_build_menu(&mut self) -> anyhow::Result<()> {
13352 let Some(plot) = self.state.my_plot_under_player() else {
13353 anyhow::bail!("stand on your plot to build");
13354 };
13355 if plot.building_id.is_some() {
13356 anyhow::bail!("this plot already has a building");
13357 }
13358 let building_now = self
13359 .state
13360 .timed_channel
13361 .as_ref()
13362 .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
13363 if !building_now && self.state.building_materials.is_empty() {
13364 anyhow::bail!("no building materials loaded — wait a moment and try again");
13365 }
13366 self.state.show_plot_build_menu = true;
13367 self.state.show_craft_menu = false;
13368 self.state.show_shop_menu = false;
13369 self.state.shop_catalog = None;
13370 self.state.show_stats = false;
13371 self.state.show_inventory_menu = false;
13372 self.state.plot_build_focus_wall = true;
13373 let walls = self.state.plot_build_wall_options().len();
13374 let roofs = self.state.plot_build_roof_options().len();
13375 if walls > 0 {
13376 self.state.plot_build_wall_index = self.state.plot_build_wall_index.min(walls - 1);
13377 } else {
13378 self.state.plot_build_wall_index = 0;
13379 }
13380 if roofs > 0 {
13381 self.state.plot_build_roof_index = self.state.plot_build_roof_index.min(roofs - 1);
13382 } else {
13383 self.state.plot_build_roof_index = 0;
13384 }
13385 Ok(())
13386 }
13387
13388 pub fn close_plot_build_menu(&mut self) {
13389 self.state.show_plot_build_menu = false;
13390 }
13391
13392 pub fn plot_build_menu_move(&mut self, delta: i32) {
13393 let walls = self.state.plot_build_wall_options();
13394 let roofs = self.state.plot_build_roof_options();
13395 if self.state.plot_build_focus_wall {
13396 if walls.is_empty() {
13397 return;
13398 }
13399 let n = walls.len() as i32;
13400 let cur = self.state.plot_build_wall_index as i32;
13401 self.state.plot_build_wall_index = ((cur + delta).rem_euclid(n)) as usize;
13402 } else {
13403 if roofs.is_empty() {
13404 return;
13405 }
13406 let n = roofs.len() as i32;
13407 let cur = self.state.plot_build_roof_index as i32;
13408 self.state.plot_build_roof_index = ((cur + delta).rem_euclid(n)) as usize;
13409 }
13410 }
13411
13412 pub fn plot_build_menu_toggle_focus(&mut self) {
13413 self.state.plot_build_focus_wall = !self.state.plot_build_focus_wall;
13414 }
13415
13416 pub async fn plot_build_menu_confirm(&mut self) -> anyhow::Result<()> {
13418 let wall = self
13419 .state
13420 .plot_build_selected_wall()
13421 .ok_or_else(|| anyhow::anyhow!("pick a wall material"))?
13422 .id
13423 .clone();
13424 let roof = self
13425 .state
13426 .plot_build_selected_roof()
13427 .ok_or_else(|| anyhow::anyhow!("pick a roof material"))?
13428 .id
13429 .clone();
13430 self.start_plot_build(&wall, &roof).await
13432 }
13433
13434 pub async fn plot_build_menu_cancel_build(&mut self) -> anyhow::Result<()> {
13436 self.seq += 1;
13437 self.session
13438 .submit_intent(Intent::CancelPlotBuild {
13439 entity_id: self.state.entity_id,
13440 seq: self.seq,
13441 })
13442 .await?;
13443 self.state.intents_sent += 1;
13444 Ok(())
13445 }
13446
13447 pub async fn start_plot_build(
13449 &mut self,
13450 wall_material_id: &str,
13451 roof_material_id: &str,
13452 ) -> anyhow::Result<()> {
13453 let Some(plot) = self.state.my_plot_under_player() else {
13454 anyhow::bail!("stand on your plot to build");
13455 };
13456 if plot.building_id.is_some() {
13457 anyhow::bail!("this plot already has a building");
13458 }
13459 let plot_id = plot.plot_id;
13460 self.seq += 1;
13461 self.session
13462 .submit_intent(Intent::StartPlotBuild {
13463 entity_id: self.state.entity_id,
13464 plot_id,
13465 wall_material_id: wall_material_id.to_string(),
13466 roof_material_id: roof_material_id.to_string(),
13467 seq: self.seq,
13468 })
13469 .await?;
13470 self.state.intents_sent += 1;
13471 Ok(())
13472 }
13473
13474 pub async fn toggle_nearby_door_lock(&mut self) -> anyhow::Result<()> {
13476 let (px, py) = self.state.player_position();
13477 let mut best: Option<(f32, String, bool)> = None;
13478 for d in &self.state.doors {
13479 if d.lock_id.is_none() {
13480 continue;
13481 }
13482 let dist = (d.x - px).hypot(d.y - py);
13483 if dist > 3.5 {
13484 continue;
13485 }
13486 if best.as_ref().is_none_or(|(bd, _, _)| dist < *bd) {
13487 best = Some((dist, d.id.clone(), d.locked));
13488 }
13489 }
13490 let Some((_, door_id, locked_now)) = best else {
13491 anyhow::bail!("no lockable door nearby");
13492 };
13493 let locked = !locked_now;
13494 self.seq += 1;
13495 self.session
13496 .submit_intent(Intent::SetDoorLocked {
13497 entity_id: self.state.entity_id,
13498 door_id,
13499 locked,
13500 seq: self.seq,
13501 })
13502 .await?;
13503 self.state.intents_sent += 1;
13504 Ok(())
13505 }
13506
13507 pub async fn enter_nearby_open_door(&mut self) -> anyhow::Result<()> {
13509 if !self.state.is_alive() {
13510 anyhow::bail!("you are dead");
13511 }
13512 if self.state.effective_inside_building().is_some() {
13513 anyhow::bail!("already inside");
13514 }
13515 let (px, py) = self.state.player_position();
13516 let mut best: Option<(f32, String)> = None;
13517 for d in &self.state.doors {
13518 if !d.open || d.locked {
13519 continue;
13520 }
13521 let player_house = self
13522 .state
13523 .buildings
13524 .iter()
13525 .find(|b| b.id == d.building_id)
13526 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
13527 if !player_house {
13528 continue;
13529 }
13530 let dist = (d.x - px).hypot(d.y - py);
13531 if dist > 3.5 {
13532 continue;
13533 }
13534 if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
13535 best = Some((dist, d.id.clone()));
13536 }
13537 }
13538 let Some((_, door_id)) = best else {
13539 anyhow::bail!("no open house door nearby — open with f first");
13540 };
13541 self.seq += 1;
13542 self.session
13543 .submit_intent(Intent::EnterBuildingDoor {
13544 entity_id: self.state.entity_id,
13545 door_id,
13546 seq: self.seq,
13547 })
13548 .await?;
13549 self.state.intents_sent += 1;
13550 Ok(())
13551 }
13552
13553 pub async fn exit_nearby_building_door(&mut self) -> anyhow::Result<()> {
13556 if !self.state.is_alive() {
13557 anyhow::bail!("you are dead");
13558 }
13559 let Some(bid) = self.state.effective_inside_building() else {
13560 anyhow::bail!("not inside a building");
13561 };
13562 let (px, py) = self.state.player_position();
13563 let mut best: Option<(f32, String)> = None;
13564 for d in &self.state.doors {
13565 if d.building_id != bid || d.portal.is_none() {
13566 continue;
13567 }
13568 let player_house = self
13569 .state
13570 .buildings
13571 .iter()
13572 .find(|b| b.id == d.building_id)
13573 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
13574 if !player_house {
13575 continue;
13576 }
13577 let dist = (d.x - px).hypot(d.y - py);
13578 if dist > 1.5 {
13579 continue;
13580 }
13581 if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
13582 best = Some((dist, d.id.clone()));
13583 }
13584 }
13585 let Some((_, door_id)) = best else {
13586 anyhow::bail!("stand by the door to exit");
13587 };
13588 self.seq += 1;
13589 self.session
13590 .submit_intent(Intent::ExitBuildingDoor {
13591 entity_id: self.state.entity_id,
13592 door_id,
13593 seq: self.seq,
13594 })
13595 .await?;
13596 self.state.intents_sent += 1;
13597 Ok(())
13598 }
13599
13600 pub async fn confirm_interior_edit(
13602 &mut self,
13603 building_id: String,
13604 rooms: Vec<flatland_protocol::InteriorRoomEdit>,
13605 room_doors: Vec<flatland_protocol::InteriorRoomDoorEdit>,
13606 ) -> anyhow::Result<()> {
13607 self.seq += 1;
13608 self.session
13609 .submit_intent(Intent::ConfirmInteriorEdit {
13610 entity_id: self.state.entity_id,
13611 building_id,
13612 rooms,
13613 room_doors,
13614 seq: self.seq,
13615 })
13616 .await?;
13617 self.state.intents_sent += 1;
13618 Ok(())
13619 }
13620
13621 pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
13622 if !self.state.is_alive() {
13623 anyhow::bail!("you are dead");
13624 }
13625 self.seq += 1;
13626 self.session
13627 .submit_intent(Intent::Cultivate {
13628 entity_id: self.state.entity_id,
13629 x,
13630 y,
13631 seq: self.seq,
13632 })
13633 .await?;
13634 self.state.intents_sent += 1;
13635 Ok(())
13636 }
13637
13638 pub async fn plant_seeds(
13639 &mut self,
13640 seed_template_id: String,
13641 quantity: u32,
13642 ) -> anyhow::Result<()> {
13643 if !self.state.is_alive() {
13644 anyhow::bail!("you are dead");
13645 }
13646 self.seq += 1;
13647 self.session
13648 .submit_intent(Intent::PlantSeeds {
13649 entity_id: self.state.entity_id,
13650 seed_template_id: seed_template_id.clone(),
13651 quantity,
13652 seq: self.seq,
13653 })
13654 .await?;
13655 self.state.intents_sent += 1;
13656 self.state
13657 .push_log(format!("Planting {quantity}× {seed_template_id}…"));
13658 Ok(())
13659 }
13660
13661 pub fn open_plant_menu(&mut self) {
13662 if self.state.farm_seed_entries().is_empty() {
13663 self.state.push_log("No seeds in inventory to plant");
13664 return;
13665 }
13666 self.state.show_plant_menu = true;
13667 self.state.plant_menu_index = 0;
13668 self.state.plant_quantity = 1;
13669 self.state.clamp_plant_menu();
13670 }
13671
13672 pub fn close_plant_menu(&mut self) {
13673 self.state.show_plant_menu = false;
13674 }
13675
13676 pub fn plant_menu_move(&mut self, delta: i32) {
13677 let n = self.state.farm_seed_entries().len();
13678 if n == 0 {
13679 return;
13680 }
13681 let idx = self.state.plant_menu_index as i32 + delta;
13682 self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
13683 self.state.clamp_plant_menu();
13684 }
13685
13686 pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
13687 let next = self.state.plant_quantity as i32 + delta;
13688 self.state.plant_quantity = next.max(1) as u32;
13689 self.state.clamp_plant_menu();
13690 }
13691
13692 pub fn plant_menu_set_quantity_max(&mut self) {
13693 if let Some((_, max, _)) = self.state.plant_menu_selection() {
13694 self.state.plant_quantity = max;
13695 }
13696 self.state.clamp_plant_menu();
13697 }
13698
13699 pub fn plant_menu_set_quantity_min(&mut self) {
13700 self.state.plant_quantity = 1;
13701 self.state.clamp_plant_menu();
13702 }
13703
13704 pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
13705 let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
13706 self.close_plant_menu();
13707 anyhow::bail!("no seeds to plant");
13708 };
13709 self.close_plant_menu();
13710 self.plant_seeds(seed, qty).await?;
13711 self.state.push_log(format!("Planted {qty}× {label}"));
13712 Ok(())
13713 }
13714
13715 pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
13718 if !self.state.is_alive() {
13719 anyhow::bail!("you are dead");
13720 }
13721 let binding = self
13722 .state
13723 .hotbar_ability(slot)
13724 .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
13725 .to_string();
13726 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
13727 let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
13728 if qty == 0 {
13729 anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
13730 }
13731 return self.use_item(template_id).await;
13732 }
13733 let ability_id = binding;
13734 if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
13735 return self
13736 .cast_ability(&ability_id, Some(self.state.entity_id))
13737 .await;
13738 }
13739 let is_heal = ability_id == "heal_touch"
13740 || self
13741 .state
13742 .ability_meta
13743 .get(&ability_id)
13744 .map(|meta| meta.is_heal)
13745 .unwrap_or(false);
13746 let target = if is_heal {
13747 Some(
13748 self.state
13749 .target_for_slot(2)
13750 .unwrap_or(self.state.entity_id),
13751 )
13752 } else {
13753 self.state
13754 .target_for_slot(1)
13755 .or_else(|| self.state.target_for_slot(2))
13756 };
13757 let Some(target_id) = target else {
13758 anyhow::bail!("no target — Tab to select, then press the hotbar key");
13759 };
13760 self.cast_ability(&ability_id, Some(target_id)).await
13761 }
13762
13763 pub async fn set_hotbar_slot(
13766 &mut self,
13767 slot: u8,
13768 ability_id: Option<&str>,
13769 ) -> anyhow::Result<()> {
13770 if !self.state.is_alive() {
13771 anyhow::bail!("you are dead");
13772 }
13773 if !(1..=9).contains(&slot) {
13774 anyhow::bail!("hotbar slot must be 1–9");
13775 }
13776 let ability_id = ability_id
13777 .map(str::trim)
13778 .filter(|id| !id.is_empty())
13779 .map(str::to_string);
13780 self.seq += 1;
13781 self.session
13782 .submit_intent(Intent::SetHotbarSlot {
13783 entity_id: self.state.entity_id,
13784 slot,
13785 ability_id: ability_id.clone(),
13786 seq: self.seq,
13787 })
13788 .await?;
13789 self.state.intents_sent += 1;
13790 let idx = (slot - 1) as usize;
13791 if self.state.hotbar.len() < 9 {
13792 self.state.hotbar.resize(9, None);
13793 }
13794 if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
13795 *slot_mut = ability_id.clone();
13796 }
13797 match ability_id {
13798 Some(id) => {
13799 let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
13800 format!("use {tid}")
13801 } else {
13802 id
13803 };
13804 self.state.push_log(format!("Hotbar {slot} → {label}"))
13805 }
13806 None => self.state.push_log(format!("Hotbar {slot} cleared")),
13807 }
13808 Ok(())
13809 }
13810
13811 pub fn npc_verb_options(&self) -> Vec<&'static str> {
13812 self.state.npc_verb_options()
13813 }
13814
13815 pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
13816 let Some(npc_id) = self.state.npc_verb_target.clone() else {
13817 return Ok(());
13818 };
13819 let options = self.npc_verb_options();
13820 let choice = options
13821 .get(self.state.npc_verb_index)
13822 .copied()
13823 .unwrap_or("Talk");
13824 self.seq += 1;
13825 match choice {
13826 "Trade" | "Bank" | "Storage" | "Market" => {
13827 self.session
13828 .submit_intent(Intent::Interact {
13829 entity_id: self.state.entity_id,
13830 target_id: npc_id,
13831 seq: self.seq,
13832 })
13833 .await?;
13834 }
13835 _ => {
13836 self.session
13837 .submit_intent(Intent::NpcTalkOpen {
13838 entity_id: self.state.entity_id,
13839 npc_id,
13840 seq: self.seq,
13841 })
13842 .await?;
13843 }
13844 }
13845 self.state.intents_sent += 1;
13846 Ok(())
13847 }
13848
13849 pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
13850 let Some(chat) = self.state.npc_chat.clone() else {
13851 return Ok(());
13852 };
13853 let message = chat.input.trim().to_string();
13854 if message.is_empty() || chat.pending {
13855 return Ok(());
13856 }
13857 if let Some(c) = self.state.npc_chat.as_mut() {
13858 c.lines.push(format!("You: {message}"));
13859 c.input.clear();
13860 c.pending = true;
13861 }
13862 self.seq += 1;
13863 self.session
13864 .submit_intent(Intent::NpcTalkSay {
13865 entity_id: self.state.entity_id,
13866 npc_id: chat.npc_id,
13867 message,
13868 seq: self.seq,
13869 })
13870 .await?;
13871 self.state.intents_sent += 1;
13872 Ok(())
13873 }
13874
13875 pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
13876 let return_to_verbs = self.state.npc_verb_target.is_some();
13877 let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
13878 self.state.show_npc_chat = false;
13879 if return_to_verbs {
13880 self.state.show_npc_verb_menu = true;
13881 }
13882 return Ok(());
13883 };
13884 self.seq += 1;
13885 self.session
13886 .submit_intent(Intent::NpcTalkClose {
13887 entity_id: self.state.entity_id,
13888 npc_id,
13889 seq: self.seq,
13890 })
13891 .await?;
13892 self.state.intents_sent += 1;
13893 self.state.show_npc_chat = false;
13894 self.state.npc_chat = None;
13895 if return_to_verbs {
13896 self.state.show_npc_verb_menu = true;
13897 }
13898 Ok(())
13899 }
13900
13901 pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
13903 if self.state.show_quest_offer
13904 && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
13905 {
13906 self.quest_offer_decline();
13907 return Ok(());
13908 }
13909 if self.state.show_npc_chat {
13910 return self.npc_talk_close().await;
13911 }
13912 if self.state.show_shop_menu {
13913 return self.back_from_shop_menu().await;
13914 }
13915 if self.state.bank_panel.is_some() {
13916 if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
13917 self.bank_transfer_back();
13918 return Ok(());
13919 }
13920 return self.close_bank_panel().await;
13921 }
13922 if self.state.storage_panel.is_some() {
13923 if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
13924 self.storage_ui_back();
13925 return Ok(());
13926 }
13927 return self.close_storage_panel().await;
13928 }
13929 if self.state.market_panel.is_some() {
13930 if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
13931 self.market_ui_back();
13932 return Ok(());
13933 }
13934 if self.state.market_buy_confirm.is_some() {
13935 self.state.market_buy_confirm = None;
13936 return Ok(());
13937 }
13938 return self.close_market_panel().await;
13939 }
13940 if self.state.show_npc_verb_menu {
13941 self.state.show_npc_verb_menu = false;
13942 self.state.npc_verb_target = None;
13943 }
13944 Ok(())
13945 }
13946
13947 pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
13948 self.seq += 1;
13949 self.session
13950 .submit_intent(Intent::TestDamage {
13951 entity_id: self.state.entity_id,
13952 amount,
13953 seq: self.seq,
13954 })
13955 .await?;
13956 self.state.intents_sent += 1;
13957 Ok(())
13958 }
13959
13960 pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
13961 self.cycle_combat_target_slot(1, reverse).await
13962 }
13963
13964 pub async fn cycle_combat_target_slot(
13965 &mut self,
13966 slot_index: u8,
13967 reverse: bool,
13968 ) -> anyhow::Result<()> {
13969 if !self.state.is_alive() {
13970 anyhow::bail!("you are dead");
13971 }
13972 let candidates = self.state.candidates_for_slot(slot_index);
13973 if candidates.is_empty() {
13974 anyhow::bail!("no targets nearby");
13975 }
13976 let current = self.state.target_for_slot(slot_index);
13977 let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
13978 let next_idx = match idx {
13979 None => 0,
13980 Some(i) if reverse => {
13981 if i == 0 {
13982 candidates.len() - 1
13983 } else {
13984 i - 1
13985 }
13986 }
13987 Some(i) => (i + 1) % candidates.len(),
13988 };
13989 if idx == Some(next_idx) && candidates.len() == 1 {
13990 self.clear_combat_target_slot(slot_index).await?;
13991 return Ok(());
13992 }
13993 let (target_id, label) = candidates[next_idx].clone();
13994 self.set_combat_target_slot(slot_index, target_id, &label)
13995 .await
13996 }
13997
13998 pub async fn set_combat_target_slot(
13999 &mut self,
14000 slot_index: u8,
14001 target_id: EntityId,
14002 label: &str,
14003 ) -> anyhow::Result<()> {
14004 if !self.state.is_alive() {
14005 anyhow::bail!("you are dead");
14006 }
14007 self.seq += 1;
14008 self.session
14009 .submit_intent(Intent::SetTargetSlot {
14010 entity_id: self.state.entity_id,
14011 slot_index,
14012 target_id,
14013 seq: self.seq,
14014 })
14015 .await?;
14016 self.state.intents_sent += 1;
14017 if slot_index == 1 {
14018 self.state.combat_target = Some(target_id);
14019 self.state.combat_target_label = Some(label.to_string());
14020 }
14021 self.state
14022 .push_log(format!("Slot {slot_index} target: {label}"));
14023 Ok(())
14024 }
14025
14026 pub async fn set_combat_target(
14027 &mut self,
14028 target_id: EntityId,
14029 label: &str,
14030 ) -> anyhow::Result<()> {
14031 self.set_combat_target_slot(1, target_id, label).await
14032 }
14033
14034 pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
14035 if slot_index == 1 && self.state.combat_target.is_none() {
14036 return Ok(());
14037 }
14038 self.seq += 1;
14039 self.session
14040 .submit_intent(Intent::ClearTargetSlot {
14041 entity_id: self.state.entity_id,
14042 slot_index,
14043 seq: self.seq,
14044 })
14045 .await?;
14046 if slot_index == 1 {
14047 self.state.combat_target = None;
14048 self.state.combat_target_label = None;
14049 }
14050 self.state.intents_sent += 1;
14051 self.state
14052 .push_log(format!("Slot {slot_index} target cleared"));
14053 Ok(())
14054 }
14055
14056 pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
14057 self.clear_combat_target_slot(1).await
14058 }
14059
14060 pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
14061 if !self.state.is_alive() {
14062 anyhow::bail!("you are dead");
14063 }
14064 self.seq += 1;
14065 self.session
14066 .submit_intent(Intent::AdvanceRotation {
14067 entity_id: self.state.entity_id,
14068 slot_index,
14069 seq: self.seq,
14070 })
14071 .await?;
14072 self.state.intents_sent += 1;
14073 Ok(())
14074 }
14075
14076 pub async fn assign_slot_preset(
14077 &mut self,
14078 slot_index: u8,
14079 preset_id: &str,
14080 ) -> anyhow::Result<()> {
14081 if !self.state.is_alive() {
14082 anyhow::bail!("you are dead");
14083 }
14084 self.seq += 1;
14085 self.session
14086 .submit_intent(Intent::AssignSlotPreset {
14087 entity_id: self.state.entity_id,
14088 slot_index,
14089 preset_id: preset_id.to_string(),
14090 seq: self.seq,
14091 })
14092 .await?;
14093 self.state.intents_sent += 1;
14094 if let Some(slot) = self
14095 .state
14096 .combat_slots
14097 .iter_mut()
14098 .find(|s| s.slot_index == slot_index)
14099 {
14100 slot.preset_id = Some(preset_id.to_string());
14101 if let Some(preset) = self
14102 .state
14103 .rotation_presets
14104 .iter()
14105 .find(|p| p.id == preset_id)
14106 {
14107 slot.preset_label = Some(preset.label.clone());
14108 slot.rotation = preset.abilities.clone();
14109 slot.rotation_index = 0;
14110 }
14111 }
14112 self.state
14113 .push_log(format!("T{slot_index} loadout → {preset_id}"));
14114 Ok(())
14115 }
14116
14117 pub async fn cast_ability(
14118 &mut self,
14119 ability_id: &str,
14120 target_id: Option<EntityId>,
14121 ) -> anyhow::Result<()> {
14122 if !self.state.is_alive() {
14123 anyhow::bail!("you are dead");
14124 }
14125 let allows_ground = self.state.ability_allows_ground(ability_id);
14126 let requires_ground = self.state.ability_requires_ground(ability_id);
14127 if requires_ground && self.state.ground_target.is_none() {
14128 anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
14129 }
14130 let (resolved_target_id, target_point) = if allows_ground {
14131 if let Some((x, y, z)) = self.state.ground_target {
14132 (
14133 target_id.unwrap_or(self.state.entity_id),
14134 Some(flatland_protocol::AimPoint { x, y, z }),
14135 )
14136 } else {
14137 (
14138 target_id
14139 .or_else(|| self.state.target_for_slot(2))
14140 .or_else(|| self.state.target_for_slot(1))
14141 .unwrap_or(self.state.entity_id),
14142 None,
14143 )
14144 }
14145 } else {
14146 (
14147 target_id
14148 .or_else(|| self.state.target_for_slot(2))
14149 .or_else(|| self.state.target_for_slot(1))
14150 .unwrap_or(self.state.entity_id),
14151 None,
14152 )
14153 };
14154 self.seq += 1;
14155 self.session
14156 .submit_intent(Intent::Cast {
14157 entity_id: self.state.entity_id,
14158 ability_id: ability_id.to_string(),
14159 target_id: resolved_target_id,
14160 target_point,
14161 seq: self.seq,
14162 })
14163 .await?;
14164 self.state.intents_sent += 1;
14165 match target_point {
14166 Some(point) => self.state.push_log(format!(
14167 "Cast {ability_id} → ({:.1}, {:.1})",
14168 point.x, point.y
14169 )),
14170 None => self
14171 .state
14172 .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
14173 }
14174 Ok(())
14175 }
14176
14177 pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
14178 self.seq += 1;
14179 self.session
14180 .submit_intent(Intent::UpsertRotationPreset {
14181 entity_id: self.state.entity_id,
14182 preset: preset.clone(),
14183 seq: self.seq,
14184 })
14185 .await?;
14186 self.state.intents_sent += 1;
14187 if let Some(existing) = self
14188 .state
14189 .rotation_presets
14190 .iter_mut()
14191 .find(|p| p.id == preset.id)
14192 {
14193 *existing = preset.clone();
14194 } else {
14195 self.state.rotation_presets.push(preset.clone());
14196 }
14197 for slot in &mut self.state.combat_slots {
14198 if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
14199 slot.preset_label = Some(preset.label.clone());
14200 slot.rotation = preset.abilities.clone();
14201 }
14202 }
14203 self.state
14204 .push_log(format!("Saved rotation: {}", preset.label));
14205 Ok(())
14206 }
14207
14208 pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
14209 self.seq += 1;
14210 self.session
14211 .submit_intent(Intent::DeleteRotationPreset {
14212 entity_id: self.state.entity_id,
14213 preset_id: preset_id.to_string(),
14214 seq: self.seq,
14215 })
14216 .await?;
14217 self.state.intents_sent += 1;
14218 self.state.rotation_presets.retain(|p| p.id != preset_id);
14219 for slot in &mut self.state.combat_slots {
14220 if slot.preset_id.as_deref() == Some(preset_id) {
14221 slot.preset_id = None;
14222 slot.preset_label = None;
14223 slot.rotation.clear();
14224 slot.rotation_index = 0;
14225 }
14226 }
14227 self.state
14228 .push_log(format!("Deleted rotation: {preset_id}"));
14229 Ok(())
14230 }
14231
14232 pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
14233 if !self.state.is_alive() {
14234 anyhow::bail!("you are dead");
14235 }
14236 let enabled = !self
14237 .state
14238 .combat_slots
14239 .iter()
14240 .find(|s| s.slot_index == slot_index)
14241 .map(|s| s.auto_enabled)
14242 .unwrap_or(false);
14243 self.seq += 1;
14244 self.session
14245 .submit_intent(Intent::SetAutoAttack {
14246 entity_id: self.state.entity_id,
14247 slot_index,
14248 enabled,
14249 seq: self.seq,
14250 })
14251 .await?;
14252 if slot_index == 1 {
14253 self.state.auto_attack = enabled;
14254 }
14255 self.state.intents_sent += 1;
14256 self.state.push_log(format!(
14257 "T{slot_index} auto {}",
14258 if enabled { "ON" } else { "OFF" }
14259 ));
14260 Ok(())
14261 }
14262
14263 pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
14264 if !self.state.connected {
14265 anyhow::bail!("not connected");
14266 }
14267 if !self.state.is_alive() {
14268 anyhow::bail!("you are dead");
14269 }
14270 let (px, py) = self.state.player_position();
14271 if self
14272 .state
14273 .ground_drops
14274 .iter()
14275 .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
14276 {
14277 anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
14278 }
14279 self.seq += 1;
14280 self.session
14281 .submit_intent(Intent::Pickup {
14282 entity_id: self.state.entity_id,
14283 drop_id: None,
14284 seq: self.seq,
14285 })
14286 .await?;
14287 self.state.intents_sent += 1;
14288 Ok(())
14289 }
14290
14291 pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
14292 self.toggle_auto_attack_slot(1).await
14293 }
14294
14295 pub async fn dodge(&mut self) -> anyhow::Result<()> {
14296 if !self.state.is_alive() {
14297 anyhow::bail!("you are dead");
14298 }
14299 let (forward, strafe) = self.last_move_axes();
14300 self.seq += 1;
14301 self.session
14302 .submit_intent(Intent::Dodge {
14303 entity_id: self.state.entity_id,
14304 forward,
14305 strafe,
14306 seq: self.seq,
14307 })
14308 .await?;
14309 self.state.intents_sent += 1;
14310 self.state.push_log("Dodge!");
14311 Ok(())
14312 }
14313
14314 pub async fn lunge(&mut self) -> anyhow::Result<()> {
14315 if !self.state.is_alive() {
14316 anyhow::bail!("you are dead");
14317 }
14318 let (forward, strafe) = self.last_move_axes();
14319 self.seq += 1;
14320 self.session
14321 .submit_intent(Intent::Lunge {
14322 entity_id: self.state.entity_id,
14323 forward,
14324 strafe,
14325 seq: self.seq,
14326 })
14327 .await?;
14328 self.state.intents_sent += 1;
14329 self.state.push_log("Lunge!");
14330 Ok(())
14331 }
14332
14333 pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
14334 if !self.state.is_alive() {
14335 anyhow::bail!("you are dead");
14336 }
14337 self.seq += 1;
14338 self.session
14339 .submit_intent(Intent::DirectionalJump {
14340 entity_id: self.state.entity_id,
14341 forward,
14342 strafe,
14343 seq: self.seq,
14344 })
14345 .await?;
14346 self.state.intents_sent += 1;
14347 self.state.push_log("Jump!");
14348 Ok(())
14349 }
14350
14351 pub fn last_move_axes(&self) -> (f32, f32) {
14353 (self.last_move_forward, self.last_move_strafe)
14354 }
14355
14356 pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
14357 if !self.state.is_alive() {
14358 anyhow::bail!("you are dead");
14359 }
14360 self.seq += 1;
14361 self.session
14362 .submit_intent(Intent::Block {
14363 entity_id: self.state.entity_id,
14364 enabled,
14365 seq: self.seq,
14366 })
14367 .await?;
14368 self.state.intents_sent += 1;
14369 if enabled {
14370 self.state.push_log("Blocking");
14371 }
14372 Ok(())
14373 }
14374
14375 pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
14376 if !self.state.is_alive() {
14377 anyhow::bail!("you are dead");
14378 }
14379 self.seq += 1;
14380 self.session
14381 .submit_intent(Intent::EquipMainhand {
14382 entity_id: self.state.entity_id,
14383 template_id,
14384 instance_id: None,
14385 seq: self.seq,
14386 })
14387 .await?;
14388 self.state.intents_sent += 1;
14389 Ok(())
14390 }
14391
14392 pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
14394 let idx = self.state.equip_menu_index;
14395 let slots = equip_paperdoll_rows(&self.state);
14396 let Some(row) = slots.get(idx) else {
14397 return Ok(());
14398 };
14399 match row {
14400 EquipPaperdollRow::Body { slot, filled } => {
14401 if *filled {
14402 self.equip_worn(*slot, None).await
14403 } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
14404 self.equip_worn(*slot, Some(inst)).await
14405 } else {
14406 self.state.push_log(format!("No item for {}", body_slot_label(*slot)));
14407 Ok(())
14408 }
14409 }
14410 EquipPaperdollRow::Mainhand { filled } => {
14411 if *filled {
14412 self.unequip_mainhand().await
14413 } else if let Some(tid) = first_inventory_weapon(&self.state) {
14414 self.equip_mainhand(Some(tid)).await
14415 } else {
14416 self.state.push_log("No weapon in inventory".to_string());
14417 Ok(())
14418 }
14419 }
14420 EquipPaperdollRow::Offhand { filled, locked } => {
14421 if *locked {
14422 self.state
14423 .push_log("Offhand locked — two-handed weapon equipped".to_string());
14424 Ok(())
14425 } else if *filled {
14426 self.unequip_offhand().await
14427 } else if let Some(tid) = first_inventory_offhand(&self.state) {
14428 self.equip_offhand(Some(tid)).await
14429 } else {
14430 self.state
14431 .push_log("No offhand item in inventory".to_string());
14432 Ok(())
14433 }
14434 }
14435 }
14436 }
14437
14438 pub async fn say(
14439 &mut self,
14440 channel: flatland_protocol::ChatChannel,
14441 text: &str,
14442 ) -> anyhow::Result<()> {
14443 self.say_to(channel, text, None).await
14444 }
14445
14446 pub async fn say_to(
14447 &mut self,
14448 channel: flatland_protocol::ChatChannel,
14449 text: &str,
14450 to_entity: Option<EntityId>,
14451 ) -> anyhow::Result<()> {
14452 self.seq += 1;
14453 self.session
14454 .submit_intent(Intent::Say {
14455 entity_id: self.state.entity_id,
14456 channel,
14457 text: text.to_string(),
14458 to_entity,
14459 seq: self.seq,
14460 })
14461 .await?;
14462 self.state.intents_sent += 1;
14463 Ok(())
14464 }
14465
14466 pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
14467 let Some(peer) = self.state.player_verbs.target_entity else {
14468 return Ok(());
14469 };
14470 let label = self.state.player_verbs.target_label.clone();
14471 let choice = crate::social::PlayerVerbState::options()
14472 .get(self.state.player_verbs.index)
14473 .copied()
14474 .unwrap_or("Whisper");
14475 self.state.player_verbs.close();
14476 match choice {
14477 "Trade" => {
14478 self.seq += 1;
14481 self.session
14482 .submit_intent(Intent::TradeRequest {
14483 entity_id: self.state.entity_id,
14484 peer_entity_id: peer,
14485 seq: self.seq,
14486 })
14487 .await?;
14488 self.state.intents_sent += 1;
14489 self.state
14490 .social_chat
14491 .push_system(format!("Trade request sent to {label} — waiting for accept"));
14492 }
14493 "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
14494 _ => self.state.social_chat.focus_nearby(),
14495 }
14496 Ok(())
14497 }
14498
14499 pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
14500 let Some(pending) = self.state.social_chat.pending_trade.take() else {
14501 return Ok(());
14502 };
14503 self.seq += 1;
14504 self.session
14505 .submit_intent(Intent::TradeRespond {
14506 entity_id: self.state.entity_id,
14507 peer_entity_id: pending.from_entity,
14508 accept,
14509 seq: self.seq,
14510 })
14511 .await?;
14512 self.state.intents_sent += 1;
14513 if accept {
14514 self.state
14515 .social_chat
14516 .push_system(format!("Accepted trade with {}", pending.from_name));
14517 } else {
14518 self.state
14519 .social_chat
14520 .push_system(format!("Declined trade with {}", pending.from_name));
14521 }
14522 Ok(())
14523 }
14524
14525 pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
14526 let text = self.state.social_chat.buffer.trim().to_string();
14527 if text.is_empty() {
14528 return Ok(());
14529 }
14530 self.state.social_chat.buffer.clear();
14531 if crate::social::is_chat_slash_line(&text) {
14532 match crate::social::parse_chat_slash(&text) {
14533 Some(cmd) => return self.apply_chat_slash(cmd).await,
14534 None => {
14535 self.state.social_chat.push_system(format!(
14536 "Unknown command — {}",
14537 crate::social::chat_slash_help_text()
14538 ));
14539 return Ok(());
14540 }
14541 }
14542 }
14543 let thread = self.state.social_chat.thread;
14544 let channel = thread.channel();
14545 let to = thread.to_entity();
14546 if let Some(peer) = to {
14547 let label = self.state.social_chat.peer_label.clone();
14548 self.state
14549 .social_chat
14550 .remember_whisper_peer(peer, &label, channel);
14551 }
14552 self.say_to(channel, &text, to).await
14553 }
14554
14555 async fn apply_chat_slash(
14556 &mut self,
14557 cmd: crate::social::ChatSlashCommand,
14558 ) -> anyhow::Result<()> {
14559 use crate::social::{chat_slash_help_text, ChatSlashCommand};
14560 match cmd {
14561 ChatSlashCommand::Help => {
14562 self.state
14563 .social_chat
14564 .push_system(chat_slash_help_text().to_string());
14565 Ok(())
14566 }
14567 ChatSlashCommand::Nearby { message } => {
14568 self.state.social_chat.focus_nearby();
14569 self.state
14570 .social_chat
14571 .push_system("Nearby speech — everyone close can hear");
14572 if let Some(msg) = message {
14573 self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
14574 .await
14575 } else {
14576 Ok(())
14577 }
14578 }
14579 ChatSlashCommand::Reply { message } => {
14580 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
14581 self.state.social_chat.push_system(
14582 "No one to reply to — wait for a whisper, or /whisper Name",
14583 );
14584 return Ok(());
14585 };
14586 let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
14587 self.state
14588 .social_chat
14589 .set_whisper_thread(peer.entity_id, &peer.label, stone);
14590 self.state.social_chat.push_system(format!(
14591 "Replying to {} — type and Enter · /nearby",
14592 peer.label
14593 ));
14594 if let Some(msg) = message {
14595 self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
14596 } else {
14597 Ok(())
14598 }
14599 }
14600 ChatSlashCommand::Whisper { name, message } => {
14601 let (peer_id, label, stone) = if let Some(name) = name {
14602 match self.resolve_whisper_target(&name) {
14603 Ok(t) => t,
14604 Err(err) => {
14605 self.state.social_chat.push_system(err);
14606 return Ok(());
14607 }
14608 }
14609 } else {
14610 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
14611 self.state.social_chat.push_system(
14612 "Usage: /whisper Name [message] · or /reply after someone whispers you",
14613 );
14614 return Ok(());
14615 };
14616 (
14617 peer.entity_id,
14618 peer.label,
14619 peer.channel == flatland_protocol::ChatChannel::WhisperStone,
14620 )
14621 };
14622 self.state
14623 .social_chat
14624 .set_whisper_thread(peer_id, &label, stone);
14625 let channel = if stone {
14626 flatland_protocol::ChatChannel::WhisperStone
14627 } else {
14628 flatland_protocol::ChatChannel::Whisper
14629 };
14630 if let Some(msg) = message {
14631 self.state.social_chat.push_system(format!(
14632 "Whisper → {label}"
14633 ));
14634 self.say_to(channel, &msg, Some(peer_id)).await
14635 } else {
14636 self.state.social_chat.push_system(format!(
14637 "Whispering {label} — type and Enter · Esc / /nearby cancels"
14638 ));
14639 Ok(())
14640 }
14641 }
14642 }
14643 }
14644
14645 fn resolve_whisper_target(
14647 &self,
14648 name: &str,
14649 ) -> Result<(EntityId, String, bool), String> {
14650 let needle = name.trim().to_ascii_lowercase();
14651 if needle.is_empty() {
14652 return Err("Usage: /whisper Name [message]".into());
14653 }
14654 let mut candidates: Vec<(EntityId, String)> = self
14655 .state
14656 .entities
14657 .iter()
14658 .filter(|e| e.id != self.state.entity_id)
14659 .filter(|e| !e.label.trim().is_empty())
14660 .filter(|e| e.vitals.is_some())
14661 .filter(|e| {
14662 !self
14663 .state
14664 .npcs
14665 .iter()
14666 .any(|n| n.id == e.id.to_string())
14667 })
14668 .filter(|e| {
14669 !self
14670 .state
14671 .hired_workers
14672 .iter()
14673 .any(|w| w.entity_id == e.id)
14674 })
14675 .map(|e| (e.id, e.label.clone()))
14676 .collect();
14677
14678 if let Some(last) = &self.state.social_chat.last_whisper_peer {
14680 if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
14681 candidates.push((last.entity_id, last.label.clone()));
14682 }
14683 }
14684
14685 let exact: Vec<_> = candidates
14686 .iter()
14687 .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
14688 .cloned()
14689 .collect();
14690 let pool = if exact.len() == 1 {
14691 exact
14692 } else if exact.len() > 1 {
14693 return Err(format!(
14694 "Several players named '{name}' nearby — move closer and try again"
14695 ));
14696 } else {
14697 let starts: Vec<_> = candidates
14698 .iter()
14699 .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
14700 .cloned()
14701 .collect();
14702 if starts.len() == 1 {
14703 starts
14704 } else if starts.len() > 1 {
14705 let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
14706 return Err(format!(
14707 "Ambiguous name '{name}' — matches: {}",
14708 names.join(", ")
14709 ));
14710 } else {
14711 let contains: Vec<_> = candidates
14712 .iter()
14713 .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
14714 .cloned()
14715 .collect();
14716 if contains.len() == 1 {
14717 contains
14718 } else if contains.is_empty() {
14719 return Err(format!(
14720 "No player matching '{name}' in range — get closer or check the spelling"
14721 ));
14722 } else {
14723 let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
14724 return Err(format!(
14725 "Ambiguous name '{name}' — matches: {}",
14726 names.join(", ")
14727 ));
14728 }
14729 }
14730 };
14731
14732 let (id, label) = pool.into_iter().next().unwrap();
14733 let stone = self
14734 .state
14735 .social_chat
14736 .last_whisper_peer
14737 .as_ref()
14738 .is_some_and(|p| p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone);
14739 Ok((id, label, stone))
14740 }
14741
14742 pub async fn trade_present_selected(
14743 &mut self,
14744 item_instance_id: uuid::Uuid,
14745 ) -> anyhow::Result<()> {
14746 self.trade_present_quantity(item_instance_id, None).await
14747 }
14748
14749 pub async fn trade_present_quantity(
14750 &mut self,
14751 item_instance_id: uuid::Uuid,
14752 quantity: Option<u32>,
14753 ) -> anyhow::Result<()> {
14754 self.seq += 1;
14755 self.session
14756 .submit_intent(Intent::TradePresent {
14757 entity_id: self.state.entity_id,
14758 item_instance_id,
14759 quantity,
14760 seq: self.seq,
14761 })
14762 .await?;
14763 self.state.intents_sent += 1;
14764 self.state.trade_ui.qty_entry = None;
14765 self.state.trade_ui.picking_inventory = false;
14766 Ok(())
14767 }
14768
14769 pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
14771 if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
14772 let qty = self.state.trade_ui.present_quantity();
14773 return self
14774 .trade_present_quantity(entry.item_instance_id, qty)
14775 .await;
14776 }
14777 if !self.state.trade_ui.picking_inventory {
14778 return Ok(());
14779 }
14780 let stacks = self.state.trade_presentable_stacks();
14781 let Some(stack) = stacks.get(self.state.trade_ui.inventory_index).copied() else {
14782 return Ok(());
14783 };
14784 let Some(id) = stack.item_instance_id else {
14785 return Ok(());
14786 };
14787 let label = stack
14788 .display_name
14789 .clone()
14790 .unwrap_or_else(|| stack.template_id.clone());
14791 if stack.quantity <= 1 {
14792 self.trade_present_quantity(id, Some(1)).await
14793 } else {
14794 self.state
14795 .trade_ui
14796 .begin_qty_entry(id, label, stack.quantity);
14797 Ok(())
14798 }
14799 }
14800
14801 pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
14802 self.seq += 1;
14803 self.session
14804 .submit_intent(Intent::TradeSetReady {
14805 entity_id: self.state.entity_id,
14806 ready,
14807 seq: self.seq,
14808 })
14809 .await?;
14810 self.state.intents_sent += 1;
14811 Ok(())
14812 }
14813
14814 pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
14815 self.seq += 1;
14816 self.session
14817 .submit_intent(Intent::TradeCancel {
14818 entity_id: self.state.entity_id,
14819 seq: self.seq,
14820 })
14821 .await?;
14822 self.state.intents_sent += 1;
14823 self.state.trade_ui.close();
14824 Ok(())
14825 }
14826
14827 pub async fn destroy_whisper_stone(
14828 &mut self,
14829 item_instance_id: uuid::Uuid,
14830 ) -> anyhow::Result<()> {
14831 self.seq += 1;
14832 self.session
14833 .submit_intent(Intent::DestroyWhisperStone {
14834 entity_id: self.state.entity_id,
14835 item_instance_id,
14836 seq: self.seq,
14837 })
14838 .await?;
14839 self.state.intents_sent += 1;
14840 Ok(())
14841 }
14842
14843 pub async fn stop(&mut self) -> anyhow::Result<()> {
14844 self.seq += 1;
14845 self.session
14846 .submit_intent(Intent::Stop {
14847 entity_id: self.state.entity_id,
14848 seq: self.seq,
14849 })
14850 .await?;
14851 self.state.intents_sent += 1;
14852 Ok(())
14853 }
14854
14855 pub fn disconnect(&self) {
14856 self.session.disconnect();
14857 }
14858}
14859
14860fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
14861 let dx = ax - bx;
14862 let dy = ay - by;
14863 (dx * dx + dy * dy).sqrt()
14864}
14865
14866#[cfg(test)]
14867mod tests {
14868 use std::collections::BTreeMap;
14869
14870 use super::*;
14871 use flatland_protocol::{
14872 BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
14873 };
14874
14875 fn sample_state() -> GameState {
14876 let mut state = GameState {
14877 session_id: 1,
14878 entity_id: 1,
14879 character_id: None,
14880 tick: 0,
14881 chunk_rev: 0,
14882 content_rev: 0,
14883 publish_rev: 0,
14884 entities: vec![EntityState {
14885 id: 1,
14886 label: "You".into(),
14887 transform: Transform {
14888 position: WorldCoord::surface(128.0, 128.0),
14889 yaw: 0.0,
14890 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
14891 },
14892 vitals: None,
14893 attributes: None,
14894 skills: None,
14895 inside_building: None,
14896 tile_id: None,
14897 paperdoll_ref: None,
14898 draw_scale: 1.0,
14899 presentation_state: None,
14900 sprite_mode: None,
14901 progression_xp: None,
14902 combat_cues: vec![],
14903 statuses: vec![],
14904 }],
14905 player: None,
14906 resource_nodes: vec![ResourceNodeView {
14907 id: "oak-1".into(),
14908 label: "Oak".into(),
14909 x: 130.0,
14910 y: 128.0,
14911 z: 0.0,
14912 item_template: "oak_log".into(),
14913 state: ResourceNodeState::Available,
14914 blocking: true,
14915 blocking_radius_m: 0.8,
14916 harvest_off: false,
14917 tile_id: None,
14918 yaw: 0.0,
14919 pitch: 0.0,
14920 roll: 0.0,
14921 draw_scale: 1.0,
14922 sprite_mode: None,
14923 growth_progress: None,
14924 presentation_state: None,
14925 channel_start_tick: None,
14926 channel_end_tick: None,
14927 harvest_drop_templates: vec![],
14928 }],
14929 ground_drops: vec![],
14930 placed_containers: vec![],
14931 buildings: vec![BuildingView {
14932 id: "broker-hut".into(),
14933 label: "Broker".into(),
14934 x: 148.0,
14935 y: 118.0,
14936 width_m: 8.0,
14937 depth_m: 6.0,
14938 interior_blueprint: Some("broker_hut".into()),
14939 tags: vec![],
14940 market_boundary_zone_ids: vec![],
14941 market_max_volume: None,
14942 wall_set: None,
14943 roof_set: None,
14944 }],
14945 doors: vec![flatland_protocol::DoorView {
14946 id: "door-1".into(),
14947 building_id: "broker-hut".into(),
14948 x: 148.0,
14949 y: 118.0,
14950 open: false,
14951 portal: Some("front".into()),
14952 locked: false,
14953 accessible: true,
14954 lock_id: None,
14955 }],
14956 interior_map: None,
14957 npcs: vec![],
14958 blueprints: vec![],
14959 building_materials: vec![],
14960 world_x0: 0.0,
14961 world_y0: 0.0,
14962 world_width_m: 256.0,
14963 world_height_m: 256.0,
14964 terrain_zones: Vec::new(),
14965 z_platforms: Vec::new(),
14966 z_transitions: Vec::new(),
14967 z_bands_outdoor_backup: None,
14968 world_clock: flatland_protocol::WorldClock::default(),
14969 inventory: std::collections::HashMap::new(),
14970 inventory_hints: std::collections::HashMap::new(),
14971 logs: VecDeque::new(),
14972 intents_sent: 0,
14973 ticks_received: 0,
14974 connected: true,
14975 disconnect_reason: None,
14976 show_stats: false,
14977 hud_log_hidden: false,
14978 show_equip_menu: false,
14979 equip_menu_index: 0,
14980 show_craft_menu: false,
14981 show_plot_build_menu: false,
14982 plot_build_focus_wall: true,
14983 plot_build_wall_index: 0,
14984 plot_build_roof_index: 0,
14985 craft_menu_index: 0,
14986 craft_batch_quantity: 1,
14987 show_shop_menu: false,
14988 shop_catalog: None,
14989 bank_panel: None,
14990 bank_menu_index: 0,
14991 bank_ui_mode: BankUiMode::Menu,
14992 storage_panel: None,
14993 market_panel: None,
14994 market_menu_index: 0,
14995 market_filter: String::new(),
14996 market_filter_focused: false,
14997 market_category_filter: None,
14998 market_buy_confirm: None,
14999 market_ui_mode: MarketUiMode::Browse,
15000 storage_menu_index: 0,
15001 storage_ui_mode: StorageUiMode::Menu,
15002 shop_tab: ShopTab::default(),
15003 shop_menu_index: 0,
15004 shop_quantity: 1,
15005 shop_trade_log: VecDeque::new(),
15006 show_npc_verb_menu: false,
15007 npc_verb_target: None,
15008 npc_verb_index: 0,
15009 player_verbs: crate::social::PlayerVerbState::default(),
15010 social_chat: crate::social::SocialChatState::default(),
15011 trade_ui: crate::social::TradeUiState::default(),
15012 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
15013 show_npc_chat: false,
15014 npc_chat: None,
15015 show_inventory_menu: false,
15016 inventory_menu_index: 0,
15017 inventory_tab: InventoryTab::OnPerson,
15018 inventory_filter: String::new(),
15019 inventory_filter_focused: false,
15020 show_move_picker: false,
15021 show_rename_prompt: false,
15022 rename_plot_id: None,
15023 highlighted_plot_id: None,
15024 show_worker_rename: false,
15025 rename_buffer: String::new(),
15026 move_picker_index: 0,
15027 move_picker: None,
15028 show_grant_picker: false,
15029 grant_picker_index: 0,
15030 grant_picker: None,
15031 show_destroy_picker: false,
15032 destroy_confirm_pending: false,
15033 destroy_picker: None,
15034 combat_target: None,
15035 combat_target_label: None,
15036 ground_target: None,
15037 combat_fx: Vec::new(),
15038 property_zones: Vec::new(),
15039 tax_zones: Vec::new(),
15040 growth_zones: Vec::new(),
15041 biome_zones: Vec::new(),
15042 terrain_kind_nav: Vec::new(),
15043 property_plots: Vec::new(),
15044 property_plot_settings: None,
15045 claim_mode: None,
15046 relocate_mode: None,
15047 sell_plot_confirm: None,
15048 sell_plot_armed_at: None,
15049 show_plant_menu: false,
15050 plant_menu_index: 0,
15051 show_farm_access: false,
15052 farm_access_name_draft: String::new(),
15053 farm_access_discount_bps: 0,
15054 farm_access_index: 0,
15055 plant_quantity: 1,
15056 in_combat: false,
15057 auto_attack: true,
15058 combat_has_los: false,
15059 attack_cd_ticks: 0,
15060 gcd_ticks: 0,
15061 weapon_ability_id: "unarmed".into(),
15062 mainhand_template_id: None,
15063 mainhand_label: None,
15064 mainhand_instance_id: None,
15065 offhand_template_id: None,
15066 offhand_label: None,
15067 offhand_instance_id: None,
15068 mainhand_hand_slots: 1,
15069 defense: None,
15070 worn: BTreeMap::new(),
15071 carry_mass: 0.0,
15072 carry_mass_max: 0.0,
15073 encumbrance: flatland_protocol::EncumbranceState::Light,
15074 inventory_stacks: Vec::new(),
15075 keychain_stacks: Vec::new(),
15076 whisper_pouch_stacks: Vec::new(),
15077 combat_target_detail: None,
15078 statuses: Vec::new(),
15079 cast_progress: None,
15080 timed_channel: None,
15081 plot_build_offer: None,
15082 ability_cooldowns: Vec::new(),
15083 blocking_active: false,
15084 max_target_slots: 1,
15085 combat_slots: Vec::new(),
15086 rotation_presets: Vec::new(),
15087 known_abilities: Vec::new(),
15088 ability_meta: std::collections::HashMap::new(),
15089 ability_mastery: std::collections::HashMap::new(),
15090 hotbar: vec![None; 9],
15091 max_abilities_per_rotation: 0,
15092 show_loadout_menu: false,
15093 show_keychain_menu: false,
15094 keychain_menu_index: 0,
15095 show_rotation_editor: false,
15096 loadout_menu_index: 0,
15097 loadout_hotbar_slot: 1,
15098 loadout_ability_index: 0,
15099 loadout_focus_presets: false,
15100 rotation_editor: RotationEditorState::default(),
15101 harvest_in_progress: false,
15102 harvest_started_at: None,
15103 pending_craft_ack: None,
15104 pending_worker_job_ack: None,
15105 attending_worker_instance_id: None,
15106 quest_log: Vec::new(),
15107 interactables: Vec::new(),
15108 ledger: None,
15109 career: None,
15110 character_sheet_tab: CharacterSheetTab::Character,
15111 ledger_period: LedgerPeriod::Day,
15112 show_quest_offer: false,
15113 pending_quest_offer: None,
15114 show_quest_menu: false,
15115 quest_menu_index: 0,
15116 quest_withdraw_confirm: false,
15117 hired_workers: Vec::new(),
15118 show_workers_menu: false,
15119 workers_menu_index: 0,
15120 workers_menu_compact: false,
15121 worker_step_display: BTreeMap::new(),
15122 worker_error_display: BTreeMap::new(),
15123 show_worker_give_picker: false,
15124 worker_give_picker_index: 0,
15125 worker_give_picker: None,
15126 show_worker_give_target_picker: false,
15127 worker_give_target_picker_index: 0,
15128 worker_give_target_picker: None,
15129 show_worker_take_picker: false,
15130 worker_take_picker_index: 0,
15131 worker_take_picker: None,
15132 show_worker_teach_picker: false,
15133 worker_teach_picker_index: 0,
15134 worker_teach_picker: None,
15135 worker_route_editor: None,
15136 progression_curve: None,
15137 };
15138 state.player = state.entities.first().cloned();
15139 state
15140 }
15141
15142 #[test]
15143 fn whisper_cancels_when_peer_walks_out_of_range() {
15144 let mut state = sample_state();
15145 state.player = state.entities.first().cloned();
15146 let mut peer = state.entities[0].clone();
15147 peer.id = 2;
15148 peer.label = "Ada".into();
15149 peer.transform.position = WorldCoord::surface(129.0, 128.0); state.entities.push(peer.clone());
15151 state.social_chat.focus_whisper(2, "Ada");
15152 state.refresh_whisper_range();
15153 assert!(matches!(
15154 state.social_chat.thread,
15155 crate::social::ChatThreadKind::Whisper { peer: 2 }
15156 ));
15157
15158 peer.transform.position = WorldCoord::surface(132.0, 128.0); state.entities[1] = peer;
15160 state.refresh_whisper_range();
15161 assert_eq!(
15162 state.social_chat.thread,
15163 crate::social::ChatThreadKind::Nearby
15164 );
15165 assert!(!state.social_chat.input_focused);
15166 }
15167
15168 #[test]
15169 fn probe_use_world_hired_worker_manage() {
15170 let mut state = sample_state();
15171 state.hired_workers.push(flatland_protocol::HiredWorkerView {
15172 instance_id: "worker-1".into(),
15173 entity_id: 42,
15174 def_id: "worker_laborer".into(),
15175 label: "Sam".into(),
15176 x: 129.0,
15177 y: 128.0,
15178 z: 0.0,
15179 mode: flatland_protocol::WorkerModeView::JobLoop,
15180 state: flatland_protocol::WorkerStateView::Working,
15181 step_label: "cultivate".into(),
15182 vitals: flatland_protocol::WorkerVitalsSummary {
15183 health_pct: 100.0,
15184 stamina_pct: 100.0,
15185 },
15186 carry_pct: 0.0,
15187 last_error: None,
15188 wage_copper_per_interval: 1,
15189 effective_wage_copper: 1,
15190 wage_meters_walked: 0.0,
15191 lodging_container_id: None,
15192 route: None,
15193 route_stop_index: None,
15194 known_blueprint_ids: Vec::new(),
15195 level: 1,
15196 worker_xp: 0.0,
15197 inventory: Vec::new(),
15198 });
15199 let probe = state.probe_use_world();
15200 let primary = probe.primary.expect("primary");
15201 assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
15202 assert_eq!(primary.id, "worker-1");
15203 assert!(primary.hint_line().contains("Manage"));
15204 assert!(primary.hint_line().contains("Sam"));
15205 assert_eq!(
15206 state.nearest_interact_target().as_deref(),
15207 Some("worker-1")
15208 );
15209 }
15210
15211 #[test]
15212 fn market_clerk_verb_options_include_market() {
15213 let mut state = sample_state();
15214 state.npcs.push(flatland_protocol::NpcView {
15215 id: "mira_market".into(),
15216 label: "Mira".into(),
15217 role: "market_clerk".into(),
15218 x: 129.0,
15219 y: 128.0,
15220 building_id: Some("town_market".into()),
15221 entity_id: None,
15222 life_state: None,
15223 hp_pct: None,
15224 can_trade: false,
15225 tile_id: None,
15226 behavior_state: None,
15227 presentation_state: None,
15228 sprite_mode: None,
15229 paperdoll_ref: None,
15230 draw_scale: 1.0,
15231 });
15232 state.npc_verb_target = Some("mira_market".into());
15233 assert_eq!(state.npc_verb_options(), vec!["Market", "Talk"]);
15234 }
15235
15236 #[test]
15237 fn market_list_excludes_currency_stacks() {
15238 let mut state = sample_state();
15239 state.inventory_stacks = vec![
15240 flatland_protocol::ItemStack {
15241 template_id: "copper_coin".into(),
15242 quantity: 50,
15243 item_instance_id: Some(uuid::Uuid::from_u128(10)),
15244 display_name: Some("Copper Coin".into()),
15245 ..Default::default()
15246 },
15247 flatland_protocol::ItemStack {
15248 template_id: "oak_log".into(),
15249 quantity: 2,
15250 item_instance_id: Some(uuid::Uuid::from_u128(11)),
15251 display_name: Some("Oak Log".into()),
15252 ..Default::default()
15253 },
15254 flatland_protocol::ItemStack {
15255 template_id: "whisper_stone".into(),
15256 quantity: 1,
15257 item_instance_id: Some(uuid::Uuid::from_u128(12)),
15258 display_name: Some("Whisper Stone".into()),
15259 category: Some("quest".into()),
15260 listable: Some(false),
15261 ..Default::default()
15262 },
15263 ];
15264 let opts = state.market_list_item_options(&MarketListSourceKind::Person);
15265 assert_eq!(opts.len(), 1);
15266 assert!(opts[0].label.contains("Oak"));
15267 }
15268
15269 #[test]
15270 fn market_browse_filters_by_category_and_search() {
15271 let mut state = sample_state();
15272 state.market_panel = Some(flatland_protocol::MarketPanel {
15273 npc_id: "mira_market".into(),
15274 npc_label: "Mira".into(),
15275 building_id: "town_market".into(),
15276 building_label: "Town Market".into(),
15277 used_volume: 0.0,
15278 max_volume: 100.0,
15279 listings: vec![
15280 flatland_protocol::MarketListingView {
15281 listing_id: uuid::Uuid::from_u128(1),
15282 seller_character_id: uuid::Uuid::from_u128(2),
15283 seller_label: "Ada".into(),
15284 hall_building_id: "town_market".into(),
15285 hall_label: "Town Market".into(),
15286 template_id: "oak_log".into(),
15287 display_name: "Oak Log".into(),
15288 category: "resource".into(),
15289 quantity: 3,
15290 unit_price_copper: 10,
15291 line_total_copper: 30,
15292 npc_price: false,
15293 mine: false,
15294 },
15295 flatland_protocol::MarketListingView {
15296 listing_id: uuid::Uuid::from_u128(3),
15297 seller_character_id: uuid::Uuid::from_u128(2),
15298 seller_label: "Ada".into(),
15299 hall_building_id: "town_market".into(),
15300 hall_label: "Town Market".into(),
15301 template_id: "short_sword".into(),
15302 display_name: "Short Sword".into(),
15303 category: "weapon".into(),
15304 quantity: 1,
15305 unit_price_copper: 100,
15306 line_total_copper: 100,
15307 npc_price: false,
15308 mine: false,
15309 },
15310 ],
15311 tax_bps: 0,
15312 tax_flat_copper: 0,
15313 list_vaults: vec![],
15314 });
15315 assert_eq!(state.market_filtered_listing_indices().len(), 2);
15316 state.market_category_filter = Some("Weapons");
15317 let weapons = state.market_filtered_listing_indices();
15318 assert_eq!(weapons.len(), 1);
15319 assert_eq!(
15320 state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
15321 "Short Sword"
15322 );
15323 state.market_category_filter = None;
15324 state.market_filter = "oak".into();
15325 let oak = state.market_filtered_listing_indices();
15326 assert_eq!(oak.len(), 1);
15327 assert_eq!(
15328 state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
15329 "Oak Log"
15330 );
15331 }
15332
15333 #[test]
15334 fn market_list_source_includes_person_and_vaults() {
15335 let mut state = sample_state();
15336 let item_id = uuid::Uuid::from_u128(1);
15337 state.inventory_stacks = vec![flatland_protocol::ItemStack {
15338 template_id: "oak_log".into(),
15339 quantity: 2,
15340 item_instance_id: Some(item_id),
15341 display_name: Some("Oak Log".into()),
15342 ..Default::default()
15343 }];
15344 state.market_panel = Some(flatland_protocol::MarketPanel {
15345 npc_id: "mira_market".into(),
15346 npc_label: "Mira".into(),
15347 building_id: "town_market".into(),
15348 building_label: "Town Market".into(),
15349 used_volume: 0.0,
15350 max_volume: 100.0,
15351 listings: vec![],
15352 tax_bps: 0,
15353 tax_flat_copper: 0,
15354 list_vaults: vec![flatland_protocol::MarketListVault {
15355 building_id: "town_storage".into(),
15356 building_label: "Town Storage".into(),
15357 contents: vec![flatland_protocol::ItemStack {
15358 template_id: "lumber".into(),
15359 quantity: 1,
15360 item_instance_id: Some(uuid::Uuid::from_u128(2)),
15361 display_name: Some("Lumber".into()),
15362 ..Default::default()
15363 }],
15364 }],
15365 });
15366 let sources = state.market_list_source_options();
15367 assert_eq!(sources.len(), 2);
15368 assert!(matches!(sources[0].0, MarketListSourceKind::Person));
15369 assert!(matches!(
15370 sources[1].0,
15371 MarketListSourceKind::TownStorage { .. }
15372 ));
15373 assert!(sources[1].1.contains("Town Storage"));
15374 }
15375
15376 #[test]
15377 fn probe_use_world_npc_beats_nearby_loot() {
15378 let mut state = sample_state();
15379 state.npcs.push(flatland_protocol::NpcView {
15380 id: "ada".into(),
15381 label: "Ada".into(),
15382 role: "broker".into(),
15383 x: 129.0,
15384 y: 128.0,
15385 building_id: None,
15386 entity_id: None,
15387 life_state: None,
15388 hp_pct: None,
15389 can_trade: true,
15390 tile_id: None,
15391 behavior_state: None,
15392 presentation_state: None,
15393 sprite_mode: None,
15394 paperdoll_ref: None,
15395 draw_scale: 1.0,
15396 });
15397 state.ground_drops.push(flatland_protocol::GroundDropView {
15398 id: "d1".into(),
15399 template_id: "lumber".into(),
15400 quantity: 1,
15401 x: 128.5,
15402 y: 128.0,
15403 z: 0.0,
15404 tile_id: None,
15405 display_name: None,
15406 yaw: 0.0,
15407 pitch: 0.0,
15408 roll: 0.0,
15409 draw_scale: 1.0,
15410 });
15411 let probe = state.probe_use_world();
15412 let primary = probe.primary.expect("primary");
15413 assert_eq!(primary.kind, crate::UseWorldKind::Npc);
15414 assert_eq!(primary.id, "ada");
15415 }
15416
15417 #[test]
15418 fn probe_use_world_harvest_when_in_range() {
15419 let state = sample_state(); let probe = state.probe_use_world();
15421 assert!(
15422 probe.primary.is_none(),
15423 "oak is 2m away, out of harvest range"
15424 );
15425 assert!(probe
15426 .candidates
15427 .iter()
15428 .any(|c| c.kind == crate::UseWorldKind::Harvest));
15429
15430 let mut state = sample_state();
15431 state.resource_nodes[0].x = 129.0;
15432 let probe = state.probe_use_world();
15433 let primary = probe.primary.expect("primary");
15434 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
15435 }
15436
15437 #[test]
15438 fn probe_use_world_door_uses_building_label() {
15439 let mut state = sample_state();
15440 state.doors[0].x = 129.0;
15441 state.doors[0].y = 128.0;
15442 let probe = state.probe_use_world();
15443 let primary = probe.primary.expect("primary");
15444 assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
15445 assert_eq!(primary.label, "Broker");
15446 assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
15447 }
15448
15449 #[test]
15450 fn empty_entity_tick_preserves_welcome_snapshot() {
15451 let mut state = sample_state();
15452 state.inventory.insert("carrot".into(), 3);
15453 let delta = TickDelta {
15454 tick: 1,
15455 entities: vec![],
15456 resource_nodes: vec![],
15457 ground_drops: vec![],
15458 placed_containers: vec![],
15459 buildings: vec![],
15460 doors: vec![],
15461 interior_map: None,
15462 npcs: vec![],
15463 inventory: vec![],
15464 blueprints: vec![],
15465 building_materials: vec![],
15466 world_clock: flatland_protocol::WorldClock::default(),
15467 combat: None,
15468 quest_log: vec![],
15469 hired_workers: Vec::new(),
15470 interactables: vec![],
15471 ledger: None,
15472 career: None,
15473 combat_fx: Vec::new(),
15474 property_plots: Vec::new(),
15475 terrain_overlays: Vec::new(),
15476 };
15477
15478 state.apply_tick_fields(&delta, 1);
15479
15480 assert_eq!(state.entities.len(), 1);
15481 assert!(state.player.is_some());
15482 assert_eq!(state.inventory.get("carrot"), Some(&3));
15483 assert_eq!(state.resource_nodes.len(), 1);
15484 }
15485
15486 #[test]
15487 fn tick_preserves_world_layers_when_delta_omits_them() {
15488 let mut state = sample_state();
15489 let delta = TickDelta {
15490 tick: 1,
15491 entities: state.entities.clone(),
15492 resource_nodes: vec![],
15493 ground_drops: vec![],
15494 placed_containers: vec![],
15495 buildings: vec![],
15496 doors: vec![],
15497 interior_map: None,
15498 npcs: vec![],
15499 inventory: vec![],
15500 blueprints: vec![],
15501 building_materials: vec![],
15502 world_clock: flatland_protocol::WorldClock::default(),
15503 combat: None,
15504 quest_log: vec![],
15505 hired_workers: Vec::new(),
15506 interactables: vec![],
15507 ledger: None,
15508 career: None,
15509 combat_fx: Vec::new(),
15510 property_plots: Vec::new(),
15511 terrain_overlays: Vec::new(),
15512 };
15513
15514 state.apply_tick_fields(&delta, 1);
15515
15516 assert_eq!(state.resource_nodes.len(), 1);
15517 assert_eq!(state.buildings.len(), 1);
15518 assert_eq!(state.doors.len(), 1);
15519 }
15520
15521 #[test]
15522 fn tick_updates_resource_nodes_when_server_sends_them() {
15523 let mut state = sample_state();
15524 let delta = TickDelta {
15525 tick: 1,
15526 entities: state.entities.clone(),
15527 resource_nodes: vec![ResourceNodeView {
15528 id: "oak-1".into(),
15529 label: "Oak".into(),
15530 x: 130.0,
15531 y: 128.0,
15532 z: 0.0,
15533 item_template: "oak_log".into(),
15534 state: ResourceNodeState::Cooldown,
15535 blocking: true,
15536 blocking_radius_m: 0.8,
15537 harvest_off: false,
15538 tile_id: None,
15539 yaw: 0.0,
15540 pitch: 0.0,
15541 roll: 0.0,
15542 draw_scale: 1.0,
15543 sprite_mode: None,
15544 growth_progress: None,
15545 presentation_state: None,
15546 channel_start_tick: None,
15547 channel_end_tick: None,
15548 harvest_drop_templates: vec![],
15549 }],
15550 buildings: vec![],
15551 doors: vec![],
15552 interior_map: None,
15553 npcs: vec![],
15554 inventory: vec![],
15555 blueprints: vec![],
15556 building_materials: vec![],
15557 world_clock: flatland_protocol::WorldClock::default(),
15558 ground_drops: vec![],
15559 placed_containers: vec![],
15560 combat: None,
15561 quest_log: vec![],
15562 hired_workers: Vec::new(),
15563 interactables: vec![],
15564 ledger: None,
15565 career: None,
15566 combat_fx: Vec::new(),
15567 property_plots: Vec::new(),
15568 terrain_overlays: Vec::new(),
15569 };
15570
15571 state.apply_tick_fields(&delta, 1);
15572
15573 assert!(matches!(
15574 state.resource_nodes[0].state,
15575 ResourceNodeState::Cooldown
15576 ));
15577 }
15578
15579 #[test]
15580 fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
15581 let mut state = GameState {
15582 session_id: 1,
15583 entity_id: 1,
15584 character_id: None,
15585 tick: 0,
15586 chunk_rev: 0,
15587 content_rev: 0,
15588 publish_rev: 0,
15589 entities: vec![EntityState {
15590 id: 1,
15591 label: "You".into(),
15592 transform: Transform {
15593 position: WorldCoord::surface(4.5, 2.0),
15594 yaw: 0.0,
15595 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
15596 },
15597 vitals: None,
15598 attributes: None,
15599 skills: None,
15600 inside_building: Some("broker_hut".into()),
15601 tile_id: None,
15602 paperdoll_ref: None,
15603 draw_scale: 1.0,
15604 presentation_state: None,
15605 sprite_mode: None,
15606 progression_xp: None,
15607 combat_cues: vec![],
15608 statuses: vec![],
15609 }],
15610 player: None,
15611 resource_nodes: vec![],
15612 ground_drops: vec![],
15613 placed_containers: vec![],
15614 buildings: vec![BuildingView {
15615 id: "broker_hut".into(),
15616 label: "Broker".into(),
15617 x: 158.0,
15618 y: 124.0,
15619 width_m: 8.0,
15620 depth_m: 6.0,
15621 interior_blueprint: Some("broker_hut".into()),
15622 tags: vec![],
15623 market_boundary_zone_ids: vec![],
15624 market_max_volume: None,
15625 wall_set: None,
15626 roof_set: None,
15627 }],
15628 doors: vec![flatland_protocol::DoorView {
15629 id: "broker_hut_exit".into(),
15630 building_id: "broker_hut".into(),
15631 x: 4.3,
15632 y: 0.9,
15633 open: true,
15634 portal: Some("front".into()),
15635 locked: false,
15636 accessible: true,
15637 lock_id: None,
15638 }],
15639 interior_map: None,
15640 npcs: vec![flatland_protocol::NpcView {
15641 id: "ada_broker".into(),
15642 label: "Ada".into(),
15643 x: 4.5,
15644 y: 2.0,
15645 building_id: Some("broker_hut".into()),
15646 role: "broker".into(),
15647 entity_id: None,
15648 life_state: None,
15649 hp_pct: None,
15650 can_trade: true,
15651 tile_id: None,
15652 behavior_state: None,
15653 presentation_state: None,
15654 sprite_mode: None,
15655 paperdoll_ref: None,
15656 draw_scale: 1.0,
15657 }],
15658 blueprints: vec![],
15659 building_materials: vec![],
15660 world_x0: 0.0,
15661 world_y0: 0.0,
15662 world_width_m: 256.0,
15663 world_height_m: 256.0,
15664 terrain_zones: Vec::new(),
15665 z_platforms: Vec::new(),
15666 z_transitions: Vec::new(),
15667 z_bands_outdoor_backup: None,
15668 world_clock: flatland_protocol::WorldClock::default(),
15669 inventory: std::collections::HashMap::new(),
15670 inventory_hints: std::collections::HashMap::new(),
15671 logs: VecDeque::new(),
15672 intents_sent: 0,
15673 ticks_received: 0,
15674 connected: true,
15675 disconnect_reason: None,
15676 show_stats: false,
15677 hud_log_hidden: false,
15678 show_equip_menu: false,
15679 equip_menu_index: 0,
15680 show_craft_menu: false,
15681 show_plot_build_menu: false,
15682 plot_build_focus_wall: true,
15683 plot_build_wall_index: 0,
15684 plot_build_roof_index: 0,
15685 craft_menu_index: 0,
15686 craft_batch_quantity: 1,
15687 show_shop_menu: false,
15688 shop_catalog: None,
15689 bank_panel: None,
15690 bank_menu_index: 0,
15691 bank_ui_mode: BankUiMode::Menu,
15692 storage_panel: None,
15693 market_panel: None,
15694 market_menu_index: 0,
15695 market_filter: String::new(),
15696 market_filter_focused: false,
15697 market_category_filter: None,
15698 market_buy_confirm: None,
15699 market_ui_mode: MarketUiMode::Browse,
15700 storage_menu_index: 0,
15701 storage_ui_mode: StorageUiMode::Menu,
15702 shop_tab: ShopTab::default(),
15703 shop_menu_index: 0,
15704 shop_quantity: 1,
15705 shop_trade_log: VecDeque::new(),
15706 show_npc_verb_menu: false,
15707 npc_verb_target: None,
15708 npc_verb_index: 0,
15709 player_verbs: crate::social::PlayerVerbState::default(),
15710 social_chat: crate::social::SocialChatState::default(),
15711 trade_ui: crate::social::TradeUiState::default(),
15712 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
15713 show_npc_chat: false,
15714 npc_chat: None,
15715 show_inventory_menu: false,
15716 inventory_menu_index: 0,
15717 inventory_tab: InventoryTab::OnPerson,
15718 inventory_filter: String::new(),
15719 inventory_filter_focused: false,
15720 show_move_picker: false,
15721 show_rename_prompt: false,
15722 rename_plot_id: None,
15723 highlighted_plot_id: None,
15724 show_worker_rename: false,
15725 rename_buffer: String::new(),
15726 move_picker_index: 0,
15727 move_picker: None,
15728 show_grant_picker: false,
15729 grant_picker_index: 0,
15730 grant_picker: None,
15731 show_destroy_picker: false,
15732 destroy_confirm_pending: false,
15733 destroy_picker: None,
15734 combat_target: None,
15735 combat_target_label: None,
15736 ground_target: None,
15737 combat_fx: Vec::new(),
15738 property_zones: Vec::new(),
15739 tax_zones: Vec::new(),
15740 growth_zones: Vec::new(),
15741 biome_zones: Vec::new(),
15742 terrain_kind_nav: Vec::new(),
15743 property_plots: Vec::new(),
15744 property_plot_settings: None,
15745 claim_mode: None,
15746 relocate_mode: None,
15747 sell_plot_confirm: None,
15748 sell_plot_armed_at: None,
15749 show_plant_menu: false,
15750 plant_menu_index: 0,
15751 show_farm_access: false,
15752 farm_access_name_draft: String::new(),
15753 farm_access_discount_bps: 0,
15754 farm_access_index: 0,
15755 plant_quantity: 1,
15756 in_combat: false,
15757 auto_attack: true,
15758 combat_has_los: false,
15759 attack_cd_ticks: 0,
15760 gcd_ticks: 0,
15761 weapon_ability_id: "unarmed".into(),
15762 mainhand_template_id: None,
15763 mainhand_label: None,
15764 mainhand_instance_id: None,
15765 offhand_template_id: None,
15766 offhand_label: None,
15767 offhand_instance_id: None,
15768 mainhand_hand_slots: 1,
15769 defense: None,
15770 worn: BTreeMap::new(),
15771 carry_mass: 0.0,
15772 carry_mass_max: 0.0,
15773 encumbrance: flatland_protocol::EncumbranceState::Light,
15774 inventory_stacks: Vec::new(),
15775 keychain_stacks: Vec::new(),
15776 whisper_pouch_stacks: Vec::new(),
15777 combat_target_detail: None,
15778 statuses: Vec::new(),
15779 cast_progress: None,
15780 timed_channel: None,
15781 plot_build_offer: None,
15782 ability_cooldowns: Vec::new(),
15783 blocking_active: false,
15784 max_target_slots: 1,
15785 combat_slots: Vec::new(),
15786 rotation_presets: Vec::new(),
15787 known_abilities: Vec::new(),
15788 ability_meta: std::collections::HashMap::new(),
15789 ability_mastery: std::collections::HashMap::new(),
15790 hotbar: vec![None; 9],
15791 max_abilities_per_rotation: 0,
15792 show_loadout_menu: false,
15793 show_keychain_menu: false,
15794 keychain_menu_index: 0,
15795 show_rotation_editor: false,
15796 loadout_menu_index: 0,
15797 loadout_hotbar_slot: 1,
15798 loadout_ability_index: 0,
15799 loadout_focus_presets: false,
15800 rotation_editor: RotationEditorState::default(),
15801 harvest_in_progress: false,
15802 harvest_started_at: None,
15803 pending_craft_ack: None,
15804 pending_worker_job_ack: None,
15805 attending_worker_instance_id: None,
15806 quest_log: Vec::new(),
15807 interactables: Vec::new(),
15808 ledger: None,
15809 career: None,
15810 character_sheet_tab: CharacterSheetTab::Character,
15811 ledger_period: LedgerPeriod::Day,
15812 show_quest_offer: false,
15813 pending_quest_offer: None,
15814 show_quest_menu: false,
15815 quest_menu_index: 0,
15816 quest_withdraw_confirm: false,
15817 hired_workers: Vec::new(),
15818 show_workers_menu: false,
15819 workers_menu_index: 0,
15820 workers_menu_compact: false,
15821 worker_step_display: BTreeMap::new(),
15822 worker_error_display: BTreeMap::new(),
15823 show_worker_give_picker: false,
15824 worker_give_picker_index: 0,
15825 worker_give_picker: None,
15826 show_worker_give_target_picker: false,
15827 worker_give_target_picker_index: 0,
15828 worker_give_target_picker: None,
15829 show_worker_take_picker: false,
15830 worker_take_picker_index: 0,
15831 worker_take_picker: None,
15832 show_worker_teach_picker: false,
15833 worker_teach_picker_index: 0,
15834 worker_teach_picker: None,
15835 worker_route_editor: None,
15836 progression_curve: None,
15837 };
15838 state.player = state.entities.first().cloned();
15839 assert_eq!(
15840 state.nearest_interact_target().as_deref(),
15841 Some("ada_broker")
15842 );
15843 }
15844
15845 #[test]
15846 fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
15847 let mut state = sample_state();
15848 state.placed_containers = vec![
15851 flatland_protocol::PlacedContainerView {
15852 id: "near".into(),
15853 template_id: "wooden_chest_small".into(),
15854 display_name: "Wooden Chest".into(),
15855 x: 130.0,
15856 y: 128.0,
15857 z: 0.0,
15858 locked: true,
15859 accessible: true,
15860 owner_character_id: None,
15861 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
15862 lock_id: None,
15863 capacity_volume: None,
15864 item_instance_id: Some(uuid::Uuid::from_u128(1)),
15865 tile_id: None,
15866 worker_lodging_capacity: None,
15867 blocking: false,
15868 blocking_radius_m: 0.0,
15869 building_id: None,
15870 },
15871 flatland_protocol::PlacedContainerView {
15872 id: "far".into(),
15873 template_id: "wooden_chest_small".into(),
15874 display_name: "Distant Chest".into(),
15875 x: 128.0 + CONTAINER_RANGE_M + 5.0,
15876 y: 128.0,
15877 z: 0.0,
15878 locked: false,
15879 accessible: true,
15880 owner_character_id: None,
15881 contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
15882 lock_id: None,
15883 capacity_volume: None,
15884 item_instance_id: Some(uuid::Uuid::from_u128(2)),
15885 tile_id: None,
15886 worker_lodging_capacity: None,
15887 blocking: false,
15888 blocking_radius_m: 0.0,
15889 building_id: None,
15890 },
15891 ];
15892
15893 let nearby = state.nearby_containers();
15894 assert_eq!(
15895 nearby.len(),
15896 1,
15897 "far chest must not appear once out of range"
15898 );
15899 assert_eq!(nearby[0].view.id, "near");
15900 assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
15901 assert!(nearby[0].rows[0].is_chest_shell);
15902
15903 state.placed_containers[0].accessible = false;
15906 let nearby = state.nearby_containers();
15907 assert_eq!(nearby.len(), 1);
15908 assert_eq!(nearby[0].rows.len(), 1);
15909 assert!(nearby[0].rows[0].is_chest_shell);
15910 }
15911
15912 #[test]
15913 fn chest_pickup_destinations_offer_person_and_worn_bag() {
15914 let mut state = sample_state();
15915 let back_id = uuid::Uuid::from_u128(42);
15916 state.worn.insert(
15917 BodySlot::Back,
15918 flatland_protocol::ItemStack {
15919 template_id: "travel_backpack".into(),
15920 quantity: 1,
15921 item_instance_id: Some(back_id),
15922 props: Default::default(),
15923 status_bindings: Vec::new(),
15924 contents: Vec::new(),
15925 display_name: Some("Travel Backpack".into()),
15926 category: Some("container".into()),
15927 base_mass: Some(2.5),
15928 base_volume: Some(12.0),
15929 capacity_volume: Some(80.0),
15930 stackable: Some(false),
15931 world_placeable: Some(false),
15932 worker_lodging_capacity: None,
15933 equip_slot: None,
15934 armor_physical: None,
15935 resists: vec![],
15936 hand_slots: None,
15937 listable: None,
15938 },
15939 );
15940 let opts = state.chest_pickup_destinations("chest-1");
15941 assert!(matches!(
15942 opts.first().map(|o| &o.kind),
15943 Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
15944 ));
15945 assert!(opts.iter().any(|o| matches!(
15946 &o.kind,
15947 MoveOptionKind::PickupPlaced {
15948 nest_parent_instance_id: None,
15949 ..
15950 }
15951 )));
15952 assert!(opts.iter().any(|o| matches!(
15953 &o.kind,
15954 MoveOptionKind::PickupPlaced {
15955 nest_parent_instance_id: Some(id),
15956 ..
15957 } if *id == back_id
15958 )));
15959 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
15960 }
15961
15962 #[test]
15963 fn placed_container_public_label_hides_owner_custom_name() {
15964 let owner = uuid::Uuid::from_u128(99);
15965 let mut state = sample_state();
15966 state.character_id = Some(uuid::Uuid::from_u128(1));
15967 state.inventory_hints.insert(
15968 "wooden_chest_medium".into(),
15969 InventoryHint {
15970 display_name: "Medium Wooden Chest".into(),
15971 category: "container".into(),
15972 base_mass: None,
15973 base_volume: None,
15974 capacity_volume: None,
15975 stackable: false,
15976 listable: true,
15977 },
15978 );
15979 let chest = flatland_protocol::PlacedContainerView {
15980 id: "c1".into(),
15981 template_id: "wooden_chest_medium".into(),
15982 display_name: "Barry's Loot #a3f2".into(),
15983 x: 128.0,
15984 y: 128.0,
15985 z: 0.0,
15986 locked: false,
15987 accessible: true,
15988 owner_character_id: Some(owner),
15989 contents: vec![],
15990 lock_id: None,
15991 capacity_volume: None,
15992 item_instance_id: None,
15993 tile_id: None,
15994 worker_lodging_capacity: None,
15995 blocking: false,
15996 blocking_radius_m: 0.0,
15997 building_id: None,
15998 };
15999 assert_eq!(
16000 state.placed_container_public_label(&chest),
16001 "Medium Wooden Chest"
16002 );
16003 state.character_id = Some(owner);
16004 assert_eq!(
16005 state.placed_container_public_label(&chest),
16006 "Barry's Loot #a3f2"
16007 );
16008 }
16009
16010 #[test]
16011 fn location_context_shows_crop_growth_percent_not_depleted() {
16012 let mut state = sample_state();
16013 state.player = state.entities.first().cloned();
16014 state.resource_nodes[0].label = "Carrot (growing)".into();
16015 state.resource_nodes[0].x = 128.2;
16016 state.resource_nodes[0].y = 128.0;
16017 state.resource_nodes[0].state = ResourceNodeState::Cooldown;
16018 state.resource_nodes[0].growth_progress = Some(0.47);
16019 let lines = state.location_context_lines();
16020 let line = lines
16021 .iter()
16022 .find(|l| l.text.contains("Carrot"))
16023 .map(|l| l.text.as_str())
16024 .unwrap_or("");
16025 assert!(
16026 line.contains("(growing, 47%)"),
16027 "expected growth percent, got: {line}"
16028 );
16029 assert!(
16030 !line.contains("depleted"),
16031 "growing crop should not show depleted: {line}"
16032 );
16033 }
16034
16035 #[test]
16036 fn resource_node_near_action_suffix_prefers_growth() {
16037 let node = ResourceNodeView {
16038 id: "crop".into(),
16039 label: "Wheat".into(),
16040 x: 0.0,
16041 y: 0.0,
16042 z: 0.0,
16043 item_template: "wheat".into(),
16044 state: ResourceNodeState::Cooldown,
16045 blocking: false,
16046 blocking_radius_m: 0.0,
16047 harvest_off: false,
16048 tile_id: None,
16049 yaw: 0.0,
16050 pitch: 0.0,
16051 roll: 0.0,
16052 draw_scale: 1.0,
16053 sprite_mode: None,
16054 growth_progress: Some(0.12),
16055 presentation_state: None,
16056 channel_start_tick: None,
16057 channel_end_tick: None,
16058 harvest_drop_templates: vec![],
16059 };
16060 assert_eq!(
16061 resource_node_near_action_suffix(&node),
16062 " (growing, 12%)"
16063 );
16064 }
16065
16066 #[test]
16067 fn location_context_lists_nearby_resource_node() {
16068 let mut state = sample_state();
16069 state.player = state.entities.first().cloned();
16070 state.resource_nodes[0].x = 128.2;
16071 state.resource_nodes[0].y = 128.0;
16072 let lines = state.location_context_lines();
16073 assert!(
16074 lines
16075 .iter()
16076 .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
16077 "expected resource node in context: {:?}",
16078 lines
16079 );
16080 }
16081
16082 #[test]
16083 fn quest_board_usable_within_board_radius() {
16084 let mut state = sample_state();
16085 state.player = state.entities.first().cloned();
16086 state.interactables = vec![flatland_protocol::InteractableView {
16087 id: "board-1".into(),
16088 kind: "quest_board".into(),
16089 label: "Town Quest Board".into(),
16090 x: 130.5,
16091 y: 128.0,
16092 z: 0.0,
16093 board_id: Some("starter_town_board".into()),
16094 }];
16095 assert_eq!(
16097 state.nearest_interact_target().as_deref(),
16098 Some("board-1"),
16099 "quest board should be selectable at ~2.5m"
16100 );
16101 let lines = state.location_context_lines();
16102 assert!(
16103 lines
16104 .iter()
16105 .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
16106 "HUD should advertise f when board is in range: {:?}",
16107 lines
16108 );
16109 }
16110
16111 #[test]
16112 fn inventory_selectable_rows_orders_worn_before_person_on_person_tab() {
16113 let mut state = sample_state();
16114 state.worn.insert(
16115 BodySlot::Back,
16116 flatland_protocol::ItemStack {
16117 template_id: "travel_backpack".into(),
16118 quantity: 1,
16119 item_instance_id: Some(uuid::Uuid::from_u128(3)),
16120 props: Default::default(),
16121 status_bindings: Vec::new(),
16122 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
16123 display_name: None,
16124 category: None,
16125 base_mass: None,
16126 base_volume: None,
16127 capacity_volume: None,
16128 stackable: None,
16129 world_placeable: None,
16130 worker_lodging_capacity: None,
16131 equip_slot: None,
16132 armor_physical: None,
16133 resists: vec![],
16134 hand_slots: None,
16135 listable: None,
16136 },
16137 );
16138 state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
16139 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16140 id: "chest-1".into(),
16141 template_id: "wooden_chest_small".into(),
16142 display_name: "Wooden Chest".into(),
16143 x: 129.0,
16144 y: 128.0,
16145 z: 0.0,
16146 locked: false,
16147 accessible: true,
16148 owner_character_id: None,
16149 contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
16150 lock_id: None,
16151 capacity_volume: None,
16152 item_instance_id: Some(uuid::Uuid::from_u128(4)),
16153 tile_id: None,
16154 worker_lodging_capacity: None,
16155 blocking: false,
16156 blocking_radius_m: 0.0,
16157 building_id: None,
16158 }];
16159
16160 state.inventory_tab = InventoryTab::OnPerson;
16161 let rows = state.inventory_selectable_rows();
16162 let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
16163 assert_eq!(
16164 sections,
16165 vec![
16166 InventorySection::Worn, InventorySection::Worn, InventorySection::Person, ]
16170 );
16171 assert_eq!(rows[0].stack.template_id, "travel_backpack");
16172 assert!(rows[0].is_equip_shell);
16173 assert_eq!(rows[1].stack.template_id, "iron_ore");
16174 assert_eq!(rows[1].depth, 1);
16175 assert_eq!(rows[2].stack.template_id, "lumber");
16176
16177 let lines = state.inventory_browser_lines();
16178 assert!(lines.iter().any(|l| matches!(
16179 l,
16180 InventoryBrowserLine::Section(s) if s.contains("Worn")
16181 )));
16182 assert!(lines.iter().any(|l| matches!(
16183 l,
16184 InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
16185 || text.contains("backpack")
16186 )));
16187 assert!(!lines.iter().any(|l| matches!(
16188 l,
16189 InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
16190 )));
16191
16192 state.inventory_tab = InventoryTab::Nearby;
16193 let nearby_rows = state.inventory_selectable_rows();
16194 assert_eq!(nearby_rows.len(), 2);
16195 assert!(nearby_rows[0].is_chest_shell);
16196 assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
16197 let nearby_lines = state.inventory_browser_lines();
16198 assert!(nearby_lines.iter().any(|l| matches!(
16199 l,
16200 InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
16201 )));
16202 }
16203
16204 #[test]
16205 fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
16206 let mut state = sample_state();
16207 let back_id = uuid::Uuid::from_u128(5);
16208 state.worn.insert(
16209 BodySlot::Back,
16210 flatland_protocol::ItemStack {
16211 template_id: "travel_backpack".into(),
16212 quantity: 1,
16213 item_instance_id: Some(back_id),
16214 props: Default::default(),
16215 status_bindings: Vec::new(),
16216 contents: Vec::new(),
16217 display_name: None,
16218 category: Some("container".into()),
16219 base_mass: None,
16220 base_volume: None,
16221 capacity_volume: Some(80.0),
16222 stackable: None,
16223 world_placeable: None,
16224 worker_lodging_capacity: None,
16225 equip_slot: None,
16226 armor_physical: None,
16227 resists: vec![],
16228 hand_slots: None,
16229 listable: None,
16230 },
16231 );
16232 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16233 id: "chest-1".into(),
16234 template_id: "wooden_chest_small".into(),
16235 display_name: "Wooden Chest".into(),
16236 x: 129.0,
16237 y: 128.0,
16238 z: 0.0,
16239 locked: false,
16240 accessible: true,
16241 owner_character_id: None,
16242 contents: Vec::new(),
16243 lock_id: None,
16244 capacity_volume: None,
16245 item_instance_id: Some(uuid::Uuid::from_u128(6)),
16246 tile_id: None,
16247 worker_lodging_capacity: None,
16248 blocking: false,
16249 blocking_radius_m: 0.0,
16250 building_id: None,
16251 }];
16252
16253 let opts = state.move_destinations_for(
16256 &flatland_protocol::InventoryLocation::Root,
16257 None,
16258 None,
16259 "lumber",
16260 );
16261 assert!(!opts.iter().any(|o| matches!(
16262 &o.kind,
16263 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
16264 )));
16265 assert!(opts.iter().any(|o| matches!(
16266 &o.kind,
16267 MoveOptionKind::Move { location, parent_instance_id, .. }
16268 if *location == flatland_protocol::InventoryLocation::Worn {
16269 slot: BodySlot::Back,
16270 } && *parent_instance_id == Some(back_id)
16271 )));
16272 assert!(opts.iter().any(|o| matches!(
16273 &o.kind,
16274 MoveOptionKind::Move { location, .. }
16275 if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
16276 )));
16277 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
16278 assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
16279
16280 let from_backpack = flatland_protocol::InventoryLocation::Worn {
16284 slot: BodySlot::Back,
16285 };
16286 let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
16287 assert!(!opts.iter().any(|o| matches!(
16288 &o.kind,
16289 MoveOptionKind::Move { location, parent_instance_id, .. }
16290 if *location == from_backpack && *parent_instance_id == Some(back_id)
16291 )));
16292 assert!(opts.iter().any(|o| matches!(
16293 &o.kind,
16294 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
16295 )));
16296 }
16297
16298 #[test]
16299 fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
16300 let mut state = sample_state();
16301 state.worn.insert(
16304 BodySlot::Waist,
16305 flatland_protocol::ItemStack {
16306 template_id: "simple_belt".into(),
16307 quantity: 1,
16308 item_instance_id: Some(uuid::Uuid::from_u128(10)),
16309 props: Default::default(),
16310 status_bindings: Vec::new(),
16311 contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
16312 display_name: None,
16313 category: Some("container".into()),
16314 base_mass: None,
16315 base_volume: None,
16316 capacity_volume: None,
16317 stackable: None,
16318 world_placeable: None,
16319 worker_lodging_capacity: None,
16320 equip_slot: None,
16321 armor_physical: None,
16322 resists: vec![],
16323 hand_slots: None,
16324 listable: None,
16325 },
16326 );
16327 state.worn.insert(
16328 BodySlot::Head,
16329 flatland_protocol::ItemStack {
16330 template_id: "cloth_cap".into(),
16331 quantity: 1,
16332 item_instance_id: Some(uuid::Uuid::from_u128(11)),
16333 props: Default::default(),
16334 status_bindings: Vec::new(),
16335 contents: Vec::new(),
16336 display_name: None,
16337 category: Some("armor".into()),
16338 base_mass: None,
16339 base_volume: None,
16340 capacity_volume: None,
16341 stackable: None,
16342 world_placeable: None,
16343 worker_lodging_capacity: None,
16344 equip_slot: None,
16345 armor_physical: None,
16346 resists: vec![],
16347 hand_slots: None,
16348 listable: None,
16349 },
16350 );
16351 state.worn.insert(
16352 BodySlot::Back,
16353 flatland_protocol::ItemStack {
16354 template_id: "travel_backpack".into(),
16355 quantity: 1,
16356 item_instance_id: Some(uuid::Uuid::from_u128(12)),
16357 props: Default::default(),
16358 status_bindings: Vec::new(),
16359 contents: Vec::new(),
16360 display_name: None,
16361 category: Some("container".into()),
16362 base_mass: None,
16363 base_volume: None,
16364 capacity_volume: None,
16365 stackable: None,
16366 world_placeable: None,
16367 worker_lodging_capacity: None,
16368 equip_slot: None,
16369 armor_physical: None,
16370 resists: vec![],
16371 hand_slots: None,
16372 listable: None,
16373 },
16374 );
16375
16376 let rows = state.worn_rows();
16377 assert_eq!(rows.len(), 4);
16379 assert_eq!(rows[0].stack.template_id, "cloth_cap");
16380 assert!(rows[0].is_equip_shell);
16381 assert_eq!(rows[1].stack.template_id, "travel_backpack");
16382 assert!(rows[1].is_equip_shell);
16383 assert_eq!(rows[2].stack.template_id, "simple_belt");
16384 assert!(rows[2].is_equip_shell);
16385 assert_eq!(rows[3].stack.template_id, "leather_pouch");
16386 assert_eq!(rows[3].depth, 1);
16387 assert!(!rows[3].is_equip_shell);
16388 }
16389
16390 #[test]
16391 fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
16392 let mut state = sample_state();
16393 state.worn.insert(
16394 BodySlot::Waist,
16395 flatland_protocol::ItemStack {
16396 template_id: "simple_belt".into(),
16397 quantity: 1,
16398 item_instance_id: Some(uuid::Uuid::from_u128(20)),
16399 props: Default::default(),
16400 status_bindings: Vec::new(),
16401 contents: Vec::new(),
16402 display_name: Some("Simple Belt".into()),
16403 category: Some("container".into()),
16404 base_mass: None,
16405 base_volume: None,
16406 capacity_volume: None,
16407 stackable: None,
16408 world_placeable: None,
16409 worker_lodging_capacity: None,
16410 equip_slot: None,
16411 armor_physical: None,
16412 resists: vec![],
16413 hand_slots: None,
16414 listable: None,
16415 },
16416 );
16417 state.worn.insert(
16418 BodySlot::Head,
16419 flatland_protocol::ItemStack {
16420 template_id: "cloth_cap".into(),
16421 quantity: 1,
16422 item_instance_id: Some(uuid::Uuid::from_u128(21)),
16423 props: Default::default(),
16424 status_bindings: Vec::new(),
16425 contents: Vec::new(),
16426 display_name: Some("Cloth Cap".into()),
16427 category: Some("armor".into()),
16428 base_mass: None,
16429 base_volume: None,
16430 capacity_volume: None,
16431 stackable: None,
16432 world_placeable: None,
16433 worker_lodging_capacity: None,
16434 equip_slot: None,
16435 armor_physical: None,
16436 resists: vec![],
16437 hand_slots: None,
16438 listable: None,
16439 },
16440 );
16441
16442 let opts = state.move_destinations_for(
16443 &flatland_protocol::InventoryLocation::Root,
16444 None,
16445 None,
16446 "leather_pouch",
16447 );
16448 assert!(
16449 opts.iter().any(|o| matches!(
16450 &o.kind,
16451 MoveOptionKind::Move { location, .. }
16452 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
16453 )),
16454 "belt loop must be offered when moving a pouch"
16455 );
16456 assert!(
16457 !opts.iter().any(|o| matches!(
16458 &o.kind,
16459 MoveOptionKind::Move { location, .. }
16460 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
16461 )),
16462 "armor slots can't hold other items and must not appear as move destinations"
16463 );
16464 let belt_opt = opts
16465 .iter()
16466 .find(|o| matches!(
16467 &o.kind,
16468 MoveOptionKind::Move { location, .. }
16469 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
16470 ))
16471 .unwrap();
16472 assert!(belt_opt.label.contains("belt loop"));
16473
16474 let opts = state.move_destinations_for(
16475 &flatland_protocol::InventoryLocation::Root,
16476 None,
16477 None,
16478 "lumber",
16479 );
16480 assert!(
16481 !opts.iter().any(|o| o.label.contains("belt loop")),
16482 "loose materials must not target the belt shell — only nested pouches"
16483 );
16484 }
16485
16486 #[test]
16487 fn move_destinations_for_offers_dimensional_pouch_on_belt() {
16488 let mut state = sample_state();
16489 let belt_id = uuid::Uuid::from_u128(30);
16490 let pouch_id = uuid::Uuid::from_u128(31);
16491 state.worn.insert(
16492 BodySlot::Waist,
16493 flatland_protocol::ItemStack {
16494 template_id: "simple_belt".into(),
16495 quantity: 1,
16496 item_instance_id: Some(belt_id),
16497 props: Default::default(),
16498 status_bindings: Vec::new(),
16499 world_placeable: None,
16500 worker_lodging_capacity: None,
16501 equip_slot: None,
16502 armor_physical: None,
16503 resists: vec![],
16504 hand_slots: None,
16505 contents: vec![flatland_protocol::ItemStack {
16506 template_id: "dimensional_pouch".into(),
16507 quantity: 1,
16508 item_instance_id: Some(pouch_id),
16509 props: Default::default(),
16510 status_bindings: Vec::new(),
16511 contents: Vec::new(),
16512 display_name: Some("Dimensional Pouch".into()),
16513 category: Some("container".into()),
16514 base_mass: None,
16515 base_volume: None,
16516 capacity_volume: Some(200.0),
16517 stackable: None,
16518 world_placeable: None,
16519 worker_lodging_capacity: None,
16520 equip_slot: None,
16521 armor_physical: None,
16522 resists: vec![],
16523 hand_slots: None,
16524 listable: None,
16525 }],
16526 display_name: Some("Simple Belt".into()),
16527 category: Some("container".into()),
16528 base_mass: None,
16529 base_volume: None,
16530 capacity_volume: None,
16531 stackable: None,
16532 listable: None,
16533 },
16534 );
16535
16536 let opts = state.move_destinations_for(
16537 &flatland_protocol::InventoryLocation::Root,
16538 None,
16539 None,
16540 "iron_ore",
16541 );
16542 assert!(
16543 opts.iter().any(|o| matches!(
16544 &o.kind,
16545 MoveOptionKind::Move {
16546 location,
16547 parent_instance_id,
16548 ..
16549 } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
16550 && *parent_instance_id == Some(pouch_id)
16551 )),
16552 "dimensional pouch clipped on belt must accept loose items"
16553 );
16554 assert!(
16555 opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
16556 "destination label should name the pouch"
16557 );
16558 }
16559
16560 #[test]
16561 fn container_volume_label_on_placed_chest_shell() {
16562 let mut state = sample_state();
16563 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16564 id: "chest-1".into(),
16565 template_id: "wooden_chest_small".into(),
16566 display_name: "Camp Chest".into(),
16567 x: 129.0,
16568 y: 128.0,
16569 z: 0.0,
16570 locked: false,
16571 accessible: true,
16572 owner_character_id: None,
16573 contents: vec![flatland_protocol::ItemStack {
16574 template_id: "iron_ore".into(),
16575 quantity: 2,
16576 item_instance_id: None,
16577 props: Default::default(),
16578 status_bindings: Vec::new(),
16579 contents: Vec::new(),
16580 display_name: None,
16581 category: None,
16582 base_mass: None,
16583 base_volume: Some(2.0),
16584 capacity_volume: None,
16585 stackable: None,
16586 world_placeable: None,
16587 worker_lodging_capacity: None,
16588 equip_slot: None,
16589 armor_physical: None,
16590 resists: vec![],
16591 hand_slots: None,
16592 listable: None,
16593 }],
16594 lock_id: None,
16595 capacity_volume: Some(60.0),
16596 item_instance_id: Some(uuid::Uuid::from_u128(4)),
16597 tile_id: None,
16598 worker_lodging_capacity: None,
16599 blocking: false,
16600 blocking_radius_m: 0.0,
16601 building_id: None,
16602 }];
16603 let nearby = state.nearby_containers();
16604 let label = state.container_volume_label(&nearby[0].rows[0]);
16605 assert!(
16606 label.contains("vol 4/60"),
16607 "expected used/cap in label, got {label}"
16608 );
16609 assert!(
16610 label.contains("56 free"),
16611 "expected free space, got {label}"
16612 );
16613 }
16614
16615 #[test]
16616 fn key_pair_chest_label_from_placed_lock_id() {
16617 let mut state = sample_state();
16618 let owner = uuid::Uuid::from_u128(77);
16619 state.character_id = Some(owner);
16620 let lock = uuid::Uuid::from_u128(99).to_string();
16621 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16622 id: "chest-1".into(),
16623 template_id: "wooden_chest_small".into(),
16624 display_name: "Barry's Loot #a3f2".into(),
16625 x: 129.0,
16626 y: 128.0,
16627 z: 0.0,
16628 locked: true,
16629 accessible: true,
16630 owner_character_id: Some(owner),
16631 contents: Vec::new(),
16632 lock_id: Some(lock.clone()),
16633 capacity_volume: None,
16634 item_instance_id: Some(uuid::Uuid::from_u128(4)),
16635 tile_id: None,
16636 worker_lodging_capacity: None,
16637 blocking: false,
16638 blocking_radius_m: 0.0,
16639 building_id: None,
16640 }];
16641 let key_id = uuid::Uuid::from_u128(5);
16642 let key = flatland_protocol::ItemStack {
16643 template_id: KEY_TEMPLATE.into(),
16644 quantity: 1,
16645 item_instance_id: Some(key_id),
16646 props: BTreeMap::from([
16647 (PROP_OPENS_LOCK_ID.into(), lock),
16648 (
16649 PROP_OPENS_CONTAINER_NAME.into(),
16650 "Barry's Loot #a3f2".into(),
16651 ),
16652 ]),
16653 status_bindings: Vec::new(),
16654 contents: Vec::new(),
16655 display_name: Some("Container Key".into()),
16656 category: Some("key".into()),
16657 base_mass: None,
16658 base_volume: None,
16659 capacity_volume: None,
16660 stackable: None,
16661 world_placeable: None,
16662 worker_lodging_capacity: None,
16663 equip_slot: None,
16664 armor_physical: None,
16665 resists: vec![],
16666 hand_slots: None,
16667 listable: None,
16668 };
16669 state.inventory_stacks = vec![key.clone()];
16670 assert_eq!(
16671 state.key_pair_chest_label(&key).as_deref(),
16672 Some("Barry's Loot #a3f2")
16673 );
16674 assert!(state.key_drop_blocked(&key));
16675 }
16676
16677 #[test]
16678 fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
16679 let mut state = sample_state();
16680 let lock = uuid::Uuid::from_u128(101).to_string();
16681 let key = flatland_protocol::ItemStack {
16682 template_id: KEY_TEMPLATE.into(),
16683 quantity: 1,
16684 item_instance_id: Some(uuid::Uuid::from_u128(7)),
16685 props: BTreeMap::from([
16686 (PROP_OPENS_LOCK_ID.into(), lock),
16687 (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
16688 ]),
16689 status_bindings: Vec::new(),
16690 contents: Vec::new(),
16691 display_name: None,
16692 category: Some("key".into()),
16693 base_mass: None,
16694 base_volume: None,
16695 capacity_volume: None,
16696 stackable: None,
16697 world_placeable: None,
16698 worker_lodging_capacity: None,
16699 equip_slot: None,
16700 armor_physical: None,
16701 resists: vec![],
16702 hand_slots: None,
16703 listable: None,
16704 };
16705 state.placed_containers.clear();
16706 assert_eq!(
16707 state.key_pair_chest_label(&key).as_deref(),
16708 Some("Camp Stash")
16709 );
16710 }
16711
16712 #[test]
16713 fn key_drop_allowed_when_paired_chest_unlocked() {
16714 let mut state = sample_state();
16715 let lock = uuid::Uuid::from_u128(100).to_string();
16716 let key_id = uuid::Uuid::from_u128(6);
16717 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16718 id: "chest-1".into(),
16719 template_id: "wooden_chest_small".into(),
16720 display_name: "Camp Chest".into(),
16721 x: 129.0,
16722 y: 128.0,
16723 z: 0.0,
16724 locked: false,
16725 accessible: true,
16726 owner_character_id: None,
16727 contents: Vec::new(),
16728 lock_id: Some(lock.clone()),
16729 capacity_volume: None,
16730 item_instance_id: None,
16731 tile_id: None,
16732 worker_lodging_capacity: None,
16733 blocking: false,
16734 blocking_radius_m: 0.0,
16735 building_id: None,
16736 }];
16737 let key = flatland_protocol::ItemStack {
16738 template_id: KEY_TEMPLATE.into(),
16739 quantity: 1,
16740 item_instance_id: Some(key_id),
16741 props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
16742 status_bindings: Vec::new(),
16743 contents: Vec::new(),
16744 display_name: None,
16745 category: Some("key".into()),
16746 base_mass: None,
16747 base_volume: None,
16748 capacity_volume: None,
16749 stackable: None,
16750 world_placeable: None,
16751 worker_lodging_capacity: None,
16752 equip_slot: None,
16753 armor_physical: None,
16754 resists: vec![],
16755 hand_slots: None,
16756 listable: None,
16757 };
16758 state.inventory_stacks = vec![key.clone()];
16759 assert!(!state.key_drop_blocked(&key));
16760 let opts = state.move_destinations_for(
16761 &flatland_protocol::InventoryLocation::Root,
16762 None,
16763 Some(key_id),
16764 KEY_TEMPLATE,
16765 );
16766 assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
16767 }
16768
16769 #[test]
16770 fn combat_hud_refreshes_progression_xp_when_entity_stale() {
16771 use flatland_protocol::{CombatHud, ProgressionXp, ProgressionCurve};
16772
16773 let mut state = sample_state();
16774 let curve = ProgressionCurve::default();
16775 let bootstrap = ProgressionXp::bootstrap_new(
16776 curve.baseline_display,
16777 curve.xp_base,
16778 curve.xp_growth,
16779 );
16780 let mut fresh = bootstrap.clone();
16781 fresh.strength += 0.08;
16782 if let Some(player) = state.player.as_mut() {
16783 player.progression_xp = Some(bootstrap);
16784 }
16785
16786 let combat = CombatHud {
16787 progression_xp: Some(fresh.clone()),
16788 progression_baseline: curve.baseline_display,
16789 progression_xp_base: curve.xp_base,
16790 progression_xp_growth: curve.xp_growth,
16791 attributes: state.player.as_ref().and_then(|p| p.attributes),
16792 skills: state.player.as_ref().and_then(|p| p.skills.clone()),
16793 ..CombatHud::default()
16794 };
16795 state.apply_combat_hud(&combat);
16796
16797 let xp = state
16798 .player
16799 .as_ref()
16800 .and_then(|p| p.progression_xp.as_ref())
16801 .expect("xp");
16802 assert!((xp.strength - fresh.strength).abs() < 0.001);
16803 assert!(state.progression_curve.is_some());
16804 }
16805
16806 #[test]
16807 fn combat_hud_syncs_known_abilities_and_hotbar() {
16808 use flatland_protocol::CombatHud;
16809
16810 let mut state = sample_state();
16811 let combat = CombatHud {
16812 known_abilities: vec!["unarmed".into(), "fireball".into()],
16813 hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
16814 max_abilities_per_rotation: 4,
16815 ability_id: "short_sword_slash".into(),
16816 ..CombatHud::default()
16817 };
16818 state.apply_combat_hud(&combat);
16819
16820 assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
16821 assert_eq!(state.hotbar_ability(1), Some("fireball"));
16822 assert_eq!(state.hotbar_ability(2), None);
16823 assert_eq!(state.hotbar_ability(3), Some("unarmed"));
16824 assert_eq!(state.max_abilities_per_rotation, 4);
16825 let choices = state.loadout_ability_choices();
16826 assert!(choices.iter().any(|a| a == "short_sword_slash"));
16827 assert!(choices.iter().any(|a| a == "fireball"));
16828 }
16829
16830 #[test]
16831 fn loadout_hotbar_choices_include_inventory_consumables() {
16832 let mut state = sample_state();
16833 state.known_abilities = vec!["unarmed".into()];
16834 state.weapon_ability_id = "unarmed".into();
16835 state.inventory_stacks = vec![flatland_protocol::ItemStack {
16836 template_id: "bottle_of_water".into(),
16837 quantity: 3,
16838 item_instance_id: Some(uuid::Uuid::from_u128(9)),
16839 display_name: Some("Bottle of Water".into()),
16840 category: Some("consumable".into()),
16841 ..Default::default()
16842 }];
16843 state.inventory.insert("bottle_of_water".into(), 3);
16844 state.inventory_hints.insert(
16845 "bottle_of_water".into(),
16846 InventoryHint {
16847 display_name: "Bottle of Water".into(),
16848 category: "consumable".into(),
16849 ..Default::default()
16850 },
16851 );
16852
16853 let choices = state.loadout_hotbar_choices();
16854 assert!(choices.iter().any(|c| c.binding == "unarmed"));
16855 let water = choices
16856 .iter()
16857 .find(|c| c.binding == "item:bottle_of_water")
16858 .expect("water binding");
16859 assert_eq!(water.meta.as_deref(), Some("use"));
16860 assert!(water.label.contains("Water"));
16861 assert_eq!(
16862 state.hotbar_slot_label(1),
16863 None,
16864 "unbound until set"
16865 );
16866 state.hotbar = vec![None, None, None, None, Some("item:bottle_of_water".into())];
16867 assert_eq!(
16868 state.hotbar_slot_label(5).as_deref(),
16869 Some("Bottle of Water×3")
16870 );
16871 }
16872
16873 #[test]
16874 fn storage_store_options_excludes_hand_equipped() {
16875 let mut state = sample_state();
16876 let sword_id = uuid::Uuid::from_u128(11);
16877 let ore_id = uuid::Uuid::from_u128(22);
16878 state.inventory_stacks = vec![
16879 flatland_protocol::ItemStack {
16880 template_id: "short_sword".into(),
16881 quantity: 1,
16882 item_instance_id: Some(sword_id),
16883 display_name: Some("Short Sword".into()),
16884 category: Some("weapon".into()),
16885 ..Default::default()
16886 },
16887 flatland_protocol::ItemStack {
16888 template_id: "iron_ore".into(),
16889 quantity: 5,
16890 item_instance_id: Some(ore_id),
16891 display_name: Some("Iron Ore".into()),
16892 category: Some("resource".into()),
16893 ..Default::default()
16894 },
16895 ];
16896 state.mainhand_template_id = Some("short_sword".into());
16897 state.mainhand_instance_id = Some(sword_id);
16898
16899 let opts = state.storage_store_options();
16900 assert_eq!(opts.len(), 1);
16901 assert_eq!(opts[0].item_instance_id, ore_id);
16902 assert!(state.hand_equipped_instance_ids().contains(&sword_id));
16903 }
16904
16905 #[test]
16906 fn loose_consumable_move_picker_offers_use_and_storage() {
16907 let mut state = sample_state();
16908 let inst = uuid::Uuid::from_u128(77);
16909 state.inventory_stacks = vec![flatland_protocol::ItemStack {
16910 template_id: "carrot".into(),
16911 quantity: 2,
16912 item_instance_id: Some(inst),
16913 props: Default::default(),
16914 status_bindings: Vec::new(),
16915 contents: Vec::new(),
16916 display_name: Some("Wild Carrot".into()),
16917 category: Some("consumable".into()),
16918 base_mass: None,
16919 base_volume: None,
16920 capacity_volume: None,
16921 stackable: Some(true),
16922 world_placeable: None,
16923 worker_lodging_capacity: None,
16924 equip_slot: None,
16925 armor_physical: None,
16926 resists: vec![],
16927 hand_slots: None,
16928 listable: None,
16929 }];
16930 state.inventory_hints.insert(
16931 "carrot".into(),
16932 InventoryHint {
16933 display_name: "Wild Carrot".into(),
16934 category: "consumable".into(),
16935 base_mass: Some(0.15),
16936 base_volume: Some(0.3),
16937 capacity_volume: None,
16938 stackable: true,
16939 listable: true,
16940 },
16941 );
16942 state.show_inventory_menu = true;
16943 state.inventory_menu_index = 0;
16944
16945 let row = state.inventory_selected_row().expect("carrot row");
16946 let mut options = state.move_destinations_for(
16947 &row.from,
16948 row.from_parent_instance_id,
16949 row.stack.item_instance_id,
16950 &row.stack.template_id,
16951 );
16952 if row.from == flatland_protocol::InventoryLocation::Root
16953 && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
16954 {
16955 options.insert(
16956 0,
16957 MoveOption {
16958 label: "Use (eat / drink)".into(),
16959 kind: MoveOptionKind::Use,
16960 },
16961 );
16962 }
16963
16964 assert_eq!(options.first().map(|o| &o.label), Some(&"Use (eat / drink)".into()));
16965 assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
16966 assert!(options.iter().any(|o| matches!(o.kind, MoveOptionKind::Drop)));
16967 }
16968
16969 #[test]
16970 fn inventory_category_group_order_is_stable() {
16971 assert_eq!(inventory_category_group("weapon").0, "Weapons");
16972 assert_eq!(inventory_category_group("armor").0, "Armor");
16973 assert_eq!(inventory_category_group("consumable").0, "Consumables");
16974 assert_eq!(inventory_category_group("resource").0, "Resources");
16975 assert_eq!(inventory_category_group("container").0, "Containers");
16976 assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
16977 assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
16978 }
16979
16980 #[test]
16981 fn page_list_index_clamps_without_wrap() {
16982 assert_eq!(page_list_index(0, -1, 25), 0);
16983 assert_eq!(page_list_index(0, 1, 25), 10);
16984 assert_eq!(page_list_index(12, 1, 25), 22);
16985 assert_eq!(page_list_index(22, 1, 25), 24);
16986 assert_eq!(page_list_index(5, 1, 0), 0);
16987 assert_eq!(page_list_index(3, -1, 8), 0);
16988 }
16989
16990 #[test]
16991 fn inventory_filter_hides_non_matching_person_items() {
16992 let mut state = sample_state();
16993 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
16994 sword.display_name = Some("Iron Sword".into());
16995 sword.category = Some("weapon".into());
16996 let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
16997 herb.display_name = Some("Wild Herb".into());
16998 herb.category = Some("consumable".into());
16999 state.inventory_stacks = vec![sword, herb];
17000 state.inventory_tab = InventoryTab::OnPerson;
17001 state.inventory_filter = "sword".into();
17002
17003 let rows = state.inventory_selectable_rows();
17004 assert_eq!(rows.len(), 1);
17005 assert_eq!(rows[0].stack.template_id, "iron_sword");
17006
17007 let lines = state.inventory_browser_lines();
17008 assert!(lines.iter().any(|l| matches!(
17009 l,
17010 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
17011 )));
17012 assert!(!lines.iter().any(|l| matches!(
17013 l,
17014 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
17015 )));
17016 }
17017
17018 #[test]
17019 fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
17020 let mut state = sample_state();
17021 let id_a = uuid::Uuid::from_u128(0xa1);
17022 let id_b = uuid::Uuid::from_u128(0xb2);
17023 let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
17024 sword_a.display_name = Some("Iron Sword".into());
17025 sword_a.category = Some("weapon".into());
17026 sword_a.item_instance_id = Some(id_a);
17027 let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
17028 sword_b.display_name = Some("Iron Sword".into());
17029 sword_b.category = Some("weapon".into());
17030 sword_b.item_instance_id = Some(id_b);
17031 state.inventory_stacks = vec![sword_a, sword_b];
17032 state.inventory_tab = InventoryTab::OnPerson;
17033
17034 let lines = state.inventory_browser_lines();
17035 let items: Vec<_> = lines
17036 .iter()
17037 .filter_map(|l| match l {
17038 InventoryBrowserLine::Item {
17039 title,
17040 instance_tooltip,
17041 ..
17042 } => Some((title.clone(), instance_tooltip.clone())),
17043 _ => None,
17044 })
17045 .collect();
17046 assert_eq!(items.len(), 2);
17047 for (title, tip) in &items {
17048 assert!(
17049 !title.contains('#'),
17050 "title should not show instance suffix: {title}"
17051 );
17052 assert!(
17053 tip.is_some(),
17054 "two identical rows should expose instance on hover"
17055 );
17056 }
17057
17058 state.inventory_stacks.pop();
17059 let lines = state.inventory_browser_lines();
17060 let one = lines.iter().find_map(|l| match l {
17061 InventoryBrowserLine::Item {
17062 title,
17063 instance_tooltip,
17064 ..
17065 } => Some((title.clone(), instance_tooltip.clone())),
17066 _ => None,
17067 });
17068 let (title, tip) = one.expect("one sword row");
17069 assert!(!title.contains('#'));
17070 assert!(tip.is_none(), "single row should not need instance tooltip");
17071 }
17072
17073 #[test]
17074 fn inventory_person_rows_group_by_category() {
17075 let mut state = sample_state();
17076 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
17077 sword.category = Some("weapon".into());
17078 sword.display_name = Some("Iron Sword".into());
17079 let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
17080 ore.category = Some("resource".into());
17081 ore.display_name = Some("Iron Ore".into());
17082 let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
17083 potion.category = Some("consumable".into());
17084 potion.display_name = Some("Health Potion".into());
17085 state.inventory_stacks = vec![ore, potion, sword];
17086 state.inventory_tab = InventoryTab::OnPerson;
17087
17088 let lines = state.inventory_browser_lines();
17089 let labels: Vec<&str> = lines
17090 .iter()
17091 .filter_map(|l| match l {
17092 InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
17093 _ => None,
17094 })
17095 .collect();
17096 assert!(
17097 labels.iter().any(|s| s.contains("Weapons")),
17098 "expected Weapons group: {labels:?}"
17099 );
17100 assert!(labels.iter().any(|s| s.contains("Consumables")));
17101 assert!(labels.iter().any(|s| s.contains("Resources")));
17102
17103 let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
17104 let consumable_pos = labels.iter().position(|s| s.contains("Consumables")).unwrap();
17105 let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
17106 assert!(weapon_pos < consumable_pos);
17107 assert!(consumable_pos < resource_pos);
17108 }
17109
17110 #[test]
17111 fn inventory_tab_cycle_resets_selection() {
17112 let mut state = sample_state();
17113 state.inventory_tab = InventoryTab::OnPerson;
17114 state.inventory_menu_index = 3;
17115 state.inventory_tab = state.inventory_tab.cycle(true);
17116 assert_eq!(state.inventory_tab, InventoryTab::Nearby);
17117 assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
17119 assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
17120 assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
17121 assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
17122 }
17123
17124 #[test]
17125 fn parse_bank_copper_amount_blank_and_zero_mean_all() {
17126 assert_eq!(parse_bank_copper_amount(""), Some(0));
17127 assert_eq!(parse_bank_copper_amount(" "), Some(0));
17128 assert_eq!(parse_bank_copper_amount("0"), Some(0));
17129 assert_eq!(parse_bank_copper_amount("250"), Some(250));
17130 assert_eq!(parse_bank_copper_amount("nope"), None);
17131 }
17132
17133 #[test]
17134 fn parse_storage_quantity_blank_and_zero_mean_all() {
17135 assert_eq!(parse_storage_quantity(""), Some(None));
17136 assert_eq!(parse_storage_quantity(" "), Some(None));
17137 assert_eq!(parse_storage_quantity("0"), Some(None));
17138 assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
17139 assert_eq!(parse_storage_quantity("nope"), None);
17140 }
17141
17142 #[test]
17143 fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
17144 assert!(worker_error_is_hud_noise("path stuck — repathing"));
17145 assert!(worker_error_is_hud_noise("path stuck — nudged clear, repathing"));
17146 assert!(worker_error_is_hud_noise("returned to lodging after path failures"));
17147 assert!(!worker_error_is_hud_noise(
17149 "path stuck — no lodging to reset to"
17150 ));
17151 }
17152
17153 #[test]
17154 fn leaving_building_restores_outdoor_z_bands() {
17155 use flatland_protocol::{InteriorMapView, ZPlatformView};
17156
17157 let mut state = sample_state();
17158 state.z_platforms.clear();
17159 state.z_transitions.clear();
17160 state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
17161 state.interior_map = Some(InteriorMapView {
17162 building_id: "broker_hut".into(),
17163 blueprint_id: "broker_hut".into(),
17164 background_color: "#000".into(),
17165 default_floor_color: None,
17166 floor_height_m: 3.0,
17167 z_platforms: vec![ZPlatformView {
17168 id: "floor_0".into(),
17169 z: 0.0,
17170 x0: 0.0,
17171 y0: 0.0,
17172 x1: 8.0,
17173 y1: 8.0,
17174 }],
17175 z_transitions: vec![],
17176 rooms: vec![],
17177 room_doors: vec![],
17178 });
17179 state.sync_interior_map_context();
17180 assert_eq!(state.z_platforms.len(), 1, "indoors installs interior platforms");
17181 assert!(state.z_bands_outdoor_backup.is_some());
17182
17183 state.player.as_mut().unwrap().inside_building = None;
17184 state.sync_interior_map_context();
17185 assert!(
17186 state.z_platforms.is_empty(),
17187 "leaving must restore outdoor bands (empty), not leave interior platforms"
17188 );
17189 assert!(state.z_bands_outdoor_backup.is_none());
17190 assert!(state.interior_map.is_none());
17191 }
17192
17193 #[test]
17194 fn resource_node_route_label_prefers_friendly_label_with_suffix() {
17195 let node = ResourceNodeView {
17196 id: "crop-carrot-1_copy10".into(),
17197 label: "crop-carrot-1_copy10".into(),
17198 x: 0.0,
17199 y: 0.0,
17200 z: 0.0,
17201 item_template: "carrot".into(),
17202 state: ResourceNodeState::Available,
17203 blocking: false,
17204 blocking_radius_m: 0.5,
17205 harvest_off: false,
17206 tile_id: None,
17207 yaw: 0.0,
17208 pitch: 0.0,
17209 roll: 0.0,
17210 draw_scale: 1.0,
17211 sprite_mode: None,
17212 growth_progress: None,
17213 presentation_state: None,
17214 channel_start_tick: None,
17215 channel_end_tick: None,
17216 harvest_drop_templates: vec![],
17217 };
17218 let label = super::resource_node_route_label(&node);
17219 assert!(label.starts_with("Carrot ("), "got {label}");
17220 assert!(label.ends_with(')'), "got {label}");
17221
17222 let mut named = node;
17223 named.label = "Sweet Pad".into();
17224 named.id = "crop-carrot-a3f2b1c0".into();
17225 assert_eq!(
17226 super::resource_node_route_label(&named),
17227 "Sweet Pad (b1c0)"
17228 );
17229 }
17230
17231 #[test]
17232 fn plot_public_label_uses_owner_zone_and_label() {
17233 let plot = flatland_protocol::PropertyPlotView {
17234 plot_id: uuid::Uuid::nil(),
17235 property_zone_id: "zone_a".into(),
17236 zone_label: Some("Starter Town East 1".into()),
17237 deed_instance_id: uuid::Uuid::nil(),
17238 x0: 0.0,
17239 y0: 0.0,
17240 x1: 4.0,
17241 y1: 4.0,
17242 upkeep_copper_per_day: 1,
17243 arrears_days: 0,
17244 is_mine: true,
17245 may_farm: true,
17246 purchase_basis_copper: 0,
17247 farm_public: false,
17248 public_tax_discount_bps: 0,
17249 farm_allow: vec![],
17250 owner_character_id: None,
17251 owner_label: Some("Madsin".into()),
17252 building_id: None,
17253 plot_code: "xyz1234a".into(),
17254 label: "Food Pad".into(),
17255 };
17256 assert_eq!(
17257 super::plot_public_label(&plot),
17258 "Madsin — Starter Town East 1 — Food Pad"
17259 );
17260 }
17261}