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 let sticky = state
990 .worker_error_display
991 .get(&w.instance_id)
992 .and_then(|s| s.shown(now))
993 .filter(|e| !worker_error_is_hud_noise(e));
994 let live = w.last_error.as_deref().filter(|e| {
995 !worker_error_is_transient(e) && !worker_error_is_hud_noise(e)
996 });
997 if let Some(err) = sticky.or(live) {
998 if let Some(hint) = w
999 .issue_hint
1000 .as_deref()
1001 .filter(|h| !h.is_empty())
1002 .or_else(|| worker_issue_fix_hint(err))
1003 {
1004 return Some(format!("Worker {}: {err} — {hint}", w.label));
1005 }
1006 return Some(format!("Worker {}: {err}", w.label));
1007 }
1008 if let Some(hint) = w.issue_hint.as_deref().filter(|h| !h.is_empty()) {
1010 return Some(format!("Worker {}: {hint}", w.label));
1011 }
1012 }
1013 None
1014}
1015
1016pub fn worker_issue_fix_hint(err: &str) -> Option<&'static str> {
1018 let e = err.to_ascii_lowercase();
1019 if e.contains("missing")
1020 || e.contains("container not found")
1021 || e.contains("lodging container not found")
1022 {
1023 return Some("edit route (e): replace the missing chest/bed");
1024 }
1025 if e.contains("stranded at interior") || e.contains("interior map coords") {
1026 return Some("recovered — continuing route");
1027 }
1028 if e.contains("stuck inside")
1029 || e.contains("sent outside")
1030 || e.contains("sent to door")
1031 || e.contains("left building")
1032 {
1033 return Some("auto-exit for outdoor work — restart after update if it still loops");
1034 }
1035 if e.contains("collapsed") || e.contains("need food") {
1036 return Some("stock lodging bed with food and drink");
1037 }
1038 if e.contains("overburdened") {
1039 return Some("add a deposit/sell stop, or empty their pack");
1040 }
1041 if e.contains("need a hoe") || e.contains("need a dibber") {
1042 return Some("give them the tool or withdraw it on the route");
1043 }
1044 None
1045}
1046
1047pub fn worker_error_is_transient(err: &str) -> bool {
1049 let e = err.to_ascii_lowercase();
1050 e.contains("continuing route")
1051 || e.contains("storage full")
1052 || e.starts_with("nothing to withdraw")
1053}
1054
1055pub fn worker_error_is_hud_noise(err: &str) -> bool {
1058 let e = err.to_ascii_lowercase();
1059 e.contains("returned to lodging after path")
1060 || e.contains("path failure")
1061 || e.contains("no path to")
1062 || e.contains("pathfinding")
1063 || e.contains("repathing")
1065 || e.contains("nudged clear")
1066 || e.contains("auto-recovery")
1068 || e.contains("stranded at interior map coords")
1069}
1070
1071#[derive(Debug, Clone)]
1073pub struct PendingWorkerJobAck {
1074 pub seq: u32,
1075 pub worker_instance_id: String,
1076 pub worker_label: String,
1077 pub idle: bool,
1078 pub stop_count: usize,
1079 pub prev_route: Option<flatland_protocol::WorkerRouteView>,
1080 pub prev_mode: flatland_protocol::WorkerModeView,
1081 pub prev_step_label: String,
1082 pub prev_last_error: Option<String>,
1083}
1084
1085fn push_inventory_rows(
1086 rows: &mut Vec<InventoryRow>,
1087 depth: usize,
1088 stack: &flatland_protocol::ItemStack,
1089 from: &flatland_protocol::InventoryLocation,
1090 from_parent_instance_id: Option<uuid::Uuid>,
1091 section: InventorySection,
1092) {
1093 push_inventory_rows_filtered(
1094 rows,
1095 depth,
1096 stack,
1097 from,
1098 from_parent_instance_id,
1099 section,
1100 "",
1101 );
1102}
1103
1104fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
1105 if filter.is_empty() {
1106 return true;
1107 }
1108 let f = filter.to_ascii_lowercase();
1109 let name = stack
1110 .display_name
1111 .as_deref()
1112 .unwrap_or("")
1113 .to_ascii_lowercase();
1114 let tid = stack.template_id.to_ascii_lowercase();
1115 name.contains(&f)
1116 || tid.contains(&f)
1117 || stack
1118 .contents
1119 .iter()
1120 .any(|c| stack_matches_filter(c, filter))
1121}
1122
1123fn push_inventory_rows_filtered(
1124 rows: &mut Vec<InventoryRow>,
1125 depth: usize,
1126 stack: &flatland_protocol::ItemStack,
1127 from: &flatland_protocol::InventoryLocation,
1128 from_parent_instance_id: Option<uuid::Uuid>,
1129 section: InventorySection,
1130 filter: &str,
1131) {
1132 if !filter.is_empty() && !stack_matches_filter(stack, filter) {
1133 return;
1134 }
1135 let self_hit = filter.is_empty() || {
1136 let f = filter.to_ascii_lowercase();
1137 let name = stack
1138 .display_name
1139 .as_deref()
1140 .unwrap_or("")
1141 .to_ascii_lowercase();
1142 let tid = stack.template_id.to_ascii_lowercase();
1143 name.contains(&f) || tid.contains(&f)
1144 };
1145 rows.push(InventoryRow {
1146 depth,
1147 stack: stack.clone(),
1148 from: from.clone(),
1149 from_parent_instance_id,
1150 is_equip_shell: false,
1151 is_chest_shell: false,
1152 section,
1153 });
1154 for child in &stack.contents {
1155 if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
1156 push_inventory_rows_filtered(
1157 rows,
1158 depth + 1,
1159 child,
1160 from,
1161 stack.item_instance_id,
1162 section,
1163 if self_hit { "" } else { filter },
1164 );
1165 }
1166 }
1167}
1168
1169#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1170pub enum ShopTab {
1171 #[default]
1172 Buy,
1173 Sell,
1174}
1175
1176#[derive(Debug, Clone)]
1177pub struct NpcChatState {
1178 pub npc_id: String,
1179 pub npc_label: String,
1180 pub lines: Vec<String>,
1181 pub input: String,
1182 pub pending: bool,
1183 pub talk_depth: flatland_protocol::NpcTalkDepth,
1184 pub trade_allowed: bool,
1185 pub banner: Option<String>,
1186}
1187
1188impl Default for NpcChatState {
1189 fn default() -> Self {
1190 Self {
1191 npc_id: String::new(),
1192 npc_label: String::new(),
1193 lines: Vec::new(),
1194 input: String::new(),
1195 pending: false,
1196 talk_depth: flatland_protocol::NpcTalkDepth::Full,
1197 trade_allowed: true,
1198 banner: None,
1199 }
1200 }
1201}
1202
1203#[derive(Debug, Clone)]
1204pub struct GameState {
1205 pub session_id: SessionId,
1206 pub entity_id: EntityId,
1207 pub character_id: Option<uuid::Uuid>,
1209 pub tick: Tick,
1210 pub chunk_rev: u64,
1211 pub content_rev: u64,
1212 pub publish_rev: u64,
1213 pub entities: Vec<EntityState>,
1214 pub player: Option<EntityState>,
1215 pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1216 pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1217 pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1218 pub buildings: Vec<BuildingView>,
1219 pub doors: Vec<DoorView>,
1220 pub interior_map: Option<InteriorMapView>,
1221 pub npcs: Vec<NpcView>,
1222 pub blueprints: Vec<BlueprintView>,
1223 pub building_materials: Vec<flatland_protocol::BuildingMaterialView>,
1225 pub world_x0: f32,
1227 pub world_y0: f32,
1228 pub world_width_m: f32,
1229 pub world_height_m: f32,
1230 pub terrain_zones: Vec<TerrainZoneView>,
1231 pub z_platforms: Vec<ZPlatformView>,
1232 pub z_transitions: Vec<ZTransitionView>,
1233 #[doc(hidden)]
1236 pub z_bands_outdoor_backup: Option<(Vec<ZPlatformView>, Vec<ZTransitionView>)>,
1237 pub world_clock: flatland_protocol::WorldClock,
1238 pub inventory: std::collections::HashMap<String, u32>,
1239 pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1240 pub logs: VecDeque<String>,
1241 pub intents_sent: u64,
1242 pub ticks_received: u64,
1243 pub connected: bool,
1244 pub disconnect_reason: Option<String>,
1245 pub show_stats: bool,
1246 pub hud_log_hidden: bool,
1248 pub show_equip_menu: bool,
1249 pub equip_menu_index: usize,
1250 pub show_craft_menu: bool,
1251 pub craft_menu_index: usize,
1252 pub craft_batch_quantity: u32,
1254 pub show_plot_build_menu: bool,
1256 pub plot_build_focus_wall: bool,
1258 pub plot_build_wall_index: usize,
1259 pub plot_build_roof_index: usize,
1260 pub show_shop_menu: bool,
1261 pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1262 pub bank_panel: Option<flatland_protocol::BankPanel>,
1263 pub bank_menu_index: usize,
1264 pub bank_ui_mode: BankUiMode,
1265 pub storage_panel: Option<flatland_protocol::StoragePanel>,
1266 pub market_panel: Option<flatland_protocol::MarketPanel>,
1267 pub market_menu_index: usize,
1269 pub market_filter: String,
1271 pub market_filter_focused: bool,
1272 pub market_category_filter: Option<&'static str>,
1274 pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
1276 pub market_ui_mode: MarketUiMode,
1277 pub storage_menu_index: usize,
1278 pub storage_ui_mode: StorageUiMode,
1279 pub shop_tab: ShopTab,
1280 pub shop_menu_index: usize,
1281 pub shop_quantity: u32,
1282 pub shop_trade_log: VecDeque<String>,
1284 pub show_npc_verb_menu: bool,
1285 pub npc_verb_target: Option<String>,
1286 pub npc_verb_index: usize,
1287 pub player_verbs: crate::social::PlayerVerbState,
1289 pub social_chat: crate::social::SocialChatState,
1290 pub trade_ui: crate::social::TradeUiState,
1291 pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1292 pub show_npc_chat: bool,
1293 pub npc_chat: Option<NpcChatState>,
1294 pub show_inventory_menu: bool,
1295 pub inventory_menu_index: usize,
1296 pub inventory_tab: InventoryTab,
1297 pub inventory_filter: String,
1298 pub inventory_filter_focused: bool,
1299 pub show_move_picker: bool,
1300 pub move_picker_index: usize,
1301 pub move_picker: Option<MovePicker>,
1302 pub show_grant_picker: bool,
1303 pub grant_picker_index: usize,
1304 pub grant_picker: Option<GrantTargetPicker>,
1305 pub show_destroy_picker: bool,
1306 pub destroy_confirm_pending: bool,
1307 pub destroy_picker: Option<DestroyPicker>,
1308 pub show_rename_prompt: bool,
1310 pub rename_plot_id: Option<uuid::Uuid>,
1312 pub highlighted_plot_id: Option<uuid::Uuid>,
1314 pub show_worker_rename: bool,
1316 pub rename_buffer: String,
1317 pub combat_target: Option<EntityId>,
1319 pub combat_target_label: Option<String>,
1320 pub ground_target: Option<(f32, f32, f32)>,
1323 pub combat_fx: Vec<flatland_protocol::CombatFx>,
1325 pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1327 pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1329 pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1331 pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1333 pub terrain_kind_nav: Vec<flatland_protocol::TerrainKindNavView>,
1335 pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1337 pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1339 pub claim_mode: Option<ClaimModeState>,
1341 pub relocate_mode: Option<RelocateModeState>,
1343 pub sell_plot_confirm: Option<uuid::Uuid>,
1345 pub sell_plot_armed_at: Option<Instant>,
1347 pub show_plant_menu: bool,
1349 pub plant_menu_index: usize,
1350 pub show_farm_access: bool,
1352 pub farm_access_name_draft: String,
1354 pub farm_access_discount_bps: u32,
1356 pub farm_access_index: usize,
1358 pub plant_quantity: u32,
1359 pub in_combat: bool,
1360 pub auto_attack: bool,
1361 pub combat_has_los: bool,
1362 pub attack_cd_ticks: u64,
1363 pub gcd_ticks: u64,
1364 pub weapon_ability_id: String,
1365 pub mainhand_template_id: Option<String>,
1366 pub mainhand_label: Option<String>,
1367 pub mainhand_instance_id: Option<uuid::Uuid>,
1368 pub offhand_template_id: Option<String>,
1369 pub offhand_label: Option<String>,
1370 pub offhand_instance_id: Option<uuid::Uuid>,
1371 pub mainhand_hand_slots: u8,
1372 pub defense: Option<flatland_protocol::DefenseHud>,
1373 pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1375 pub carry_mass: f32,
1376 pub carry_mass_max: f32,
1377 pub encumbrance: flatland_protocol::EncumbranceState,
1378 pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1380 pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1382 pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1384 pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1386 pub combat_target_detail: Option<CombatTargetHud>,
1387 pub cast_progress: Option<CastProgressHud>,
1388 pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1390 pub plot_build_offer: Option<flatland_protocol::PlotBuildOfferHud>,
1392 pub ability_cooldowns: Vec<AbilityCooldownHud>,
1393 pub blocking_active: bool,
1394 pub max_target_slots: u8,
1395 pub combat_slots: Vec<CombatSlotHud>,
1396 pub rotation_presets: Vec<RotationPreset>,
1397 pub known_abilities: Vec<String>,
1399 pub ability_meta: std::collections::HashMap<String, flatland_protocol::AbilityMetaHud>,
1401 pub ability_mastery: std::collections::HashMap<String, flatland_protocol::AbilityMasteryHud>,
1403 pub hotbar: Vec<Option<String>>,
1405 pub max_abilities_per_rotation: u8,
1407 pub show_loadout_menu: bool,
1408 pub show_keychain_menu: bool,
1409 pub keychain_menu_index: usize,
1410 pub show_rotation_editor: bool,
1411 pub loadout_menu_index: usize,
1413 pub loadout_hotbar_slot: u8,
1415 pub loadout_ability_index: usize,
1417 pub loadout_focus_presets: bool,
1419 pub rotation_editor: RotationEditorState,
1420 pub harvest_in_progress: bool,
1422 pub harvest_started_at: Option<Instant>,
1424 pub pending_craft_ack: Option<(u32, String, u32)>,
1426 pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1427 pub interactables: Vec<flatland_protocol::InteractableView>,
1428 pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1429 pub career: Option<flatland_protocol::PlayerCareerView>,
1430 pub character_sheet_tab: CharacterSheetTab,
1431 pub ledger_period: LedgerPeriod,
1432 pub show_quest_offer: bool,
1433 pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
1434 pub show_quest_menu: bool,
1435 pub quest_menu_index: usize,
1436 pub quest_withdraw_confirm: bool,
1437 pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1438 pub show_workers_menu: bool,
1439 pub workers_menu_index: usize,
1440 pub workers_menu_compact: bool,
1442 pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1445 pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1447 pub show_worker_give_picker: bool,
1449 pub worker_give_picker_index: usize,
1450 pub worker_give_picker: Option<WorkerGivePicker>,
1451 pub show_worker_give_target_picker: bool,
1453 pub worker_give_target_picker_index: usize,
1454 pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1455 pub show_worker_take_picker: bool,
1457 pub worker_take_picker_index: usize,
1458 pub worker_take_picker: Option<WorkerTakePicker>,
1459 pub show_worker_teach_picker: bool,
1461 pub worker_teach_picker_index: usize,
1462 pub worker_teach_picker: Option<WorkerTeachPicker>,
1463 pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1465 pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1467 pub attending_worker_instance_id: Option<String>,
1469 pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1471}
1472
1473impl GameState {
1474 pub fn push_log(&mut self, line: impl Into<String>) {
1475 self.logs.push_back(line.into());
1476 while self.logs.len() > MAX_LOG_LINES {
1477 self.logs.pop_front();
1478 }
1479 }
1480
1481 pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1482 self.shop_trade_log.push_back(line.into());
1483 while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1484 self.shop_trade_log.pop_front();
1485 }
1486 }
1487
1488 pub fn clear_shop_trade_log(&mut self) {
1489 self.shop_trade_log.clear();
1490 }
1491
1492 fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1493 if !self.show_shop_menu {
1494 return;
1495 }
1496 let msg = notice.message.trim();
1497 if msg.is_empty() {
1498 return;
1499 }
1500 if notice.coins_delta != 0
1501 || msg.starts_with("Bought ")
1502 || msg.starts_with("Sold ")
1503 || msg.contains("taught you how to craft")
1504 || msg.starts_with("need ")
1505 {
1506 self.push_shop_trade_log(msg);
1507 }
1508 }
1509
1510 pub fn is_alive(&self) -> bool {
1511 self.player
1512 .as_ref()
1513 .and_then(|p| p.vitals)
1514 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1515 .unwrap_or(true)
1516 }
1517
1518 pub fn npc_verb_options(&self) -> Vec<&'static str> {
1520 let Some(ref id) = self.npc_verb_target else {
1521 return vec![];
1522 };
1523 let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
1524 return vec!["Talk"];
1525 };
1526 let role = npc.role.as_str();
1527 if Self::npc_role_is_bank(role) {
1528 return vec!["Bank", "Talk"];
1529 }
1530 if Self::npc_role_is_storage(role) {
1531 return vec!["Storage", "Talk"];
1532 }
1533 if Self::npc_role_is_market(role) {
1534 return vec!["Market", "Talk"];
1535 }
1536 if npc.can_trade || Self::npc_role_can_trade(role) {
1537 vec!["Talk", "Trade"]
1538 } else {
1539 vec!["Talk"]
1540 }
1541 }
1542
1543 fn npc_role_can_trade(role: &str) -> bool {
1544 matches!(role, "broker" | "cook" | "farmer" | "merchant")
1545 }
1546
1547 fn npc_role_is_bank(role: &str) -> bool {
1548 role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
1549 }
1550
1551 fn npc_role_is_storage(role: &str) -> bool {
1552 role.eq_ignore_ascii_case("storage_manager")
1553 }
1554
1555 fn npc_role_is_market(role: &str) -> bool {
1556 role.eq_ignore_ascii_case("market_clerk")
1557 }
1558
1559 pub fn bank_menu_options(&self) -> Vec<&'static str> {
1560 vec![
1561 "Deposit…",
1562 "Withdraw…",
1563 "Deposit all",
1564 "Withdraw all",
1565 "Transfer…",
1566 ]
1567 }
1568
1569 pub fn storage_menu_options(&self) -> Vec<String> {
1570 let mut opts = vec!["Store…".into(), "Take…".into()];
1571 if let Some(panel) = &self.storage_panel {
1572 for dest in &panel.ship_destinations {
1573 opts.push(format!(
1574 "Ship → {} ({} cp / {} ticks)",
1575 dest.label, dest.fee_copper, dest.travel_ticks
1576 ));
1577 }
1578 }
1579 opts
1580 }
1581
1582 pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
1586 let equipped = self.hand_equipped_instance_ids();
1587 self.person_rows()
1588 .into_iter()
1589 .filter(|r| r.depth == 0)
1590 .filter_map(|r| {
1591 let id = r.stack.item_instance_id?;
1592 if equipped.contains(&id) {
1593 return None;
1594 }
1595 Some(StoragePickOption {
1596 item_instance_id: id,
1597 label: storage_stack_label(&r.stack),
1598 quantity: r.stack.quantity,
1599 category: r.stack.category.clone().unwrap_or_default(),
1600 })
1601 })
1602 .collect()
1603 }
1604
1605 pub fn hand_equipped_instance_ids(&self) -> std::collections::HashSet<uuid::Uuid> {
1607 let mut ids = std::collections::HashSet::new();
1608 if let Some(id) = self.mainhand_instance_id {
1609 ids.insert(id);
1610 } else if let Some(tid) = &self.mainhand_template_id {
1611 if let Some(id) = self
1612 .inventory_stacks
1613 .iter()
1614 .find(|s| &s.template_id == tid)
1615 .and_then(|s| s.item_instance_id)
1616 {
1617 ids.insert(id);
1618 }
1619 }
1620 if let Some(id) = self.offhand_instance_id {
1621 ids.insert(id);
1622 } else if let Some(tid) = &self.offhand_template_id {
1623 if let Some(id) = self
1624 .inventory_stacks
1625 .iter()
1626 .find(|s| {
1627 &s.template_id == tid
1628 && s.item_instance_id
1629 .is_some_and(|iid| !ids.contains(&iid))
1630 })
1631 .and_then(|s| s.item_instance_id)
1632 {
1633 ids.insert(id);
1634 }
1635 }
1636 ids
1637 }
1638
1639 pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
1641 let Some(panel) = &self.storage_panel else {
1642 return Vec::new();
1643 };
1644 panel
1645 .contents
1646 .iter()
1647 .filter_map(|s| {
1648 let id = s.item_instance_id?;
1649 Some(StoragePickOption {
1650 item_instance_id: id,
1651 label: storage_stack_label(s),
1652 quantity: s.quantity,
1653 category: s.category.clone().unwrap_or_default(),
1654 })
1655 })
1656 .collect()
1657 }
1658
1659 pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
1661 let mut opts = Vec::new();
1662 if !self
1663 .market_list_item_options(&MarketListSourceKind::Person)
1664 .is_empty()
1665 {
1666 opts.push((MarketListSourceKind::Person, "On person".into()));
1667 }
1668 if let Some(panel) = &self.market_panel {
1669 for vault in &panel.list_vaults {
1670 let source = MarketListSourceKind::TownStorage {
1671 building_id: vault.building_id.clone(),
1672 };
1673 if self.market_list_item_options(&source).is_empty() {
1674 continue;
1675 }
1676 let label = if vault.building_label.is_empty() {
1677 format!("Town storage ({})", vault.building_id)
1678 } else {
1679 format!("Town storage — {}", vault.building_label)
1680 };
1681 opts.push((source, label));
1682 }
1683 }
1684 opts
1685 }
1686
1687 pub fn market_list_item_options(
1689 &self,
1690 source: &MarketListSourceKind,
1691 ) -> Vec<StoragePickOption> {
1692 let filter = self.market_filter.as_str();
1693 let cat_filter = self.market_category_filter;
1694 let mut opts: Vec<StoragePickOption> = match source {
1695 MarketListSourceKind::Person => {
1696 let equipped = self.hand_equipped_instance_ids();
1697 self.person_rows()
1698 .into_iter()
1699 .filter(|r| r.depth == 0)
1700 .filter(|r| self.stack_is_market_listable(&r.stack))
1701 .filter_map(|r| {
1702 let id = r.stack.item_instance_id?;
1703 if equipped.contains(&id) {
1704 return None;
1705 }
1706 Some(StoragePickOption {
1707 item_instance_id: id,
1708 label: storage_stack_label(&r.stack),
1709 quantity: r.stack.quantity,
1710 category: r
1711 .stack
1712 .category
1713 .clone()
1714 .or_else(|| {
1715 self.inventory_item_category(&r.stack.template_id)
1716 .map(str::to_string)
1717 })
1718 .unwrap_or_default(),
1719 })
1720 })
1721 .collect()
1722 }
1723 MarketListSourceKind::TownStorage { building_id } => {
1724 let Some(panel) = &self.market_panel else {
1725 return Vec::new();
1726 };
1727 let Some(vault) = panel
1728 .list_vaults
1729 .iter()
1730 .find(|v| &v.building_id == building_id)
1731 else {
1732 return Vec::new();
1733 };
1734 vault
1735 .contents
1736 .iter()
1737 .filter(|s| self.stack_is_market_listable(s))
1738 .filter_map(|s| {
1739 let id = s.item_instance_id?;
1740 Some(StoragePickOption {
1741 item_instance_id: id,
1742 label: storage_stack_label(s),
1743 quantity: s.quantity,
1744 category: s
1745 .category
1746 .clone()
1747 .or_else(|| {
1748 self.inventory_item_category(&s.template_id)
1749 .map(str::to_string)
1750 })
1751 .unwrap_or_default(),
1752 })
1753 })
1754 .collect()
1755 }
1756 };
1757 opts.retain(|o| {
1758 if !list_label_matches(&o.label, filter) {
1759 return false;
1760 }
1761 if let Some(group) = cat_filter {
1762 inventory_category_group(&o.category).0 == group
1763 } else {
1764 true
1765 }
1766 });
1767 opts
1768 }
1769
1770 fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
1771 if crate::currency::is_currency(&stack.template_id) {
1772 return false;
1773 }
1774 if let Some(flag) = stack.listable {
1775 return flag;
1776 }
1777 if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
1778 return hint.listable;
1779 }
1780 let cat = stack
1781 .category
1782 .as_deref()
1783 .or_else(|| self.inventory_item_category(&stack.template_id))
1784 .unwrap_or("");
1785 category_default_listable(cat)
1786 }
1787
1788 pub fn market_available_category_groups(&self) -> Vec<&'static str> {
1790 let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
1791 match &self.market_ui_mode {
1792 MarketUiMode::ListPick { source, .. } => {
1793 let raw: Vec<_> = match source {
1794 MarketListSourceKind::Person => self
1795 .person_rows()
1796 .into_iter()
1797 .filter(|r| r.depth == 0)
1798 .filter(|r| self.stack_is_market_listable(&r.stack))
1799 .filter(|r| list_label_matches(&storage_stack_label(&r.stack), &self.market_filter))
1800 .map(|r| {
1801 r.stack
1802 .category
1803 .clone()
1804 .or_else(|| {
1805 self.inventory_item_category(&r.stack.template_id)
1806 .map(str::to_string)
1807 })
1808 .unwrap_or_default()
1809 })
1810 .collect(),
1811 MarketListSourceKind::TownStorage { building_id } => self
1812 .market_panel
1813 .as_ref()
1814 .and_then(|p| {
1815 p.list_vaults
1816 .iter()
1817 .find(|v| &v.building_id == building_id)
1818 })
1819 .map(|vault| {
1820 vault
1821 .contents
1822 .iter()
1823 .filter(|s| self.stack_is_market_listable(s))
1824 .filter(|s| {
1825 list_label_matches(&storage_stack_label(s), &self.market_filter)
1826 })
1827 .map(|s| {
1828 s.category
1829 .clone()
1830 .or_else(|| {
1831 self.inventory_item_category(&s.template_id)
1832 .map(str::to_string)
1833 })
1834 .unwrap_or_default()
1835 })
1836 .collect::<Vec<_>>()
1837 })
1838 .unwrap_or_default(),
1839 };
1840 for category in raw {
1841 let (label, ord) = inventory_category_group(&category);
1842 seen.insert(ord, label);
1843 }
1844 }
1845 _ => {
1846 if let Some(panel) = &self.market_panel {
1847 for listing in &panel.listings {
1848 if !list_label_matches(&listing.display_name, &self.market_filter)
1849 && !list_label_matches(&listing.seller_label, &self.market_filter)
1850 {
1851 continue;
1852 }
1853 let (label, ord) = inventory_category_group(&listing.category);
1854 seen.insert(ord, label);
1855 }
1856 }
1857 }
1858 }
1859 seen.into_values().collect()
1860 }
1861
1862 pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
1864 let Some(panel) = &self.market_panel else {
1865 return Vec::new();
1866 };
1867 let filter = self.market_filter.as_str();
1868 let cat_filter = self.market_category_filter;
1869 panel
1870 .listings
1871 .iter()
1872 .enumerate()
1873 .filter(|(_, listing)| {
1874 if !list_label_matches(&listing.display_name, filter)
1875 && !list_label_matches(&listing.seller_label, filter)
1876 && !list_label_matches(&listing.template_id, filter)
1877 {
1878 return false;
1879 }
1880 if let Some(group) = cat_filter {
1881 inventory_category_group(&listing.category).0 == group
1882 } else {
1883 true
1884 }
1885 })
1886 .map(|(i, _)| i)
1887 .collect()
1888 }
1889
1890 pub fn clear_harvest_state(&mut self) {
1891 self.harvest_in_progress = false;
1892 self.harvest_started_at = None;
1893 }
1894
1895 fn harvest_state_stale(&self) -> bool {
1896 match self.harvest_started_at {
1897 Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
1898 None => self.harvest_in_progress,
1899 }
1900 }
1901
1902 pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
1903 self.player.as_ref().and_then(|p| p.vitals)
1904 }
1905
1906 pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
1907 let materials_ok = blueprint.inputs.iter().all(|input| {
1908 self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
1909 });
1910 let tools_ok = blueprint
1911 .required_tools
1912 .iter()
1913 .all(|tool| self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1);
1914 let station_ok = match blueprint.station.as_deref() {
1915 None | Some("hand") => true,
1916 Some(tag) => self.player_at_station_tag(tag),
1917 };
1918 materials_ok && tools_ok && station_ok
1919 }
1920
1921 pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
1922 if !self.can_craft_blueprint(blueprint) {
1923 return 0;
1924 }
1925 let mut limit = u32::MAX;
1926 for input in &blueprint.inputs {
1927 if input.quantity == 0 {
1928 continue;
1929 }
1930 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
1931 limit = limit.min(have / input.quantity);
1932 }
1933 for tool in &blueprint.required_tools {
1934 if tool.consumed {
1935 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
1936 limit = limit.min(have);
1937 }
1938 }
1939 let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
1940 if CRAFT_STAMINA_COST > 0.0 {
1941 limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
1942 }
1943 limit
1944 }
1945
1946 pub fn clamp_craft_batch_quantity(&mut self) {
1947 let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
1948 self.craft_batch_quantity = 1;
1949 return;
1950 };
1951 let max = self.max_craft_batches(bp).max(1);
1952 self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
1953 }
1954
1955 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
1956 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1957 return;
1958 };
1959 let max = self.max_craft_batches(&bp).max(1);
1960 let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
1961 self.craft_batch_quantity = next as u32;
1962 }
1963
1964 pub fn craft_batch_set_max(&mut self) {
1965 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1966 return;
1967 };
1968 let max = self.max_craft_batches(&bp);
1969 self.craft_batch_quantity = if max == 0 { 1 } else { max };
1970 }
1971
1972 pub fn craft_batch_set_min(&mut self) {
1973 self.craft_batch_quantity = 1;
1974 }
1975
1976 pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
1977 let preserve_ui = self.show_shop_menu;
1978 let tab = self.shop_tab;
1979 let index = self.shop_menu_index;
1980 let qty = self.shop_quantity;
1981
1982 self.show_shop_menu = true;
1983 self.bank_panel = None;
1984 self.show_craft_menu = false;
1985 self.show_inventory_menu = false;
1986 self.show_stats = false;
1987 if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
1988 self.npc_verb_target = Some(catalog.npc_id.clone());
1989 }
1990 self.shop_catalog = Some(catalog);
1991
1992 if preserve_ui {
1993 self.shop_tab = tab;
1994 self.shop_menu_index = index;
1995 self.shop_quantity = qty;
1996 } else {
1997 self.shop_tab = ShopTab::Buy;
1998 self.shop_menu_index = 0;
1999 self.shop_quantity = 1;
2000 self.clear_shop_trade_log();
2001 }
2002 self.show_npc_verb_menu = false;
2003 self.clamp_shop_selection();
2004 }
2005
2006 pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
2007 let same_teller = self
2008 .bank_panel
2009 .as_ref()
2010 .is_some_and(|p| p.npc_id == panel.npc_id);
2011 self.bank_panel = Some(panel);
2012 self.storage_panel = None;
2013 self.market_panel = None;
2014 self.shop_catalog = None;
2015 self.show_shop_menu = false;
2016 self.show_craft_menu = false;
2017 self.show_inventory_menu = false;
2018 self.show_stats = false;
2019 self.show_npc_verb_menu = false;
2020 self.show_npc_chat = false;
2021 self.npc_chat = None;
2022 if !same_teller {
2023 self.bank_menu_index = 0;
2024 self.bank_ui_mode = BankUiMode::Menu;
2025 }
2026 if let Some(panel) = &self.bank_panel {
2027 if self.npc_verb_target.is_none() {
2028 self.npc_verb_target = Some(panel.npc_id.clone());
2029 }
2030 }
2031 }
2032
2033 pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
2034 let same_manager = self
2035 .storage_panel
2036 .as_ref()
2037 .is_some_and(|p| p.npc_id == panel.npc_id);
2038 self.storage_panel = Some(panel);
2039 self.bank_panel = None;
2040 self.market_panel = None;
2041 self.bank_ui_mode = BankUiMode::Menu;
2042 self.shop_catalog = None;
2043 self.show_shop_menu = false;
2044 self.show_craft_menu = false;
2045 self.show_inventory_menu = false;
2046 self.show_stats = false;
2047 self.show_npc_verb_menu = false;
2048 self.show_npc_chat = false;
2049 self.npc_chat = None;
2050 if !same_manager {
2051 self.storage_menu_index = 0;
2052 self.storage_ui_mode = StorageUiMode::Menu;
2053 } else {
2054 self.clamp_storage_pick_index();
2055 }
2056 if let Some(panel) = &self.storage_panel {
2057 if self.npc_verb_target.is_none() {
2058 self.npc_verb_target = Some(panel.npc_id.clone());
2059 }
2060 }
2061 }
2062
2063 pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
2064 self.market_panel = Some(panel);
2065 self.bank_panel = None;
2066 self.storage_panel = None;
2067 self.shop_catalog = None;
2068 self.show_shop_menu = false;
2069 self.show_craft_menu = false;
2070 self.show_inventory_menu = false;
2071 self.show_stats = false;
2072 self.show_npc_verb_menu = false;
2073 self.show_npc_chat = false;
2074 self.npc_chat = None;
2075 self.market_menu_index = 0;
2076 self.market_buy_confirm = None;
2077 self.market_ui_mode = MarketUiMode::Browse;
2078 self.market_filter.clear();
2079 self.market_filter_focused = false;
2080 self.market_category_filter = None;
2081 if let Some(panel) = &self.market_panel {
2082 if self.npc_verb_target.is_none() {
2083 self.npc_verb_target = Some(panel.npc_id.clone());
2084 }
2085 }
2086 }
2087
2088 pub fn clear_market_panel(&mut self) {
2089 self.market_panel = None;
2090 self.market_menu_index = 0;
2091 self.market_buy_confirm = None;
2092 self.market_ui_mode = MarketUiMode::Browse;
2093 self.market_filter.clear();
2094 self.market_filter_focused = false;
2095 self.market_category_filter = None;
2096 }
2097
2098 pub fn clear_bank_panel(&mut self) {
2099 self.bank_panel = None;
2100 self.bank_menu_index = 0;
2101 self.bank_ui_mode = BankUiMode::Menu;
2102 }
2103
2104 pub fn clear_storage_panel(&mut self) {
2105 self.storage_panel = None;
2106 self.storage_menu_index = 0;
2107 self.storage_ui_mode = StorageUiMode::Menu;
2108 }
2109
2110 fn clamp_storage_pick_index(&mut self) {
2111 match &self.storage_ui_mode {
2112 StorageUiMode::StorePick { index } => {
2113 let n = self.storage_store_options().len();
2114 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2115 self.storage_ui_mode = StorageUiMode::StorePick { index: next };
2116 }
2117 StorageUiMode::TakePick { index } => {
2118 let n = self.storage_vault_options().len();
2119 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2120 self.storage_ui_mode = StorageUiMode::TakePick { index: next };
2121 }
2122 StorageUiMode::ShipPick {
2123 dest_building_id,
2124 dest_label,
2125 index,
2126 } => {
2127 let n = self.storage_vault_options().len();
2128 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2129 self.storage_ui_mode = StorageUiMode::ShipPick {
2130 dest_building_id: dest_building_id.clone(),
2131 dest_label: dest_label.clone(),
2132 index: next,
2133 };
2134 }
2135 StorageUiMode::Menu
2136 | StorageUiMode::StoreAmount { .. }
2137 | StorageUiMode::TakeAmount { .. }
2138 | StorageUiMode::ShipAmount { .. } => {}
2139 }
2140 }
2141
2142 pub fn shop_list_len(&self) -> usize {
2143 let Some(catalog) = &self.shop_catalog else {
2144 return 0;
2145 };
2146 match self.shop_tab {
2147 ShopTab::Buy => catalog.sells.len(),
2148 ShopTab::Sell => catalog.buys.len(),
2149 }
2150 }
2151
2152 pub fn shop_menu_move(&mut self, delta: i32) {
2153 let n = self.shop_list_len();
2154 if n == 0 {
2155 return;
2156 }
2157 let idx = self.shop_menu_index as i32;
2158 let next = (idx + delta).rem_euclid(n as i32);
2159 self.shop_menu_index = next as usize;
2160 self.clamp_shop_quantity();
2161 }
2162
2163 pub fn shop_quantity_adjust(&mut self, delta: i32) {
2164 let max = self.shop_quantity_max();
2165 if max == 0 {
2166 self.shop_quantity = 0;
2167 return;
2168 }
2169 let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
2170 self.shop_quantity = next as u32;
2171 }
2172
2173 pub(crate) fn clamp_shop_selection(&mut self) {
2174 let n = self.shop_list_len();
2175 if n == 0 {
2176 self.shop_menu_index = 0;
2177 } else {
2178 self.shop_menu_index = self.shop_menu_index.min(n - 1);
2179 }
2180 self.clamp_shop_quantity();
2181 }
2182
2183 fn shop_quantity_max(&self) -> u32 {
2184 let Some(catalog) = &self.shop_catalog else {
2185 return 1;
2186 };
2187 match self.shop_tab {
2188 ShopTab::Buy => {
2189 if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
2190 if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
2191 return 1;
2192 }
2193 }
2194 99
2195 }
2196 ShopTab::Sell => catalog
2197 .buys
2198 .get(self.shop_menu_index)
2199 .map(|l| l.quantity)
2200 .unwrap_or(0),
2201 }
2202 }
2203
2204 pub fn shop_quantity_set_max(&mut self) {
2205 self.shop_quantity = self.shop_quantity_max();
2206 }
2207
2208 pub fn shop_quantity_set_min(&mut self) {
2209 let max = self.shop_quantity_max();
2210 self.shop_quantity = if max == 0 { 0 } else { 1 };
2211 }
2212
2213 fn clamp_shop_quantity(&mut self) {
2214 let max = self.shop_quantity_max();
2215 if max == 0 {
2216 self.shop_quantity = 0;
2217 } else {
2218 self.shop_quantity = self.shop_quantity.max(1).min(max);
2219 }
2220 }
2221
2222 pub fn player_at_station_tag(&self, tag: &str) -> bool {
2223 let Some(id) = self.effective_inside_building() else {
2224 return false;
2225 };
2226 self.buildings
2227 .iter()
2228 .find(|b| b.id == id)
2229 .is_some_and(|b| b.tags.iter().any(|t| t == tag))
2230 }
2231
2232 pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
2234 if self.can_craft_blueprint(blueprint) {
2235 return None;
2236 }
2237 let mut missing = Vec::new();
2238 for input in &blueprint.inputs {
2239 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2240 if have < input.quantity {
2241 let name = self.blueprint_ingredient_label(input);
2242 missing.push(format!("{}×{} (have {have})", input.quantity, name));
2243 }
2244 }
2245 for tool in &blueprint.required_tools {
2246 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2247 if have < 1 {
2248 missing.push(format!("tool: {}", self.blueprint_tool_label(tool)));
2249 }
2250 }
2251 if let Some(station) = blueprint.station.as_deref() {
2252 if station != "hand" && !self.player_at_station_tag(station) {
2253 missing.push(format!("station: {station} (enter building)"));
2254 }
2255 }
2256 if missing.is_empty() {
2257 None
2258 } else {
2259 Some(missing.join(", "))
2260 }
2261 }
2262
2263 pub fn player_entity(&self) -> Option<&EntityState> {
2264 self.player
2265 .as_ref()
2266 .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
2267 }
2268
2269 pub fn apply_client_ui_prefs(&mut self) {
2271 let cfg = crate::client_config::ClientConfig::load();
2272 if let Some(hidden) = cfg.hud_log_hidden {
2273 self.hud_log_hidden = hidden;
2274 }
2275 if let Some(compact) = cfg.workers_menu_compact {
2276 self.workers_menu_compact = compact;
2277 }
2278 }
2279
2280 pub fn player_position(&self) -> (f32, f32) {
2281 let (x, y, _) = self.player_position_with_z();
2282 (x, y)
2283 }
2284
2285 pub fn player_position_with_z(&self) -> (f32, f32, f32) {
2286 if let Some(p) = self.player_entity() {
2287 (
2288 p.transform.position.x,
2289 p.transform.position.y,
2290 p.transform.position.z,
2291 )
2292 } else {
2293 (0.0, 0.0, 0.0)
2294 }
2295 }
2296
2297 pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
2298 let mut rows: Vec<(String, u32, String)> = self
2299 .inventory
2300 .iter()
2301 .filter(|(_, q)| **q > 0)
2302 .map(|(id, qty)| {
2303 let label = self
2304 .inventory_hints
2305 .get(id)
2306 .map(|h| h.display_name.clone())
2307 .unwrap_or_else(|| id.clone());
2308 (id.clone(), *qty, label)
2309 })
2310 .collect();
2311 rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
2312 rows
2313 }
2314
2315 pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
2316 self.inventory_hints
2317 .get(template_id)
2318 .map(|h| h.category.as_str())
2319 .filter(|c| !c.is_empty())
2320 }
2321
2322 pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
2323 stack
2324 .props
2325 .get("grants_item_status_effect")
2326 .map(|s| !s.is_empty())
2327 .unwrap_or(false)
2328 }
2329
2330 pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
2331 stack
2332 .props
2333 .get("grants_item_status_effect")
2334 .map(String::as_str)
2335 .filter(|s| !s.is_empty())
2336 }
2337
2338 pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
2339 stack
2340 .props
2341 .get("grants_item_status_mode")
2342 .map(String::as_str)
2343 .unwrap_or("on_hit")
2344 }
2345
2346 pub fn grant_target_options(
2348 &self,
2349 grant: &flatland_protocol::ItemStack,
2350 ) -> Vec<GrantTargetOption> {
2351 let mode = Self::grant_mode(grant);
2352 let grant_tags: Vec<&str> = grant
2353 .props
2354 .get("grants_item_status_tags")
2355 .map(|s| {
2356 s.split(',')
2357 .map(str::trim)
2358 .filter(|t| !t.is_empty())
2359 .collect()
2360 })
2361 .unwrap_or_default();
2362 let grant_id = grant.item_instance_id;
2363 let mut out = Vec::new();
2364 let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
2365 let Some(iid) = stack.item_instance_id else {
2366 return;
2367 };
2368 if Some(iid) == grant_id {
2369 return;
2370 }
2371 if stack.props.get("enchantable").map(String::as_str) == Some("0") {
2372 return;
2373 }
2374 if !grant_target_matches_mode(stack, mode) {
2375 return;
2376 }
2377 if !grant_tags_match(stack, &grant_tags) {
2378 return;
2379 }
2380 let name = stack
2381 .display_name
2382 .clone()
2383 .unwrap_or_else(|| stack.template_id.clone());
2384 let bindings = if stack.status_bindings.is_empty() {
2385 String::new()
2386 } else {
2387 format!(
2388 " · {}",
2389 stack
2390 .status_bindings
2391 .iter()
2392 .map(|b| b.effect_id.as_str())
2393 .collect::<Vec<_>>()
2394 .join(", ")
2395 )
2396 };
2397 out.push(GrantTargetOption {
2398 label: format!("{where_label}: {name}{bindings}"),
2399 target_instance_id: iid,
2400 });
2401 };
2402 fn walk(
2403 stacks: &[flatland_protocol::ItemStack],
2404 where_label: &str,
2405 push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
2406 ) {
2407 for s in stacks {
2408 push(s, where_label);
2409 if !s.contents.is_empty() {
2410 let nested = format!(
2411 "{where_label}/{}",
2412 s.display_name
2413 .as_deref()
2414 .unwrap_or(s.template_id.as_str())
2415 );
2416 walk(&s.contents, &nested, push);
2417 }
2418 }
2419 }
2420 walk(&self.inventory_stacks, "Bag", &mut push);
2421 for (slot, stack) in &self.worn {
2422 push(stack, body_slot_label(*slot));
2423 let nest = format!(
2424 "{}/{}",
2425 body_slot_label(*slot),
2426 stack
2427 .display_name
2428 .as_deref()
2429 .unwrap_or(stack.template_id.as_str())
2430 );
2431 walk(&stack.contents, &nest, &mut push);
2432 }
2433 out
2434 }
2435
2436 pub fn item_base_mass(&self, template_id: &str) -> f32 {
2437 self.inventory_hints
2438 .get(template_id)
2439 .and_then(|h| h.base_mass)
2440 .unwrap_or(0.5)
2441 }
2442
2443 pub fn item_base_volume(&self, template_id: &str) -> f32 {
2444 self.inventory_hints
2445 .get(template_id)
2446 .and_then(|h| h.base_volume)
2447 .unwrap_or(1.0)
2448 }
2449
2450 pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
2451 let unit = stack
2452 .base_mass
2453 .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
2454 unit * stack.quantity as f32
2455 }
2456
2457 fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
2458 let unit = stack.base_volume.unwrap_or(1.0);
2459 unit * stack.quantity as f32
2460 + stack
2461 .contents
2462 .iter()
2463 .map(Self::stack_tree_volume)
2464 .sum::<f32>()
2465 }
2466
2467 fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
2468 contents.iter().map(Self::stack_tree_volume).sum()
2469 }
2470
2471 fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
2472 self.inventory_hints
2473 .get(template_id)
2474 .and_then(|h| h.capacity_volume)
2475 .filter(|c| *c > 0.0)
2476 }
2477
2478 fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
2479 stack
2480 .capacity_volume
2481 .filter(|c| *c > 0.0)
2482 .or_else(|| self.template_capacity_volume(&stack.template_id))
2483 }
2484
2485 pub fn container_volume_label(&self, row: &InventoryRow) -> String {
2487 let Some((used, cap)) = self.container_volume_stats(row) else {
2488 return String::new();
2489 };
2490 let free = (cap - used).max(0.0);
2491 format!(" vol {used:.0}/{cap:.0} ({free:.0} free)")
2492 }
2493
2494 fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
2495 if row.is_chest_shell {
2496 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
2497 return None;
2498 };
2499 let chest = self
2500 .placed_containers
2501 .iter()
2502 .find(|c| c.id == *container_id)?;
2503 let cap = self
2504 .stack_capacity_volume(&row.stack)
2505 .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
2506 let used = if chest.accessible {
2507 Self::contents_used_volume(&chest.contents)
2508 } else {
2509 0.0
2510 };
2511 return Some((used, cap));
2512 }
2513
2514 let cap = self.stack_capacity_volume(&row.stack)?;
2515 let used = Self::contents_used_volume(&row.stack.contents);
2516 Some((used, cap))
2517 }
2518
2519 pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
2520 if row.is_chest_shell {
2521 return true;
2522 }
2523 if row.is_equip_shell {
2524 return self.inventory_item_category(&row.stack.template_id) == Some("container");
2525 }
2526 self.inventory_item_category(&row.stack.template_id) == Some("container")
2527 || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
2528 }
2529
2530 fn container_stack_for(
2531 &self,
2532 location: &flatland_protocol::InventoryLocation,
2533 parent_instance_id: Option<uuid::Uuid>,
2534 ) -> Option<flatland_protocol::ItemStack> {
2535 match location {
2536 flatland_protocol::InventoryLocation::Root => {
2537 let pid = parent_instance_id?;
2538 self.find_stack_by_instance(&self.inventory_stacks, pid)
2539 }
2540 flatland_protocol::InventoryLocation::Worn { slot } => {
2541 let worn = self.worn.get(slot)?;
2542 if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
2543 Some(worn.clone())
2544 } else {
2545 self.find_stack_by_instance(&worn.contents, parent_instance_id?)
2546 }
2547 }
2548 flatland_protocol::InventoryLocation::Placed { container_id } => {
2549 let chest = self
2550 .placed_containers
2551 .iter()
2552 .find(|c| c.id == *container_id)?;
2553 if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
2554 Some(flatland_protocol::ItemStack {
2555 template_id: chest.template_id.clone(),
2556 quantity: 1,
2557 item_instance_id: chest.item_instance_id,
2558 props: Default::default(),
2559 status_bindings: Vec::new(),
2560 contents: chest.contents.clone(),
2561 display_name: Some(chest.display_name.clone()),
2562 category: Some("container".into()),
2563 capacity_volume: self
2564 .inventory_hints
2565 .get(&chest.template_id)
2566 .and_then(|h| h.capacity_volume),
2567 worker_lodging_capacity: chest.worker_lodging_capacity,
2568 ..Default::default()
2569 })
2570 } else {
2571 self.find_stack_by_instance(&chest.contents, parent_instance_id?)
2572 }
2573 }
2574 flatland_protocol::InventoryLocation::Keychain => None,
2575 flatland_protocol::InventoryLocation::WhisperPouch => None,
2576 }
2577 }
2578
2579 fn find_stack_by_instance(
2580 &self,
2581 stacks: &[flatland_protocol::ItemStack],
2582 instance_id: uuid::Uuid,
2583 ) -> Option<flatland_protocol::ItemStack> {
2584 for stack in stacks {
2585 if stack.item_instance_id == Some(instance_id) {
2586 return Some(stack.clone());
2587 }
2588 if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
2589 return Some(found);
2590 }
2591 }
2592 None
2593 }
2594
2595 pub fn max_movable_to(
2597 &self,
2598 template_id: &str,
2599 stack_qty: u32,
2600 from: &flatland_protocol::InventoryLocation,
2601 to: &flatland_protocol::InventoryLocation,
2602 parent_instance_id: Option<uuid::Uuid>,
2603 ) -> u32 {
2604 let unit_vol = self.item_base_volume(template_id);
2605 let unit_mass = self.item_base_mass(template_id);
2606 let mut limit = stack_qty;
2607
2608 if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
2609 let cap = parent
2610 .capacity_volume
2611 .or_else(|| {
2612 self.inventory_hints
2613 .get(&parent.template_id)
2614 .and_then(|h| h.capacity_volume)
2615 })
2616 .unwrap_or(0.0);
2617 if cap > 0.0 && unit_vol > 0.0 {
2618 let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
2619 limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
2620 }
2621 }
2622
2623 let to_person = matches!(
2624 to,
2625 flatland_protocol::InventoryLocation::Root
2626 | flatland_protocol::InventoryLocation::Worn { .. }
2627 );
2628 let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
2629 if to_person && from_placed && unit_mass > 0.0 {
2630 let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
2631 if self.encumbrance == flatland_protocol::EncumbranceState::Over {
2632 limit = 0;
2633 } else {
2634 limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
2635 }
2636 }
2637
2638 limit.max(0).min(stack_qty)
2639 }
2640
2641 pub fn move_picker_max_at_selection(&self) -> u32 {
2642 let Some(picker) = &self.move_picker else {
2643 return 1;
2644 };
2645 let Some(opt) = picker.options.get(self.move_picker_index) else {
2646 return picker.stack_quantity;
2647 };
2648 match &opt.kind {
2649 MoveOptionKind::Cancel
2650 | MoveOptionKind::Drop
2651 | MoveOptionKind::Use
2652 | MoveOptionKind::GrantApply
2653 | MoveOptionKind::SellPlotToCrown { .. }
2654 | MoveOptionKind::PickupPlaced { .. }
2655 | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
2656 MoveOptionKind::Move {
2657 location,
2658 parent_instance_id,
2659 } => self.max_movable_to(
2660 &picker.template_id,
2661 picker.stack_quantity,
2662 &picker.from,
2663 location,
2664 *parent_instance_id,
2665 ),
2666 }
2667 }
2668
2669 pub fn clamp_move_picker_quantity(&mut self) {
2670 let max = self.move_picker_max_at_selection();
2671 if let Some(picker) = &mut self.move_picker {
2672 if max == 0 {
2673 picker.quantity = 1;
2674 } else {
2675 picker.quantity = picker.quantity.clamp(1, max);
2676 }
2677 }
2678 }
2679
2680 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
2681 let max = self.move_picker_max_at_selection().max(1);
2682 if let Some(picker) = &mut self.move_picker {
2683 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2684 picker.quantity = next as u32;
2685 }
2686 }
2687
2688 pub fn move_picker_set_quantity_max(&mut self) {
2689 let max = self.move_picker_max_at_selection();
2690 if let Some(picker) = &mut self.move_picker {
2691 picker.quantity = if max == 0 {
2692 1
2693 } else {
2694 max.min(picker.stack_quantity)
2695 };
2696 }
2697 }
2698
2699 pub fn move_picker_set_quantity_min(&mut self) {
2700 if let Some(picker) = &mut self.move_picker {
2701 picker.quantity = 1;
2702 }
2703 }
2704
2705 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
2706 if let Some(picker) = &mut self.destroy_picker {
2707 let max = picker.stack_quantity.max(1);
2708 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2709 picker.quantity = next as u32;
2710 }
2711 }
2712
2713 pub fn destroy_picker_set_quantity_max(&mut self) {
2714 if let Some(picker) = &mut self.destroy_picker {
2715 picker.quantity = picker.stack_quantity.max(1);
2716 }
2717 }
2718
2719 pub fn destroy_picker_set_quantity_min(&mut self) {
2720 if let Some(picker) = &mut self.destroy_picker {
2721 picker.quantity = 1;
2722 }
2723 }
2724
2725 pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2726 let have = self.inventory.get(template_id).copied().unwrap_or(0);
2727 (have, have >= need)
2728 }
2729
2730 pub fn plot_build_stock_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2732 let have = self
2733 .plot_build_offer
2734 .as_ref()
2735 .and_then(|o| {
2736 o.available
2737 .iter()
2738 .find(|s| s.template_id == template_id)
2739 .map(|s| s.quantity)
2740 })
2741 .unwrap_or_else(|| self.inventory.get(template_id).copied().unwrap_or(0));
2742 (have, have >= need)
2743 }
2744
2745 pub fn plot_build_wall_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
2746 self.building_materials
2747 .iter()
2748 .filter(|m| m.can_wall)
2749 .collect()
2750 }
2751
2752 pub fn plot_build_roof_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
2753 self.building_materials
2754 .iter()
2755 .filter(|m| m.can_roof)
2756 .collect()
2757 }
2758
2759 pub fn plot_build_selected_wall(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
2760 self.plot_build_wall_options()
2761 .get(self.plot_build_wall_index)
2762 .copied()
2763 }
2764
2765 pub fn plot_build_selected_roof(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
2766 self.plot_build_roof_options()
2767 .get(self.plot_build_roof_index)
2768 .copied()
2769 }
2770
2771 pub fn plot_build_bom_lines(&self) -> Vec<(String, String, u32)> {
2773 let Some(wall) = self.plot_build_selected_wall() else {
2774 return Vec::new();
2775 };
2776 let Some(roof) = self.plot_build_selected_roof() else {
2777 return Vec::new();
2778 };
2779 let area = self
2780 .plot_build_offer
2781 .as_ref()
2782 .filter(|o| o.pad_ok)
2783 .map(|o| o.pad_width_m * o.pad_depth_m)
2784 .unwrap_or(0.0);
2785 if area <= 0.0 {
2786 return Vec::new();
2787 }
2788 let mut map: std::collections::HashMap<String, (String, u32)> =
2789 std::collections::HashMap::new();
2790 for line in &wall.wall_bom {
2791 let qty = (area * line.per_m2).ceil().max(0.0) as u32;
2792 if qty == 0 {
2793 continue;
2794 }
2795 let name = if line.display_name.is_empty() {
2796 line.template_id.clone()
2797 } else {
2798 line.display_name.clone()
2799 };
2800 let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
2801 entry.1 = entry.1.saturating_add(qty);
2802 }
2803 for line in &roof.roof_bom {
2804 let qty = (area * line.per_m2).ceil().max(0.0) as u32;
2805 if qty == 0 {
2806 continue;
2807 }
2808 let name = if line.display_name.is_empty() {
2809 line.template_id.clone()
2810 } else {
2811 line.display_name.clone()
2812 };
2813 let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
2814 entry.1 = entry.1.saturating_add(qty);
2815 }
2816 let mut out: Vec<_> = map
2817 .into_iter()
2818 .map(|(id, (name, qty))| (id, name, qty))
2819 .collect();
2820 out.sort_by(|a, b| a.0.cmp(&b.0));
2821 out
2822 }
2823
2824 pub fn plot_build_duration_secs(&self) -> Option<f32> {
2825 let wall = self.plot_build_selected_wall()?;
2826 let roof = self.plot_build_selected_roof()?;
2827 let offer = self.plot_build_offer.as_ref()?;
2828 if !offer.pad_ok {
2829 return None;
2830 }
2831 let area = offer.pad_width_m * offer.pad_depth_m;
2832 let mult = wall.tick_mult.max(roof.tick_mult).max(0.1);
2833 let ticks = (offer.base_ticks as f32 + area * offer.tick_per_m2 as f32 * mult).ceil();
2834 Some(ticks.max(2.0) / 30.0)
2835 }
2836
2837 pub fn plot_build_can_afford(&self) -> bool {
2838 if self
2839 .plot_build_offer
2840 .as_ref()
2841 .is_none_or(|o| !o.pad_ok)
2842 {
2843 return false;
2844 }
2845 self.plot_build_bom_lines()
2846 .iter()
2847 .all(|(id, _, need)| self.plot_build_stock_status(id, *need).1)
2848 }
2849
2850 pub fn currency_display(&self) -> String {
2851 crate::currency::currency_line(&self.inventory)
2852 }
2853
2854 pub fn in_shallow_water(&self) -> bool {
2856 let (px, py) = self.player_position();
2857 self.terrain_at(px, py)
2858 .is_some_and(|k| k == TerrainKindView::ShallowWater)
2859 }
2860
2861 pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
2862 self.terrain_zone_at(x, y).map(|z| z.kind)
2863 }
2864
2865 pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
2867 use std::cell::RefCell;
2868
2869 const CHUNK: i32 = 8;
2870 thread_local! {
2871 static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
2872 RefCell::new(None);
2873 }
2874
2875 let zones = &self.terrain_zones;
2876 if zones.is_empty() {
2877 return None;
2878 }
2879 if zones.len() <= 48 {
2880 return zones
2881 .iter()
2882 .enumerate()
2883 .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
2884 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
2885 .map(|(_, z)| z);
2886 }
2887
2888 let ptr = zones.as_ptr();
2889 let len = zones.len();
2890 INDEX.with(|cell| {
2891 let mut slot = cell.borrow_mut();
2892 let stale = match slot.as_ref() {
2893 Some((p, l, _)) => *p != ptr || *l != len,
2894 None => true,
2895 };
2896 if stale {
2897 let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
2898 std::collections::HashMap::new();
2899 for (zi, z) in zones.iter().enumerate() {
2900 let x0 = z.x0.min(z.x1).floor() as i32;
2901 let y0 = z.y0.min(z.y1).floor() as i32;
2902 let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
2903 let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
2904 let cx0 = x0.div_euclid(CHUNK);
2905 let cy0 = y0.div_euclid(CHUNK);
2906 let cx1 = x1.div_euclid(CHUNK);
2907 let cy1 = y1.div_euclid(CHUNK);
2908 for cy in cy0..=cy1 {
2909 for cx in cx0..=cx1 {
2910 chunks.entry((cx, cy)).or_default().push(zi);
2911 }
2912 }
2913 }
2914 *slot = Some((ptr, len, chunks));
2915 }
2916 let chunks = &slot.as_ref().expect("index").2;
2917 let cx = (x.floor() as i32).div_euclid(CHUNK);
2918 let cy = (y.floor() as i32).div_euclid(CHUNK);
2919 let mut best: Option<(usize, &TerrainZoneView)> = None;
2920 if let Some(list) = chunks.get(&(cx, cy)) {
2921 for &zi in list {
2922 let Some(z) = zones.get(zi) else { continue };
2923 if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
2924 continue;
2925 }
2926 best = match best {
2927 None => Some((zi, z)),
2928 Some((bi, bz)) => {
2929 if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
2930 Some((zi, z))
2931 } else {
2932 Some((bi, bz))
2933 }
2934 }
2935 };
2936 }
2937 }
2938 best.map(|(_, z)| z)
2939 })
2940 }
2941
2942 pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
2944 self.terrain_zone_at(x, y)
2945 .map(|z| z.elevation)
2946 .unwrap_or(0.0)
2947 }
2948
2949 pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
2951 const TOL: f32 = 0.35;
2952 let mut levels = vec![self.elevation_at(x, y)];
2953 for p in &self.z_platforms {
2954 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2955 levels.push(p.z);
2956 }
2957 }
2958 levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2959 levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
2960 levels
2961 }
2962
2963 pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
2964 const TOL: f32 = 0.35;
2965 self.walkable_levels_at(x, y)
2966 .iter()
2967 .any(|&l| (l - z).abs() <= TOL)
2968 }
2969
2970 pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
2971 let mut top = self.elevation_at(x, y);
2972 for p in &self.z_platforms {
2973 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2974 top = top.max(p.z);
2975 }
2976 }
2977 top
2978 }
2979
2980 pub fn effective_inside_building(&self) -> Option<String> {
2982 self.player_entity().and_then(|p| p.inside_building.clone())
2983 }
2984
2985 pub fn placed_container_in_current_space(
2989 &self,
2990 c: &flatland_protocol::PlacedContainerView,
2991 ) -> bool {
2992 match (
2993 self.effective_inside_building().as_deref(),
2994 c.building_id.as_deref(),
2995 ) {
2996 (None, None) => true,
2997 (Some(a), Some(b)) => a == b,
2998 _ => false,
2999 }
3000 }
3001
3002 pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
3003 self.inventory_stacks = stacks.to_vec();
3004 self.inventory.clear();
3005 self.inventory_hints.clear();
3006 fn walk(
3007 stacks: &[flatland_protocol::ItemStack],
3008 inventory: &mut std::collections::HashMap<String, u32>,
3009 hints: &mut std::collections::HashMap<String, InventoryHint>,
3010 ) {
3011 for stack in stacks {
3012 *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
3013 if stack.display_name.is_some()
3014 || stack.category.is_some()
3015 || stack.base_mass.is_some()
3016 || stack.base_volume.is_some()
3017 {
3018 hints.insert(
3019 stack.template_id.clone(),
3020 InventoryHint {
3021 display_name: stack
3022 .display_name
3023 .clone()
3024 .unwrap_or_else(|| stack.template_id.clone()),
3025 category: stack.category.clone().unwrap_or_default(),
3026 base_mass: stack.base_mass,
3027 base_volume: stack.base_volume,
3028 capacity_volume: stack.capacity_volume,
3029 stackable: stack.stackable.unwrap_or(true),
3030 listable: stack.listable.unwrap_or_else(|| {
3031 category_default_listable(
3032 stack.category.as_deref().unwrap_or(""),
3033 )
3034 }),
3035 },
3036 );
3037 }
3038 walk(&stack.contents, inventory, hints);
3039 }
3040 }
3041 walk(stacks, &mut self.inventory, &mut self.inventory_hints);
3042 for item in self.worn.values() {
3044 walk(
3045 std::slice::from_ref(item),
3046 &mut self.inventory,
3047 &mut self.inventory_hints,
3048 );
3049 }
3050 }
3051
3052 pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
3055 let subtract_items =
3056 notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
3057 for stack in ¬ice.inventory_delta {
3058 if stack.quantity == 0 {
3059 continue;
3060 }
3061 if subtract_items {
3062 crate::currency::drain_template_stacks(
3063 &mut self.inventory_stacks,
3064 &stack.template_id,
3065 stack.quantity,
3066 );
3067 continue;
3068 }
3069 let stackable = self
3070 .inventory_hints
3071 .get(&stack.template_id)
3072 .map(|h| h.stackable)
3073 .or(stack.stackable)
3074 .unwrap_or(true);
3075 if stackable {
3076 if let Some(existing) = self
3077 .inventory_stacks
3078 .iter_mut()
3079 .find(|s| s.template_id == stack.template_id)
3080 {
3081 existing.quantity = existing.quantity.saturating_add(stack.quantity);
3082 if stack.display_name.is_some() {
3083 existing.display_name = stack.display_name.clone();
3084 }
3085 if stack.category.is_some() {
3086 existing.category = stack.category.clone();
3087 }
3088 continue;
3089 }
3090 }
3091 self.inventory_stacks.push(stack.clone());
3092 }
3093 if notice.coins_delta != 0 {
3094 crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
3095 }
3096 if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
3097 let stacks = self.inventory_stacks.clone();
3098 self.sync_inventory_from_stacks(&stacks);
3099 }
3100 self.record_shop_trade_notice(notice);
3101 }
3102
3103 pub fn worn_rows(&self) -> Vec<InventoryRow> {
3108 let mut rows = Vec::new();
3109 for (slot, item) in &self.worn {
3110 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3111 rows.push(InventoryRow {
3112 depth: 0,
3113 stack: item.clone(),
3114 from: from.clone(),
3115 from_parent_instance_id: None,
3116 is_equip_shell: true,
3117 is_chest_shell: false,
3118 section: InventorySection::Worn,
3119 });
3120 for child in &item.contents {
3121 push_inventory_rows(
3122 &mut rows,
3123 1,
3124 child,
3125 &from,
3126 item.item_instance_id,
3127 InventorySection::Worn,
3128 );
3129 }
3130 }
3131 rows
3132 }
3133
3134 pub fn trade_presentable_stacks(&self) -> Vec<&flatland_protocol::ItemStack> {
3136 let equipped = self.hand_equipped_instance_ids();
3137 self.inventory_stacks
3138 .iter()
3139 .filter(|s| {
3140 s.item_instance_id
3141 .is_some_and(|id| !equipped.contains(&id))
3142 })
3143 .collect()
3144 }
3145
3146 pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
3148 let equipped = self.hand_equipped_instance_ids();
3149 self.inventory_stacks
3150 .iter()
3151 .filter_map(|stack| {
3152 let item_instance_id = stack.item_instance_id?;
3153 if equipped.contains(&item_instance_id) {
3154 return None;
3155 }
3156 let label = stack
3157 .display_name
3158 .clone()
3159 .unwrap_or_else(|| stack.template_id.clone());
3160 let label = if stack.quantity > 1 {
3161 format!("{label} ×{}", stack.quantity)
3162 } else {
3163 label
3164 };
3165 Some(WorkerGiveOption {
3166 item_instance_id,
3167 label,
3168 quantity: stack.quantity,
3169 template_id: stack.template_id.clone(),
3170 })
3171 })
3172 .collect()
3173 }
3174
3175 pub fn teachable_blueprint_options(
3177 &self,
3178 worker: &flatland_protocol::HiredWorkerView,
3179 ) -> Vec<WorkerTeachOption> {
3180 let copper = crate::currency::copper_from_counts(&self.inventory);
3181 let mut options: Vec<WorkerTeachOption> = self
3182 .blueprints
3183 .iter()
3184 .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
3185 .map(|bp| {
3186 let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
3187 let cost = bp.worker_train_copper;
3188 WorkerTeachOption {
3189 blueprint_id: bp.id.clone(),
3190 label: if bp.label.is_empty() {
3191 bp.id.clone()
3192 } else {
3193 bp.label.clone()
3194 },
3195 cost_copper: cost,
3196 min_level,
3197 worker_level: worker.level,
3198 can_afford: copper >= cost,
3199 level_ok: worker.level >= min_level,
3200 }
3201 })
3202 .collect();
3203 options.sort_by(|a, b| a.label.cmp(&b.label));
3204 options
3205 }
3206
3207 pub fn person_rows(&self) -> Vec<InventoryRow> {
3210 self.person_rows_filtered("")
3211 }
3212
3213 pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
3214 let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
3215 roots.sort_by(|a, b| {
3216 let ca = a
3217 .category
3218 .as_deref()
3219 .or_else(|| self.inventory_item_category(&a.template_id))
3220 .unwrap_or("");
3221 let cb = b
3222 .category
3223 .as_deref()
3224 .or_else(|| self.inventory_item_category(&b.template_id))
3225 .unwrap_or("");
3226 let ga = inventory_category_group(ca).1;
3227 let gb = inventory_category_group(cb).1;
3228 ga.cmp(&gb).then_with(|| {
3229 let na = a
3230 .display_name
3231 .as_deref()
3232 .unwrap_or(a.template_id.as_str());
3233 let nb = b
3234 .display_name
3235 .as_deref()
3236 .unwrap_or(b.template_id.as_str());
3237 na.cmp(nb)
3238 })
3239 });
3240 let mut rows = Vec::new();
3241 for stack in roots {
3242 push_inventory_rows_filtered(
3243 &mut rows,
3244 0,
3245 stack,
3246 &flatland_protocol::InventoryLocation::Root,
3247 None,
3248 InventorySection::Person,
3249 filter,
3250 );
3251 }
3252 rows
3253 }
3254
3255 pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
3256 if filter.is_empty() {
3257 return self.worn_rows();
3258 }
3259 let mut rows = Vec::new();
3260 for (slot, item) in &self.worn {
3261 if !stack_matches_filter(item, filter) {
3262 continue;
3263 }
3264 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3265 let self_hit = {
3266 let f = filter.to_ascii_lowercase();
3267 let name = item
3268 .display_name
3269 .as_deref()
3270 .unwrap_or("")
3271 .to_ascii_lowercase();
3272 let tid = item.template_id.to_ascii_lowercase();
3273 name.contains(&f) || tid.contains(&f)
3274 };
3275 rows.push(InventoryRow {
3276 depth: 0,
3277 stack: item.clone(),
3278 from: from.clone(),
3279 from_parent_instance_id: None,
3280 is_equip_shell: true,
3281 is_chest_shell: false,
3282 section: InventorySection::Worn,
3283 });
3284 for child in &item.contents {
3285 if self_hit || stack_matches_filter(child, filter) {
3286 push_inventory_rows_filtered(
3287 &mut rows,
3288 1,
3289 child,
3290 &from,
3291 item.item_instance_id,
3292 InventorySection::Worn,
3293 if self_hit { "" } else { filter },
3294 );
3295 }
3296 }
3297 }
3298 rows
3299 }
3300
3301 pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
3303 let mut rows = self.worn_rows();
3304 rows.extend(self.person_rows());
3305 rows.into_iter().map(|r| (r.depth, r.stack)).collect()
3306 }
3307
3308 pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
3312 let (px, py) = self.player_position();
3313 let mut list: Vec<NearbyContainer> = self
3314 .placed_containers
3315 .iter()
3316 .filter(|c| self.placed_container_in_current_space(c))
3317 .filter_map(|c| {
3318 let distance_m = (c.x - px).hypot(c.y - py);
3319 if distance_m > CONTAINER_RANGE_M {
3320 return None;
3321 }
3322 let mut rows = Vec::new();
3323 let from = flatland_protocol::InventoryLocation::Placed {
3324 container_id: c.id.clone(),
3325 };
3326 rows.push(InventoryRow {
3327 depth: 0,
3328 stack: flatland_protocol::ItemStack {
3329 template_id: c.template_id.clone(),
3330 quantity: 1,
3331 item_instance_id: c.item_instance_id,
3332 props: Default::default(),
3333 status_bindings: Vec::new(),
3334 contents: Vec::new(),
3335 display_name: Some(c.display_name.clone()),
3336 category: Some("container".into()),
3337 capacity_volume: c.capacity_volume,
3338 worker_lodging_capacity: c.worker_lodging_capacity,
3339 ..Default::default()
3340 },
3341 from: from.clone(),
3342 from_parent_instance_id: None,
3343 is_equip_shell: false,
3344 is_chest_shell: true,
3345 section: InventorySection::Nearby,
3346 });
3347 if c.accessible {
3348 for child in &c.contents {
3349 push_inventory_rows(
3350 &mut rows,
3351 1,
3352 child,
3353 &from,
3354 c.item_instance_id,
3355 InventorySection::Nearby,
3356 );
3357 }
3358 }
3359 Some(NearbyContainer {
3360 view: c.clone(),
3361 distance_m,
3362 rows,
3363 })
3364 })
3365 .collect();
3366 list.sort_by(|a, b| {
3367 a.distance_m
3368 .partial_cmp(&b.distance_m)
3369 .unwrap_or(std::cmp::Ordering::Equal)
3370 });
3371 list
3372 }
3373
3374 pub fn nearest_placed_container(
3376 &self,
3377 max_dist: f32,
3378 ) -> Option<flatland_protocol::PlacedContainerView> {
3379 let (px, py) = self.player_position();
3380 self.placed_containers
3381 .iter()
3382 .filter(|c| self.placed_container_in_current_space(c))
3383 .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
3384 .min_by(|a, b| {
3385 let da = (a.x - px).hypot(a.y - py);
3386 let db = (b.x - px).hypot(b.y - py);
3387 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3388 })
3389 .cloned()
3390 }
3391
3392 pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
3395 let filter = self.inventory_filter.as_str();
3396 match self.inventory_tab {
3397 InventoryTab::OnPerson => {
3398 let mut rows = self.worn_rows_filtered(filter);
3399 rows.extend(self.person_rows_filtered(filter));
3400 rows
3401 }
3402 InventoryTab::Nearby => {
3403 let mut rows = Vec::new();
3404 for nc in self.nearby_containers() {
3405 if filter.is_empty() {
3406 rows.extend(nc.rows);
3407 continue;
3408 }
3409 let shell = nc.rows.first().cloned();
3410 let contents: Vec<_> = nc
3411 .rows
3412 .iter()
3413 .skip(1)
3414 .filter(|r| stack_matches_filter(&r.stack, filter))
3415 .cloned()
3416 .collect();
3417 let shell_hit = shell
3418 .as_ref()
3419 .map(|s| stack_matches_filter(&s.stack, filter))
3420 .unwrap_or(false);
3421 if shell_hit || !contents.is_empty() {
3422 if let Some(s) = shell {
3423 rows.push(s);
3424 }
3425 if shell_hit {
3426 rows.extend(nc.rows.into_iter().skip(1));
3427 } else {
3428 rows.extend(contents);
3429 }
3430 }
3431 }
3432 rows
3433 }
3434 }
3435 }
3436
3437 pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
3438 self.inventory_selectable_rows()
3439 .into_iter()
3440 .nth(self.inventory_menu_index)
3441 }
3442
3443 fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
3444 let cat = self
3445 .inventory_item_category(&row.stack.template_id)
3446 .unwrap_or("");
3447 if cat == "key" {
3448 self.key_inventory_label(&row.stack)
3449 } else {
3450 row.stack
3451 .display_name
3452 .clone()
3453 .unwrap_or_else(|| row.stack.template_id.clone())
3454 }
3455 }
3456
3457 fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
3459 let bindings = format_status_bindings_suffix(
3460 &row.stack.status_bindings,
3461 self.tick,
3462 DEFAULT_TICK_HZ,
3463 );
3464 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3465 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3466 let mode = Self::grant_mode(&row.stack);
3467 format!(" [grant {effect} · {mode} — e apply]")
3468 } else {
3469 String::new()
3470 };
3471 let qty = if row.stack.quantity > 1 {
3472 format!(" ×{}", row.stack.quantity)
3473 } else {
3474 String::new()
3475 };
3476 let worn_slot = if row.is_equip_shell {
3477 match row.from {
3478 flatland_protocol::InventoryLocation::Worn { slot } => {
3479 format!(" ({})", body_slot_label(slot))
3480 }
3481 _ => String::new(),
3482 }
3483 } else {
3484 String::new()
3485 };
3486 format!("{grant_hint}{bindings}{qty}{worn_slot}")
3487 }
3488
3489 fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
3490 (
3491 row.stack.template_id.clone(),
3492 self.inventory_row_base_label(row),
3493 self.inventory_row_visible_mod_signature(row),
3494 )
3495 }
3496
3497 fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
3499 let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
3500 for row in self.inventory_selectable_rows() {
3501 if row.stack.item_instance_id.is_none() {
3502 continue;
3503 }
3504 let key = self.inventory_row_instance_identity_key(&row);
3505 *counts.entry(key).or_default() += 1;
3506 }
3507 counts
3508 .into_iter()
3509 .filter(|(_, n)| *n > 1)
3510 .map(|(k, _)| k)
3511 .collect()
3512 }
3513
3514 fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
3515 let hex: String = id
3516 .as_simple()
3517 .to_string()
3518 .chars()
3519 .filter(|c| c.is_ascii_hexdigit())
3520 .collect();
3521 let short = if hex.len() >= 4 {
3522 &hex[hex.len() - 4..]
3523 } else {
3524 hex.as_str()
3525 };
3526 format!("Instance {id} (#{short})")
3527 }
3528
3529 pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
3531 let cat = self
3532 .inventory_item_category(&row.stack.template_id)
3533 .unwrap_or("");
3534 let label = self.inventory_row_base_label(row);
3535 let hint: String = if row.is_equip_shell {
3536 " [worn — Enter to unequip]".into()
3537 } else if row.is_chest_shell {
3538 let (locked, lodging_note) = match &row.from {
3539 flatland_protocol::InventoryLocation::Placed { container_id } => {
3540 let locked = self
3541 .placed_containers
3542 .iter()
3543 .find(|c| c.id == *container_id)
3544 .map(|c| c.locked)
3545 .unwrap_or(false);
3546 let lodging_note = self
3547 .lodging_occupancy_label(container_id)
3548 .map(|who| format!(" [lodging: {who}]"))
3549 .unwrap_or_default();
3550 (locked, lodging_note)
3551 }
3552 _ => (false, String::new()),
3553 };
3554 if locked {
3555 format!(" [locked — Enter pick up · l unlock]{lodging_note}")
3556 } else {
3557 format!(" [Enter pick up · l lock]{lodging_note}")
3558 }
3559 } else if cat == "key" {
3560 self.key_inventory_hint(&row.stack)
3561 } else {
3562 match cat {
3563 "weapon" => " [weapon]".into(),
3564 "container" => " [bag/chest/belt]".into(),
3565 "lodging" => " [worker lodging]".into(),
3566 "armor" => " [armor]".into(),
3567 _ => String::new(),
3568 }
3569 };
3570 let qty = if row.stack.quantity > 1 {
3571 format!(" ×{}", row.stack.quantity)
3572 } else {
3573 String::new()
3574 };
3575 let bindings = format_status_bindings_suffix(
3576 &row.stack.status_bindings,
3577 self.tick,
3578 DEFAULT_TICK_HZ,
3579 );
3580 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3581 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3582 let mode = Self::grant_mode(&row.stack);
3583 format!(" [grant {effect} · {mode} — e apply]")
3584 } else {
3585 String::new()
3586 };
3587 let mass = self.stack_mass(&row.stack);
3588 let mass_kg = (mass >= 0.05).then_some(mass);
3589 let mass_str = mass_kg
3590 .map(|m| format!(" {m:.1} kg"))
3591 .unwrap_or_default();
3592 let volume = self.container_volume_stats(row);
3593 let vol_str = self.container_volume_label(row);
3594
3595 let mut title = label.clone();
3596 title.push_str(&qty);
3597 if row.is_equip_shell {
3598 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3599 title.push_str(&format!(" ({})", body_slot_label(slot)));
3600 }
3601 }
3602
3603 InventoryRowView {
3604 depth: row.depth,
3605 text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
3606 title: format!("{title}{grant_hint}{bindings}"),
3607 mass_kg,
3608 volume,
3609 instance_tooltip: None,
3610 }
3611 }
3612
3613 fn push_browser_item(
3614 &self,
3615 lines: &mut Vec<InventoryBrowserLine>,
3616 row: &InventoryRow,
3617 global_idx: &mut usize,
3618 target: usize,
3619 highlight: bool,
3620 ambiguous_instance_keys: &HashSet<(String, String, String)>,
3621 ) {
3622 let mut view = self.format_inventory_row(row);
3623 if let Some(id) = row.stack.item_instance_id {
3624 let key = self.inventory_row_instance_identity_key(row);
3625 if ambiguous_instance_keys.contains(&key) {
3626 view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
3627 }
3628 }
3629 lines.push(InventoryBrowserLine::Item {
3630 selectable_index: *global_idx,
3631 selected: highlight && *global_idx == target,
3632 depth: view.depth,
3633 text: view.text,
3634 title: view.title,
3635 mass_kg: view.mass_kg,
3636 volume: view.volume,
3637 instance_tooltip: view.instance_tooltip,
3638 });
3639 *global_idx += 1;
3640 }
3641
3642 pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
3645 let mut lines = Vec::new();
3646 let target = self.inventory_menu_index;
3647 let highlight = !self.show_move_picker && !self.show_grant_picker;
3648 let filter = self.inventory_filter.as_str();
3649 let mut global_idx = 0usize;
3650 let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
3651
3652 match self.inventory_tab {
3653 InventoryTab::OnPerson => {
3654 lines.push(InventoryBrowserLine::Section("— Worn —".into()));
3655 let worn = self.worn_rows_filtered(filter);
3656 if worn.is_empty() {
3657 lines.push(InventoryBrowserLine::Hint(
3658 " (nothing equipped — wear a backpack/belt from \"On you\" below)".into(),
3659 ));
3660 } else {
3661 for row in &worn {
3662 if row.is_equip_shell {
3663 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3664 lines.push(InventoryBrowserLine::SlotLabel(format!(
3665 " {}:",
3666 body_slot_label(slot)
3667 )));
3668 }
3669 }
3670 self.push_browser_item(
3671 &mut lines,
3672 row,
3673 &mut global_idx,
3674 target,
3675 highlight,
3676 &ambiguous_instance_keys,
3677 );
3678 }
3679 }
3680
3681 lines.push(InventoryBrowserLine::Blank);
3682 lines.push(InventoryBrowserLine::Section(
3683 "— On you (loose, not worn) —".into(),
3684 ));
3685 let person = self.person_rows_filtered(filter);
3686 if person.is_empty() {
3687 lines.push(InventoryBrowserLine::Hint(" (empty)".into()));
3688 } else {
3689 let mut last_group: Option<&'static str> = None;
3690 for row in &person {
3691 if row.depth == 0 {
3692 let cat = row
3693 .stack
3694 .category
3695 .as_deref()
3696 .or_else(|| self.inventory_item_category(&row.stack.template_id))
3697 .unwrap_or("");
3698 let (group, _) = inventory_category_group(cat);
3699 if last_group != Some(group) {
3700 lines.push(InventoryBrowserLine::SlotLabel(format!(
3701 " {group}"
3702 )));
3703 last_group = Some(group);
3704 }
3705 }
3706 self.push_browser_item(
3707 &mut lines,
3708 row,
3709 &mut global_idx,
3710 target,
3711 highlight,
3712 &ambiguous_instance_keys,
3713 );
3714 }
3715 }
3716 }
3717 InventoryTab::Nearby => {
3718 let nearby = self.nearby_containers();
3719 if nearby.is_empty() {
3720 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3721 lines.push(InventoryBrowserLine::Hint(
3722 " (none within reach — walk up to a chest)".into(),
3723 ));
3724 lines.push(InventoryBrowserLine::Hint(
3725 " Select an on-person item, then m / Enter → move into chest.".into(),
3726 ));
3727 } else {
3728 let mut any_visible = false;
3729 for nc in &nearby {
3730 let shell = nc.rows.first();
3731 let contents: Vec<&InventoryRow> = if filter.is_empty() {
3732 nc.rows.iter().skip(1).collect()
3733 } else {
3734 let shell_hit = shell
3735 .map(|s| {
3736 let f = filter.to_ascii_lowercase();
3737 let name = s
3738 .stack
3739 .display_name
3740 .as_deref()
3741 .unwrap_or("")
3742 .to_ascii_lowercase();
3743 let tid = s.stack.template_id.to_ascii_lowercase();
3744 name.contains(&f) || tid.contains(&f)
3745 })
3746 .unwrap_or(false);
3747 if shell_hit {
3748 nc.rows.iter().skip(1).collect()
3749 } else {
3750 nc.rows
3751 .iter()
3752 .skip(1)
3753 .filter(|r| stack_matches_filter(&r.stack, filter))
3754 .collect()
3755 }
3756 };
3757 let shell_visible = filter.is_empty()
3758 || shell
3759 .map(|s| stack_matches_filter(&s.stack, filter))
3760 .unwrap_or(false)
3761 || !contents.is_empty();
3762 if !shell_visible && shell.is_some() {
3763 continue;
3764 }
3765 any_visible = true;
3766 lines.push(InventoryBrowserLine::Blank);
3767 let lock_note = if nc.view.locked && nc.view.accessible {
3768 " unlocked with your key"
3769 } else if nc.view.locked {
3770 " locked"
3771 } else {
3772 ""
3773 };
3774 lines.push(InventoryBrowserLine::Section(format!(
3775 "— {} ({:.0}m away){lock_note} —",
3776 nc.view.display_name, nc.distance_m
3777 )));
3778 if !nc.view.accessible {
3779 lines.push(InventoryBrowserLine::Hint(
3780 " locked — need the matching key (l to try)".into(),
3781 ));
3782 } else if nc.rows.is_empty() {
3783 lines.push(InventoryBrowserLine::Hint(
3784 " (empty — switch to On person, select an item, m to move in)"
3785 .into(),
3786 ));
3787 } else if let Some(shell_row) = shell {
3788 self.push_browser_item(
3789 &mut lines,
3790 shell_row,
3791 &mut global_idx,
3792 target,
3793 highlight,
3794 &ambiguous_instance_keys,
3795 );
3796 for row in contents {
3797 self.push_browser_item(
3798 &mut lines,
3799 row,
3800 &mut global_idx,
3801 target,
3802 highlight,
3803 &ambiguous_instance_keys,
3804 );
3805 }
3806 }
3807 }
3808 if !any_visible {
3809 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3810 lines.push(InventoryBrowserLine::Hint(
3811 " (no matching items — clear filter with Esc)".into(),
3812 ));
3813 }
3814 }
3815 }
3816 }
3817 lines
3818 }
3819
3820 pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
3822 let mut opts = Vec::new();
3823 opts.push(MoveOption {
3824 label: "Relocate…".into(),
3825 kind: MoveOptionKind::RelocatePlaced {
3826 container_id: container_id.to_string(),
3827 },
3828 });
3829 opts.push(MoveOption {
3830 label: "On your person (loose)".into(),
3831 kind: MoveOptionKind::PickupPlaced {
3832 container_id: container_id.to_string(),
3833 nest_location: flatland_protocol::InventoryLocation::Root,
3834 nest_parent_instance_id: None,
3835 },
3836 });
3837 for (slot, item) in &self.worn {
3838 if item.category.as_deref() != Some("container") {
3839 continue;
3840 }
3841 if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
3842 continue;
3843 }
3844 let Some(parent_id) = item.item_instance_id else {
3845 continue;
3846 };
3847 let shell_name = item
3848 .display_name
3849 .clone()
3850 .unwrap_or_else(|| item.template_id.clone());
3851 opts.push(MoveOption {
3852 label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
3853 kind: MoveOptionKind::PickupPlaced {
3854 container_id: container_id.to_string(),
3855 nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
3856 nest_parent_instance_id: Some(parent_id),
3857 },
3858 });
3859 Self::append_chest_pickup_nested(
3861 &mut opts,
3862 container_id,
3863 flatland_protocol::InventoryLocation::Worn { slot: *slot },
3864 item,
3865 &format!("in {shell_name}"),
3866 );
3867 }
3868 opts.push(MoveOption {
3869 label: "Cancel".into(),
3870 kind: MoveOptionKind::Cancel,
3871 });
3872 opts
3873 }
3874
3875 fn append_chest_pickup_nested(
3876 opts: &mut Vec<MoveOption>,
3877 container_id: &str,
3878 location: flatland_protocol::InventoryLocation,
3879 parent: &flatland_protocol::ItemStack,
3880 context: &str,
3881 ) {
3882 for child in &parent.contents {
3883 if child.category.as_deref() != Some("container") {
3884 continue;
3885 }
3886 if !Self::is_volume_container_stack(child) {
3887 continue;
3888 }
3889 if child.world_placeable == Some(true) {
3891 continue;
3892 }
3893 let Some(child_id) = child.item_instance_id else {
3894 continue;
3895 };
3896 let name = child
3897 .display_name
3898 .clone()
3899 .unwrap_or_else(|| child.template_id.clone());
3900 opts.push(MoveOption {
3901 label: format!("{name} ({context})"),
3902 kind: MoveOptionKind::PickupPlaced {
3903 container_id: container_id.to_string(),
3904 nest_location: location.clone(),
3905 nest_parent_instance_id: Some(child_id),
3906 },
3907 });
3908 Self::append_chest_pickup_nested(
3909 opts,
3910 container_id,
3911 location.clone(),
3912 child,
3913 &format!("in {name}"),
3914 );
3915 }
3916 }
3917
3918 pub fn move_destinations_for(
3920 &self,
3921 from: &flatland_protocol::InventoryLocation,
3922 from_parent_instance_id: Option<uuid::Uuid>,
3923 moving_instance_id: Option<uuid::Uuid>,
3924 moving_template_id: &str,
3925 ) -> Vec<MoveOption> {
3926 let mut opts = Vec::new();
3927 if *from != flatland_protocol::InventoryLocation::Root {
3928 opts.push(MoveOption {
3929 label: "On your person (loose)".into(),
3930 kind: MoveOptionKind::Move {
3931 location: flatland_protocol::InventoryLocation::Root,
3932 parent_instance_id: None,
3933 },
3934 });
3935 }
3936 for (slot, item) in &self.worn {
3937 if item.category.as_deref() != Some("container") {
3938 continue;
3939 }
3940 let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3941 let shell_name = item
3942 .display_name
3943 .clone()
3944 .unwrap_or_else(|| item.template_id.clone());
3945
3946 if *slot != BodySlot::Waist
3948 && item.item_instance_id != moving_instance_id
3949 && Self::is_volume_container_stack(item)
3950 {
3951 Self::push_move_destination(
3952 &mut opts,
3953 format!("{shell_name} (worn {})", body_slot_label(*slot)),
3954 location.clone(),
3955 item.item_instance_id,
3956 from,
3957 from_parent_instance_id,
3958 );
3959 }
3960
3961 if *slot == BodySlot::Waist
3963 && Self::attaches_to_belt_loop(moving_template_id)
3964 && item.item_instance_id != moving_instance_id
3965 {
3966 Self::push_move_destination(
3967 &mut opts,
3968 format!("{shell_name} (belt loop)"),
3969 location.clone(),
3970 item.item_instance_id,
3971 from,
3972 from_parent_instance_id,
3973 );
3974 }
3975
3976 let context = if *slot == BodySlot::Waist {
3977 format!("on {shell_name}")
3978 } else {
3979 format!("in {shell_name}")
3980 };
3981 Self::append_nested_container_destinations(
3982 &mut opts,
3983 location,
3984 item,
3985 &context,
3986 from,
3987 from_parent_instance_id,
3988 moving_instance_id,
3989 );
3990 }
3991 for nc in self.nearby_containers() {
3992 if !nc.view.accessible {
3993 continue;
3994 }
3995 let location = flatland_protocol::InventoryLocation::Placed {
3996 container_id: nc.view.id.clone(),
3997 };
3998 Self::push_move_destination(
3999 &mut opts,
4000 format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
4001 location,
4002 nc.view.item_instance_id,
4003 from,
4004 from_parent_instance_id,
4005 );
4006 }
4007 let allow_drop = moving_instance_id
4008 .map(|id| !self.hand_equipped_instance_ids().contains(&id))
4009 .unwrap_or(true)
4010 && moving_instance_id
4011 .and_then(|id| self.stack_for_instance(id))
4012 .map(|stack| {
4013 !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
4014 })
4015 .unwrap_or(
4016 moving_template_id != KEY_TEMPLATE
4017 && moving_template_id != PROPERTY_DEED_TEMPLATE,
4018 );
4019 if allow_drop {
4020 opts.push(MoveOption {
4021 label: "Drop on the ground".into(),
4022 kind: MoveOptionKind::Drop,
4023 });
4024 }
4025 opts.push(MoveOption {
4026 label: "Cancel".into(),
4027 kind: MoveOptionKind::Cancel,
4028 });
4029 opts
4030 }
4031
4032 fn is_same_container_dest(
4033 dest_location: &flatland_protocol::InventoryLocation,
4034 dest_parent: Option<uuid::Uuid>,
4035 from: &flatland_protocol::InventoryLocation,
4036 from_parent: Option<uuid::Uuid>,
4037 ) -> bool {
4038 dest_location == from && dest_parent == from_parent
4039 }
4040
4041 fn push_move_destination(
4042 opts: &mut Vec<MoveOption>,
4043 label: String,
4044 location: flatland_protocol::InventoryLocation,
4045 parent_instance_id: Option<uuid::Uuid>,
4046 from: &flatland_protocol::InventoryLocation,
4047 from_parent_instance_id: Option<uuid::Uuid>,
4048 ) {
4049 if Self::is_same_container_dest(
4050 &location,
4051 parent_instance_id,
4052 from,
4053 from_parent_instance_id,
4054 ) {
4055 return;
4056 }
4057 opts.push(MoveOption {
4058 label,
4059 kind: MoveOptionKind::Move {
4060 location,
4061 parent_instance_id,
4062 },
4063 });
4064 }
4065
4066 fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
4067 stack.capacity_volume.is_some_and(|c| c > 0.0)
4068 }
4069
4070 fn attaches_to_belt_loop(template_id: &str) -> bool {
4071 matches!(template_id, "leather_pouch" | "dimensional_pouch")
4072 }
4073
4074 fn append_nested_container_destinations(
4075 opts: &mut Vec<MoveOption>,
4076 location: flatland_protocol::InventoryLocation,
4077 container: &flatland_protocol::ItemStack,
4078 context: &str,
4079 from: &flatland_protocol::InventoryLocation,
4080 from_parent_instance_id: Option<uuid::Uuid>,
4081 moving_instance_id: Option<uuid::Uuid>,
4082 ) {
4083 for child in &container.contents {
4084 if Self::is_volume_container_stack(child)
4085 && child.item_instance_id != moving_instance_id
4086 {
4087 let name = child
4088 .display_name
4089 .clone()
4090 .unwrap_or_else(|| child.template_id.clone());
4091 Self::push_move_destination(
4092 opts,
4093 format!("{name} ({context})"),
4094 location.clone(),
4095 child.item_instance_id,
4096 from,
4097 from_parent_instance_id,
4098 );
4099 }
4100 let nested_context = format!(
4101 "in {}",
4102 child.display_name.as_deref().unwrap_or(&child.template_id)
4103 );
4104 Self::append_nested_container_destinations(
4105 opts,
4106 location.clone(),
4107 child,
4108 &nested_context,
4109 from,
4110 from_parent_instance_id,
4111 moving_instance_id,
4112 );
4113 }
4114 }
4115
4116 fn clamp_inventory_indices(&mut self) {
4117 let n = self.inventory_selectable_rows().len();
4118 self.inventory_menu_index = if n == 0 {
4119 0
4120 } else {
4121 self.inventory_menu_index.min(n - 1)
4122 };
4123 if let Some(picker) = &self.move_picker {
4124 let pn = picker.options.len();
4125 self.move_picker_index = if pn == 0 {
4126 0
4127 } else {
4128 self.move_picker_index.min(pn - 1)
4129 };
4130 }
4131 }
4132
4133 fn sync_interior_map_context(&mut self) {
4138 if self.effective_inside_building().is_none() {
4139 self.interior_map = None;
4140 if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
4141 self.z_platforms = platforms;
4142 self.z_transitions = transitions;
4143 }
4144 return;
4145 }
4146 self.sync_interior_z_bands();
4147 }
4148
4149 fn sync_interior_z_bands(&mut self) {
4151 if self.effective_inside_building().is_some() {
4152 if let Some(map) = &self.interior_map {
4153 if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
4154 if self.z_bands_outdoor_backup.is_none() {
4155 self.z_bands_outdoor_backup = Some((
4156 std::mem::take(&mut self.z_platforms),
4157 std::mem::take(&mut self.z_transitions),
4158 ));
4159 }
4160 self.z_platforms = map.z_platforms.clone();
4161 self.z_transitions = map.z_transitions.clone();
4162 }
4163 }
4164 }
4165 }
4166
4167 fn apply_snapshot_fields(
4168 &mut self,
4169 snapshot: &flatland_protocol::Snapshot,
4170 entity_id: EntityId,
4171 ) {
4172 self.tick = snapshot.tick;
4173 self.chunk_rev = snapshot.chunk_rev;
4174 self.content_rev = snapshot.content_rev;
4175 self.publish_rev = snapshot.publish_rev;
4176 self.resource_nodes = snapshot.resource_nodes.clone();
4177 self.ground_drops = snapshot.ground_drops.clone();
4178 self.placed_containers = snapshot.placed_containers.clone();
4179 self.world_x0 = snapshot.world_x0;
4180 self.world_y0 = snapshot.world_y0;
4181 self.world_width_m = snapshot.world_width_m;
4182 self.world_height_m = snapshot.world_height_m;
4183 self.world_clock = snapshot.world_clock;
4184 self.terrain_zones = snapshot.terrain_zones.clone();
4185 self.z_platforms = snapshot.z_platforms.clone();
4186 self.z_transitions = snapshot.z_transitions.clone();
4187 self.z_bands_outdoor_backup = None;
4189 self.buildings = snapshot.buildings.clone();
4190 self.doors = snapshot.doors.clone();
4191 self.interior_map = snapshot.interior_map.clone();
4192 self.npcs = snapshot.npcs.clone();
4193 self.blueprints = snapshot.blueprints.clone();
4194 self.building_materials = snapshot.building_materials.clone();
4195 self.sync_inventory_from_stacks(&snapshot.inventory);
4196 self.player = snapshot
4197 .entities
4198 .iter()
4199 .find(|e| e.id == entity_id)
4200 .cloned();
4201 self.entities = snapshot.entities.clone();
4202 self.quest_log = snapshot.quest_log.clone();
4203 self.apply_hired_workers(snapshot.hired_workers.clone());
4204 self.interactables = snapshot.interactables.clone();
4205 self.ledger = snapshot.ledger.clone();
4206 self.career = snapshot.career.clone();
4207 self.combat_fx = snapshot.combat_fx.clone();
4208 self.property_zones = snapshot.property_zones.clone();
4209 self.tax_zones = snapshot.tax_zones.clone();
4210 self.growth_zones = snapshot.growth_zones.clone();
4211 self.biome_zones = snapshot.biome_zones.clone();
4212 self.terrain_kind_nav = snapshot.terrain_kind_nav.clone();
4213 self.property_plots = snapshot.property_plots.clone();
4214 self.property_plot_settings = snapshot.property_plot_settings.clone();
4215 if self.effective_inside_building().is_some() {
4218 self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
4219 }
4220 self.sync_interior_map_context();
4221 self.refresh_whisper_range();
4222 }
4223
4224 fn refresh_inventory_ui(&mut self) {
4228 if let Some(picker) = &self.move_picker {
4229 let instance_id = picker.item_instance_id;
4230 let still_exists = self
4231 .inventory_selectable_rows()
4232 .iter()
4233 .any(|r| r.stack.item_instance_id == Some(instance_id));
4234 if !still_exists {
4235 self.move_picker = None;
4236 self.show_move_picker = false;
4237 }
4238 }
4239 if let Some(picker) = &self.destroy_picker {
4240 let instance_id = picker.item_instance_id;
4241 let still_exists = self
4242 .inventory_selectable_rows()
4243 .iter()
4244 .any(|r| r.stack.item_instance_id == Some(instance_id));
4245 if !still_exists {
4246 self.destroy_picker = None;
4247 self.show_destroy_picker = false;
4248 self.destroy_confirm_pending = false;
4249 }
4250 }
4251 self.clamp_inventory_indices();
4252 }
4253
4254 fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
4260 let selected_id = self
4261 .hired_workers
4262 .get(self.workers_menu_index)
4263 .map(|w| w.instance_id.clone());
4264 workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
4265 let now = Instant::now();
4266 for w in &workers {
4267 let prev_err = self
4268 .hired_workers
4269 .iter()
4270 .find(|p| p.instance_id == w.instance_id)
4271 .and_then(|p| p.last_error.as_deref());
4272 let new_err = w.last_error.as_deref();
4273 if new_err != prev_err {
4274 if let Some(err) = new_err {
4275 if !worker_error_is_transient(err) {
4276 self.push_log(format!("Worker {}: {err}", w.label));
4277 }
4278 }
4279 }
4280 }
4281 let mut next_display = BTreeMap::new();
4282 let mut next_errors = BTreeMap::new();
4283 for w in &workers {
4284 let mut sticky = self
4285 .worker_step_display
4286 .remove(&w.instance_id)
4287 .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
4288 sticky.observe(&w.step_label, now);
4289 next_display.insert(w.instance_id.clone(), sticky);
4290
4291 let mut err_sticky = self
4292 .worker_error_display
4293 .remove(&w.instance_id)
4294 .unwrap_or_default();
4295 err_sticky.observe(w.last_error.as_deref(), now);
4296 if err_sticky.shown(now).is_some() {
4297 next_errors.insert(w.instance_id.clone(), err_sticky);
4298 }
4299 }
4300 self.worker_step_display = next_display;
4301 self.worker_error_display = next_errors;
4302 self.hired_workers = workers;
4303 self.sync_worker_take_picker_from_hired();
4304 if let Some(id) = selected_id {
4305 if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
4306 self.workers_menu_index = idx;
4307 return;
4308 }
4309 }
4310 if self.workers_menu_index >= self.hired_workers.len() {
4311 self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
4312 }
4313 }
4314
4315 fn sync_worker_take_picker_from_hired(&mut self) {
4317 if !self.show_worker_take_picker {
4318 return;
4319 }
4320 let Some(picker) = self.worker_take_picker.clone() else {
4321 return;
4322 };
4323 let Some(worker) = self
4324 .hired_workers
4325 .iter()
4326 .find(|w| w.instance_id == picker.worker_instance_id)
4327 .cloned()
4328 else {
4329 self.show_worker_take_picker = false;
4330 self.worker_take_picker = None;
4331 self.worker_take_picker_index = 0;
4332 return;
4333 };
4334 let options: Vec<WorkerGiveOption> = worker
4335 .inventory
4336 .iter()
4337 .filter_map(|stack| {
4338 let item_instance_id = stack.item_instance_id?;
4339 let label = stack
4340 .display_name
4341 .clone()
4342 .unwrap_or_else(|| stack.template_id.clone());
4343 let label = if stack.quantity > 1 {
4344 format!("{label} ×{}", stack.quantity)
4345 } else {
4346 label
4347 };
4348 Some(WorkerGiveOption {
4349 item_instance_id,
4350 label,
4351 quantity: stack.quantity,
4352 template_id: stack.template_id.clone(),
4353 })
4354 })
4355 .collect();
4356 if options.is_empty() {
4357 self.show_worker_take_picker = false;
4358 self.worker_take_picker = None;
4359 self.worker_take_picker_index = 0;
4360 return;
4361 }
4362 let prev_id = picker
4363 .options
4364 .get(self.worker_take_picker_index)
4365 .map(|o| o.item_instance_id);
4366 let idx = prev_id
4367 .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
4368 .unwrap_or(0)
4369 .min(options.len().saturating_sub(1));
4370 let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
4371 let quantity = picker.quantity.clamp(1, max_qty);
4372 self.worker_take_picker_index = idx;
4373 self.worker_take_picker = Some(WorkerTakePicker {
4374 worker_instance_id: picker.worker_instance_id,
4375 worker_label: picker.worker_label,
4376 options,
4377 quantity,
4378 });
4379 }
4380
4381 pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
4383 self.worker_step_display
4384 .get(worker_instance_id)
4385 .map(|s| s.shown.as_str())
4386 .or_else(|| {
4387 self.hired_workers
4388 .iter()
4389 .find(|w| w.instance_id == worker_instance_id)
4390 .map(|w| w.step_label.as_str())
4391 })
4392 .unwrap_or("")
4393 }
4394
4395 pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
4397 let now = Instant::now();
4398 self.worker_error_display
4399 .get(worker_instance_id)
4400 .and_then(|s| s.shown(now))
4401 .or_else(|| {
4402 self.hired_workers
4403 .iter()
4404 .find(|w| w.instance_id == worker_instance_id)
4405 .and_then(|w| w.last_error.as_deref())
4406 .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
4407 })
4408 .filter(|e| !worker_error_is_hud_noise(e))
4409 }
4410
4411 fn apply_combat_hud(&mut self, combat: &CombatHud) {
4412 self.in_combat = combat.in_combat;
4413 self.auto_attack = combat.auto_attack;
4414 self.combat_has_los = combat.has_los;
4415 self.attack_cd_ticks = combat.attack_cd_ticks;
4416 self.gcd_ticks = combat.gcd_ticks;
4417 self.weapon_ability_id = combat.ability_id.clone();
4418 self.mainhand_template_id = combat.mainhand_template_id.clone();
4419 self.mainhand_label = combat.mainhand_label.clone();
4420 self.mainhand_instance_id = combat.mainhand_instance_id;
4421 self.offhand_template_id = combat.offhand_template_id.clone();
4422 self.offhand_label = combat.offhand_label.clone();
4423 self.offhand_instance_id = combat.offhand_instance_id;
4424 self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
4425 1
4426 } else {
4427 combat.mainhand_hand_slots
4428 };
4429 self.defense = combat.defense.clone();
4430 self.worn = combat.worn.iter().cloned().collect();
4431 self.carry_mass = combat.carry_mass;
4432 self.carry_mass_max = combat.carry_mass_max;
4433 self.encumbrance = combat.encumbrance;
4434 self.cast_progress = combat.cast.clone();
4435 self.timed_channel = combat.timed_channel.clone();
4436 self.plot_build_offer = combat.plot_build.clone();
4437 self.ability_cooldowns = combat.ability_cooldowns.clone();
4438 self.blocking_active = combat.blocking_active;
4439 self.max_target_slots = combat.max_target_slots.max(1);
4440 self.combat_slots = combat.slots.clone();
4441 self.rotation_presets = combat.rotation_presets.clone();
4442 self.known_abilities = combat.known_abilities.clone();
4443 self.ability_meta = combat
4444 .ability_meta
4445 .iter()
4446 .cloned()
4447 .map(|meta| (meta.id.clone(), meta))
4448 .collect();
4449 self.ability_mastery = combat
4450 .ability_mastery
4451 .iter()
4452 .cloned()
4453 .map(|row| (row.ability_id.clone(), row))
4454 .collect();
4455 self.hotbar = combat.hotbar.clone();
4456 self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
4457 self.keychain_stacks = combat.keychain.clone();
4458 self.whisper_pouch_stacks = combat.whisper_pouch.clone();
4459 self.combat_target_detail = combat.target.clone();
4460 self.statuses = combat.statuses.clone();
4461 self.combat_target = combat.target_entity_id;
4462 if combat.progression_xp_base > 0.0 {
4463 self.progression_curve = Some(flatland_protocol::ProgressionCurve {
4464 baseline_display: combat.progression_baseline,
4465 xp_base: combat.progression_xp_base,
4466 xp_growth: combat.progression_xp_growth,
4467 });
4468 }
4469 if let Some(xp) = &combat.progression_xp {
4470 if let Some(player) = &mut self.player {
4471 player.progression_xp = Some(xp.clone());
4472 if let Some(attrs) = combat.attributes {
4473 player.attributes = Some(attrs);
4474 }
4475 if let Some(skills) = &combat.skills {
4476 player.skills = Some(skills.clone());
4477 }
4478 }
4479 }
4480 if let Some(label) = &combat.target_label {
4481 self.combat_target_label = Some(label.clone());
4482 } else if let Some(id) = combat.target_entity_id {
4483 self.combat_target_label = self
4484 .entities
4485 .iter()
4486 .find(|e| e.id == id)
4487 .map(|e| e.label.clone())
4488 .or_else(|| self.combat_target_label.clone());
4489 }
4490 self.refresh_inventory_ui();
4491 }
4492
4493 pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
4495 self.combat_slots
4496 .iter()
4497 .find(|s| s.slot_index == slot)
4498 .and_then(|s| s.target_entity_id)
4499 .or_else(|| if slot == 1 { self.combat_target } else { None })
4500 }
4501
4502 pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
4504 self.ability_meta
4505 .get(ability_id)
4506 .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
4507 .unwrap_or(self.ground_target.is_some())
4510 }
4511
4512 pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
4514 self.ability_meta
4515 .get(ability_id)
4516 .map(|meta| meta.aim_mode == "ground")
4517 .unwrap_or(false)
4518 }
4519
4520 pub fn ability_auto_rotation_eligible(&self, ability_id: &str) -> bool {
4523 self.ability_meta
4524 .get(ability_id)
4525 .map(|meta| meta.auto_rotation_eligible)
4526 .unwrap_or(true)
4527 }
4528
4529 pub fn set_ground_target(&mut self, x: f32, y: f32) {
4531 self.ground_target = Some((x, y, 0.0));
4532 }
4533
4534 pub fn clear_ground_target(&mut self) {
4536 self.ground_target = None;
4537 }
4538
4539 pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
4542 if !(1..=9).contains(&slot_1_to_9) {
4543 return None;
4544 }
4545 self.hotbar
4546 .get((slot_1_to_9 - 1) as usize)
4547 .and_then(|a| a.as_deref())
4548 .filter(|id| !id.is_empty())
4549 }
4550
4551 pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
4553 let binding = self.hotbar_ability(slot_1_to_9)?;
4554 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
4555 let name = self
4556 .inventory_hints
4557 .get(template_id)
4558 .map(|h| h.display_name.as_str())
4559 .unwrap_or(template_id);
4560 let qty = self.inventory.get(template_id).copied().unwrap_or(0);
4561 Some(format!("{name}×{qty}"))
4562 } else {
4563 Some(binding.to_string())
4564 }
4565 }
4566
4567 pub fn loadout_ability_choices(&self) -> Vec<String> {
4569 let mut out = self.known_abilities.clone();
4570 let weapon = self.weapon_ability_id.trim();
4571 if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
4572 out.push(weapon.to_string());
4573 }
4574 out
4575 }
4576
4577 pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
4579 let mut out = Vec::new();
4580 for ability in self.loadout_ability_choices() {
4581 let meta = if ability == self.weapon_ability_id {
4582 Some("weapon".into())
4583 } else {
4584 None
4585 };
4586 out.push(LoadoutHotbarChoice {
4587 binding: ability.clone(),
4588 label: ability,
4589 meta,
4590 });
4591 }
4592 let mut consumables: Vec<(String, String, u32)> = Vec::new();
4593 for stack in &self.inventory_stacks {
4594 if Self::stack_is_item_grant(stack) {
4595 continue;
4596 }
4597 if self.inventory_item_category(&stack.template_id) != Some("consumable") {
4598 continue;
4599 }
4600 let qty = stack.quantity.max(1);
4601 if let Some((_, _, existing)) = consumables
4602 .iter_mut()
4603 .find(|(id, _, _)| id == &stack.template_id)
4604 {
4605 *existing = existing.saturating_add(qty);
4606 } else {
4607 let label = stack
4608 .display_name
4609 .clone()
4610 .or_else(|| {
4611 self.inventory_hints
4612 .get(&stack.template_id)
4613 .map(|h| h.display_name.clone())
4614 })
4615 .unwrap_or_else(|| stack.template_id.clone());
4616 consumables.push((stack.template_id.clone(), label, qty));
4617 }
4618 }
4619 consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
4620 for (template_id, label, qty) in consumables {
4621 out.push(LoadoutHotbarChoice {
4622 binding: flatland_protocol::hotbar_consumable_binding(&template_id),
4623 label: format!("{label} ×{qty}"),
4624 meta: Some("use".into()),
4625 });
4626 }
4627 out
4628 }
4629
4630 pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
4632 self.combat_candidates()
4633 }
4634
4635 pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
4637 let (px, py) = self.player_position();
4638 let dist = |id: EntityId| {
4639 self.entities
4640 .iter()
4641 .find(|e| e.id == id)
4642 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4643 .unwrap_or(f32::MAX)
4644 };
4645
4646 let mut allies = Vec::new();
4647 if let Some(me) = self.player.as_ref() {
4649 let alive = me
4650 .vitals
4651 .as_ref()
4652 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4653 .unwrap_or(true);
4654 if alive {
4655 allies.push((self.entity_id, "Yourself".into()));
4656 }
4657 }
4658 for entity in &self.entities {
4659 if entity.id == self.entity_id {
4660 continue;
4661 }
4662 if entity.vitals.is_some() {
4663 let alive = entity
4664 .vitals
4665 .as_ref()
4666 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4667 .unwrap_or(true);
4668 if alive {
4669 allies.push((entity.id, entity.label.clone()));
4670 }
4671 }
4672 }
4673 allies.sort_by(|(a, _), (b, _)| {
4674 if *a == self.entity_id {
4675 return std::cmp::Ordering::Less;
4676 }
4677 if *b == self.entity_id {
4678 return std::cmp::Ordering::Greater;
4679 }
4680 dist(*a)
4681 .partial_cmp(&dist(*b))
4682 .unwrap_or(std::cmp::Ordering::Equal)
4683 });
4684
4685 let mut monsters = self.combat_candidates();
4686 monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
4687 allies.into_iter().chain(monsters).collect()
4688 }
4689
4690 fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
4691 match slot_index {
4692 2 => self.t2_candidates(),
4693 _ => self.t1_candidates(),
4694 }
4695 }
4696
4697 pub fn pick_combat_target_at(
4699 &self,
4700 wx: f32,
4701 wy: f32,
4702 slot_index: u8,
4703 radius_m: f32,
4704 ) -> Option<(EntityId, String)> {
4705 let mut best: Option<(f32, EntityId, String)> = None;
4706 for (id, label) in self.candidates_for_slot(slot_index) {
4707 let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
4708 if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
4710 let d = distance(wx, wy, npc.x, npc.y);
4711 if d <= radius_m {
4712 best = match best {
4713 Some((bd, _, _)) if bd <= d => best,
4714 _ => Some((d, id, label)),
4715 };
4716 }
4717 }
4718 continue;
4719 };
4720 let d = distance(
4721 wx,
4722 wy,
4723 entity.transform.position.x,
4724 entity.transform.position.y,
4725 );
4726 if d <= radius_m {
4727 best = match best {
4728 Some((bd, _, _)) if bd <= d => best,
4729 _ => Some((d, id, label)),
4730 };
4731 }
4732 }
4733 best.map(|(_, id, label)| (id, label))
4734 }
4735
4736 pub(crate) fn restore_from_welcome(
4738 &mut self,
4739 session_id: SessionId,
4740 entity_id: EntityId,
4741 snapshot: &flatland_protocol::Snapshot,
4742 ) {
4743 self.clear_harvest_state();
4744 self.disconnect_reason = None;
4745 self.show_stats = false;
4746 self.show_craft_menu = false;
4747 self.show_shop_menu = false;
4748 self.shop_catalog = None;
4749 self.show_inventory_menu = false;
4750 self.session_id = session_id;
4751 self.entity_id = entity_id;
4752 self.connected = true;
4753 self.apply_snapshot_fields(snapshot, entity_id);
4754 if let Some(combat) = &snapshot.combat {
4755 self.apply_combat_hud(combat);
4756 let stacks = self.inventory_stacks.clone();
4757 self.sync_inventory_from_stacks(&stacks);
4758 }
4759 }
4760
4761 fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
4762 self.tick = delta.tick;
4763 self.world_clock = delta.world_clock;
4764
4765 if delta.entities.is_empty() {
4767 self.ground_drops = delta.ground_drops.clone();
4768 self.combat_fx = delta.combat_fx.clone();
4769 self.property_plots = delta.property_plots.clone();
4770 self.apply_terrain_overlays(&delta.terrain_overlays);
4771 if let Some(combat) = &delta.combat {
4772 self.apply_combat_hud(combat);
4773 let stacks = self.inventory_stacks.clone();
4774 self.sync_inventory_from_stacks(&stacks);
4775 }
4776 self.refresh_whisper_range();
4778 return;
4779 }
4780 if !delta.buildings.is_empty() {
4781 self.buildings = delta.buildings.clone();
4782 }
4783 if !delta.blueprints.is_empty() {
4784 self.blueprints = delta.blueprints.clone();
4785 }
4786 if !delta.building_materials.is_empty() {
4787 self.building_materials = delta.building_materials.clone();
4788 }
4789 self.sync_inventory_from_stacks(&delta.inventory);
4790
4791 if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
4792 self.player = Some(updated.clone());
4793 }
4794 self.entities = delta.entities.clone();
4795 if self.player.is_none() {
4796 self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
4797 }
4798
4799 self.sync_interior_map_context();
4800
4801 if !delta.resource_nodes.is_empty() {
4805 self.resource_nodes = delta.resource_nodes.clone();
4806 } else if delta.interior_map.is_some()
4807 || self.effective_inside_building().is_some()
4808 {
4809 self.resource_nodes = delta.resource_nodes.clone();
4810 }
4811 self.ground_drops = delta.ground_drops.clone();
4812 self.placed_containers = delta.placed_containers.clone();
4814 if !delta.doors.is_empty() {
4815 self.doors = delta.doors.clone();
4816 }
4817 if self.effective_inside_building().is_some() {
4818 if let Some(map) = &delta.interior_map {
4819 self.interior_map = Some(map.clone());
4820 }
4821 } else {
4822 self.interior_map = None;
4823 }
4824 self.sync_interior_z_bands();
4825 self.npcs = delta.npcs.clone();
4827 if !delta.quest_log.is_empty() {
4828 self.quest_log = delta.quest_log.clone();
4829 }
4830 self.apply_hired_workers(delta.hired_workers.clone());
4831 if !delta.interactables.is_empty() {
4832 self.interactables = delta.interactables.clone();
4833 }
4834 if delta.ledger.is_some() {
4835 self.ledger = delta.ledger.clone();
4836 }
4837 if delta.career.is_some() {
4838 self.career = delta.career.clone();
4839 }
4840 self.combat_fx = delta.combat_fx.clone();
4841 if !delta.property_plots.is_empty() {
4843 self.property_plots = delta.property_plots.clone();
4844 }
4845 self.apply_terrain_overlays(&delta.terrain_overlays);
4846 if let Some(combat) = &delta.combat {
4847 self.apply_combat_hud(combat);
4848 let stacks = self.inventory_stacks.clone();
4849 self.sync_inventory_from_stacks(&stacks);
4850 } else {
4851 self.refresh_inventory_ui();
4852 }
4853 self.refresh_whisper_range();
4854 }
4855
4856 fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
4859 self.terrain_zones
4860 .retain(|z| !z.id.starts_with("rt:"));
4861 self.terrain_zones.extend(overlays.iter().cloned());
4862 }
4863
4864 fn refresh_whisper_range(&mut self) {
4867 let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
4868 return;
4869 };
4870 let (px, py) = self.player_position();
4871 let in_range = self.entities.iter().any(|e| {
4872 e.id == peer
4873 && distance(
4874 px,
4875 py,
4876 e.transform.position.x,
4877 e.transform.position.y,
4878 ) <= INTERACTION_RADIUS_M
4879 });
4880 if !in_range {
4881 self.social_chat.cancel_whisper_out_of_range();
4882 }
4883 }
4884
4885 pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
4887 let (px, py) = self.player_position();
4888 let mut out = Vec::new();
4889 for npc in &self.npcs {
4890 let Some(eid) = npc.entity_id else {
4891 continue;
4892 };
4893 let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
4894 let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
4895 if alive && has_hp {
4896 out.push((eid, npc.label.clone()));
4897 }
4898 }
4899 out.sort_by(|(a_id, a_label), (b_id, b_label)| {
4900 let dist = |id: EntityId| {
4901 self.entities
4902 .iter()
4903 .find(|e| e.id == id)
4904 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4905 .unwrap_or(f32::MAX)
4906 };
4907 dist(*a_id)
4908 .partial_cmp(&dist(*b_id))
4909 .unwrap_or(std::cmp::Ordering::Equal)
4910 .then_with(|| a_label.cmp(b_label))
4911 .then_with(|| a_id.cmp(b_id))
4912 });
4913 out
4914 }
4915
4916 pub fn refresh_combat_target_label(&mut self) {
4917 let Some(id) = self.combat_target else {
4918 return;
4919 };
4920 if let Some((_, label)) = self
4921 .combat_candidates()
4922 .into_iter()
4923 .find(|(eid, _)| *eid == id)
4924 {
4925 self.combat_target_label = Some(label);
4926 } else if let Some(label) = self
4927 .entities
4928 .iter()
4929 .find(|e| e.id == id)
4930 .map(|e| e.label.clone())
4931 {
4932 self.combat_target_label = Some(label);
4933 }
4934 }
4935
4936 pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
4937 self.quest_log
4938 .iter()
4939 .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
4940 .collect()
4941 }
4942
4943 pub fn has_worker_lodging(&self) -> bool {
4945 self.free_worker_lodging_slots() > 0
4946 }
4947
4948 pub fn free_worker_lodging_slots(&self) -> i64 {
4950 let slots: u32 = self
4951 .placed_containers
4952 .iter()
4953 .filter(|c| match (self.character_id, c.owner_character_id) {
4954 (Some(me), Some(owner)) => me == owner,
4955 (Some(_), None) => false,
4956 (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
4957 })
4958 .map(|c| c.worker_lodging_capacity.unwrap_or(0))
4959 .sum();
4960 let used = self.hired_workers.len() as u32;
4961 slots as i64 - used as i64
4962 }
4963
4964 pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
4966 let mut names: Vec<String> = self
4967 .hired_workers
4968 .iter()
4969 .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
4970 .map(|w| w.label.clone())
4971 .collect();
4972 names.sort();
4973 names
4974 }
4975
4976 pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
4978 let is_lodging = self
4979 .placed_containers
4980 .iter()
4981 .find(|c| c.id == container_id)
4982 .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
4983 if !is_lodging {
4984 return None;
4985 }
4986 let names = self.lodging_occupant_labels(container_id);
4987 Some(if names.is_empty() {
4988 "vacant".into()
4989 } else {
4990 names.join(", ")
4991 })
4992 }
4993
4994 pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
4995 self.quest_log
4996 .iter()
4997 .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
4998 .or_else(|| {
4999 self.quest_log
5000 .iter()
5001 .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
5002 })
5003 }
5004
5005 pub fn nearby_lockable_door(&self) -> bool {
5007 let (px, py) = self.player_position();
5008 self.doors
5009 .iter()
5010 .any(|d| d.lock_id.is_some() && (d.x - px).hypot(d.y - py) <= 3.5)
5011 }
5012
5013 pub fn nearby_open_player_door(&self) -> bool {
5015 if self.effective_inside_building().is_some() {
5016 return false;
5017 }
5018 let (px, py) = self.player_position();
5019 self.doors.iter().any(|d| {
5020 if !d.open || d.locked {
5021 return false;
5022 }
5023 let player_house = self
5024 .buildings
5025 .iter()
5026 .find(|b| b.id == d.building_id)
5027 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
5028 player_house && (d.x - px).hypot(d.y - py) <= 3.5
5029 })
5030 }
5031
5032 pub fn nearby_player_exit_door(&self) -> bool {
5034 let Some(bid) = self.effective_inside_building() else {
5035 return false;
5036 };
5037 let (px, py) = self.player_position();
5038 self.doors.iter().any(|d| {
5039 if d.building_id != bid || d.portal.is_none() {
5040 return false;
5041 }
5042 let player_house = self
5043 .buildings
5044 .iter()
5045 .find(|b| b.id == d.building_id)
5046 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
5047 player_house && (d.x - px).hypot(d.y - py) <= 1.5
5048 })
5049 }
5050
5051 pub fn nearest_interact_target(&self) -> Option<String> {
5053 let (px, py) = self.player_position();
5054 let inside = self.effective_inside_building();
5055
5056 #[derive(Clone, Copy, PartialEq, Eq)]
5057 enum Kind {
5058 Player,
5059 Npc,
5060 HiredWorker,
5061 QuestBoard,
5062 ExitDoor,
5063 EnterDoor,
5064 Well,
5065 Water,
5066 }
5067
5068 fn kind_priority(kind: Kind) -> u8 {
5069 match kind {
5070 Kind::Player => 0,
5071 Kind::Npc => 0,
5072 Kind::HiredWorker => 0,
5073 Kind::QuestBoard => 1,
5074 Kind::ExitDoor => 2,
5075 Kind::EnterDoor => 3,
5076 Kind::Well => 4,
5077 Kind::Water => 5,
5078 }
5079 }
5080
5081 let mut best: Option<(f32, Kind, String)> = None;
5082
5083 let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
5084 if dist > max {
5085 return;
5086 }
5087 let replace = match best {
5088 None => true,
5089 Some((bd, _bk, _)) if dist < bd - 0.05 => true,
5090 Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
5091 kind_priority(kind) < kind_priority(bk)
5092 }
5093 _ => false,
5094 };
5095 if replace {
5096 best = Some((dist, kind, id));
5097 }
5098 };
5099
5100 for npc in &self.npcs {
5101 consider(
5102 distance(px, py, npc.x, npc.y),
5103 INTERACTION_RADIUS_M,
5104 Kind::Npc,
5105 npc.id.clone(),
5106 );
5107 }
5108
5109 for worker in &self.hired_workers {
5110 consider(
5111 distance(px, py, worker.x, worker.y),
5112 INTERACTION_RADIUS_M,
5113 Kind::HiredWorker,
5114 worker.instance_id.clone(),
5115 );
5116 }
5117
5118 for entity in &self.entities {
5119 if entity.id == self.entity_id || entity.vitals.is_none() || entity.label.trim().is_empty()
5120 {
5121 continue;
5122 }
5123 if self
5125 .hired_workers
5126 .iter()
5127 .any(|w| w.entity_id == entity.id)
5128 {
5129 continue;
5130 }
5131 consider(
5132 distance(
5133 px,
5134 py,
5135 entity.transform.position.x,
5136 entity.transform.position.y,
5137 ),
5138 INTERACTION_RADIUS_M,
5139 Kind::Player,
5140 entity.id.to_string(),
5141 );
5142 }
5143
5144 for door in &self.doors {
5145 if let Some(ref bid) = inside {
5146 if door.building_id != *bid {
5147 continue;
5148 }
5149 let is_exit = door.portal.is_some();
5150 let max = if is_exit {
5151 INTERACTION_RADIUS_M
5152 } else {
5153 DOOR_INTERACTION_RADIUS_M
5154 };
5155 let kind = if is_exit {
5156 Kind::ExitDoor
5157 } else {
5158 Kind::EnterDoor
5159 };
5160 consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
5161 continue;
5162 }
5163 consider(
5164 distance(px, py, door.x, door.y),
5165 DOOR_INTERACTION_RADIUS_M,
5166 Kind::EnterDoor,
5167 door.id.clone(),
5168 );
5169 }
5170
5171 if inside.is_none() {
5172 for inter in &self.interactables {
5173 if inter.kind == "quest_board" {
5174 consider(
5175 distance(px, py, inter.x, inter.y),
5176 QUEST_BOARD_INTERACTION_RADIUS_M,
5177 Kind::QuestBoard,
5178 inter.id.clone(),
5179 );
5180 }
5181 }
5182 for building in &self.buildings {
5183 if !building.tags.iter().any(|t| t == "well") {
5184 continue;
5185 }
5186 consider(
5187 distance(px, py, building.x, building.y),
5188 INTERACTION_RADIUS_M,
5189 Kind::Well,
5190 building.id.clone(),
5191 );
5192 }
5193 if self.in_shallow_water() {
5194 consider(
5195 0.0,
5196 INTERACTION_RADIUS_M,
5197 Kind::Water,
5198 "water_source".into(),
5199 );
5200 }
5201 }
5202
5203 best.map(|(_, _, id)| id)
5204 }
5205
5206 pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
5208 if self.effective_inside_building().is_some() {
5209 return None;
5210 }
5211 let (px, py) = self.player_position();
5212 self.interactables
5213 .iter()
5214 .filter(|i| i.kind == "quest_board")
5215 .map(|i| {
5216 let label = if i.label.is_empty() {
5217 "Quest board".to_string()
5218 } else {
5219 i.label.clone()
5220 };
5221 (label, distance(px, py, i.x, i.y))
5222 })
5223 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
5224 }
5225
5226 pub fn template_display_name(&self, template_id: &str) -> String {
5228 self.inventory_hints
5229 .get(template_id)
5230 .map(|h| h.display_name.clone())
5231 .filter(|n| !n.is_empty())
5232 .unwrap_or_else(|| humanize_template_id(template_id))
5233 }
5234
5235 pub fn blueprint_item_label(&self, template_id: &str, display_name: &str) -> String {
5237 if !display_name.is_empty() {
5238 display_name.to_string()
5239 } else {
5240 self.template_display_name(template_id)
5241 }
5242 }
5243
5244 pub fn blueprint_output_label(&self, blueprint: &BlueprintView) -> String {
5245 self.blueprint_item_label(&blueprint.output, &blueprint.output_display_name)
5246 }
5247
5248 pub fn blueprint_ingredient_label(
5249 &self,
5250 input: &flatland_protocol::BlueprintIngredientView,
5251 ) -> String {
5252 self.blueprint_item_label(&input.template_id, &input.display_name)
5253 }
5254
5255 pub fn blueprint_tool_label(&self, tool: &flatland_protocol::ToolRequirementView) -> String {
5256 self.blueprint_item_label(&tool.item, &tool.display_name)
5257 }
5258
5259 pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
5261 use crate::worker_route_editor::{
5262 node_candidates, node_candidates_stable, route_editor_lodging_anchor,
5263 };
5264 let lodging = self
5265 .worker_route_editor
5266 .as_ref()
5267 .and_then(|ed| ed.lodging_container_id.as_deref());
5268 match route_editor_lodging_anchor(lodging, &self.placed_containers) {
5269 Some((ax, ay)) => node_candidates(&self.resource_nodes, ax, ay),
5270 None => node_candidates_stable(&self.resource_nodes),
5271 }
5272 }
5273
5274 pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
5275 if dist_m.is_nan() {
5276 return "—".into();
5277 }
5278 let from_bed = self
5279 .worker_route_editor
5280 .as_ref()
5281 .and_then(|ed| ed.lodging_container_id.as_deref())
5282 .and_then(|id| {
5283 self.placed_containers
5284 .iter()
5285 .find(|c| c.id == id)
5286 .map(|c| c.display_name.clone())
5287 });
5288 match from_bed {
5289 Some(bed) => format!("{dist_m:.0}m from {bed}"),
5290 None => format!("{dist_m:.0}m"),
5291 }
5292 }
5293
5294 pub fn placed_container_public_label(
5296 &self,
5297 c: &flatland_protocol::PlacedContainerView,
5298 ) -> String {
5299 let is_owner = match (self.character_id, c.owner_character_id) {
5300 (Some(me), Some(owner)) => me == owner,
5301 _ => false,
5302 };
5303 if is_owner {
5304 c.display_name.clone()
5305 } else {
5306 self.template_display_name(&c.template_id)
5307 }
5308 }
5309
5310 pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
5312 let mut out = Vec::new();
5313 for stack in &self.inventory_stacks {
5314 if stack.template_id == KEY_TEMPLATE {
5315 out.push(KeychainEntry {
5316 stack: stack.clone(),
5317 stowed: false,
5318 });
5319 }
5320 }
5321 for stack in &self.keychain_stacks {
5322 if stack.template_id == KEY_TEMPLATE {
5323 out.push(KeychainEntry {
5324 stack: stack.clone(),
5325 stowed: true,
5326 });
5327 }
5328 }
5329 out
5330 }
5331
5332 pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
5334 if stack.template_id != KEY_TEMPLATE {
5335 return None;
5336 }
5337 if let Some(name) = stack
5338 .props
5339 .get(PROP_OPENS_CONTAINER_NAME)
5340 .filter(|n| !n.is_empty())
5341 {
5342 return Some(name.clone());
5343 }
5344 let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
5345 self.container_name_for_lock_id(opens)
5346 }
5347
5348 pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
5350 if stack.template_id == KEY_TEMPLATE {
5351 self.template_display_name(KEY_TEMPLATE)
5352 } else {
5353 stack
5354 .display_name
5355 .clone()
5356 .unwrap_or_else(|| stack.template_id.clone())
5357 }
5358 }
5359
5360 pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
5362 if stack.template_id != KEY_TEMPLATE {
5363 return String::new();
5364 }
5365 match self.key_pair_chest_label(stack) {
5366 Some(chest) if self.key_drop_blocked(stack) => {
5367 format!(" [key for {chest} — can't drop while locked]")
5368 }
5369 Some(chest) => format!(" [key for {chest}]"),
5370 None => " [key — unpaired]".into(),
5371 }
5372 }
5373
5374 pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
5376 for c in &self.placed_containers {
5377 if c.lock_id.as_deref() == Some(lock) {
5378 return Some(c.display_name.clone());
5379 }
5380 }
5381 Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
5382 self.worn
5383 .values()
5384 .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
5385 })
5386 }
5387
5388 pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
5390 if stack.template_id != KEY_TEMPLATE {
5391 return false;
5392 }
5393 let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
5394 return false;
5395 };
5396 for c in &self.placed_containers {
5397 if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
5398 return true;
5399 }
5400 }
5401 if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
5402 return true;
5403 }
5404 self.worn
5405 .values()
5406 .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
5407 }
5408
5409 pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
5411 stack.template_id == PROPERTY_DEED_TEMPLATE
5412 }
5413
5414 pub fn is_property_deed_template(template_id: &str) -> bool {
5415 template_id == PROPERTY_DEED_TEMPLATE
5416 }
5417
5418 pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
5419 stack
5420 .props
5421 .get("plot_id")
5422 .and_then(|s| uuid::Uuid::parse_str(s).ok())
5423 }
5424
5425 pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
5427 let (px, py) = self.player_position();
5428 let (cx, cy) = self.farm_plot_cell_under_player()?;
5429 let tx = cx as f32 + 0.5;
5430 let ty = cy as f32 + 0.5;
5431 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5432 return None;
5433 }
5434 let kind = self
5435 .terrain_at(tx, ty)
5436 .or_else(|| self.terrain_at(px, py));
5437 if kind == Some(TerrainKindView::Tilled) {
5438 return None;
5439 }
5440 if matches!(
5441 kind,
5442 Some(TerrainKindView::ShallowWater)
5443 | Some(TerrainKindView::DeepWater)
5444 | Some(TerrainKindView::Rock)
5445 ) {
5446 return None;
5447 }
5448 Some((tx, ty))
5449 }
5450
5451 fn container_name_in_stacks(
5452 stacks: &[flatland_protocol::ItemStack],
5453 lock: &str,
5454 ) -> Option<String> {
5455 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
5456 for s in stacks {
5457 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
5458 return Some(GameState::stack_container_label(s));
5459 }
5460 if let Some(name) = walk(&s.contents, lock) {
5461 return Some(name);
5462 }
5463 }
5464 None
5465 }
5466 walk(stacks, lock)
5467 }
5468
5469 fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
5470 stack
5471 .props
5472 .get(PROP_CUSTOM_NAME)
5473 .cloned()
5474 .or_else(|| stack.display_name.clone())
5475 .unwrap_or_else(|| stack.template_id.clone())
5476 }
5477
5478 fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5479 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5480 for s in stacks {
5481 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
5482 return true;
5483 }
5484 if walk(&s.contents, lock) {
5485 return true;
5486 }
5487 }
5488 false
5489 }
5490 walk(stacks, lock)
5491 }
5492
5493 fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
5494 if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
5495 return Some(stack.clone());
5496 }
5497 for worn in self.worn.values() {
5498 if worn.item_instance_id == Some(instance_id) {
5499 return Some(worn.clone());
5500 }
5501 if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
5502 return Some(stack.clone());
5503 }
5504 }
5505 None
5506 }
5507
5508 pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
5510 self.property_zones
5511 .iter()
5512 .enumerate()
5513 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5514 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5515 .map(|(_, z)| z)
5516 }
5517
5518 pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
5520 self.tax_zones
5521 .iter()
5522 .enumerate()
5523 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5524 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5525 .map(|(_, z)| z)
5526 }
5527
5528 pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
5530 let mut max_bps = 0u32;
5531 let mut y = y0 + 0.5;
5532 while y < y1 {
5533 let mut x = x0 + 0.5;
5534 while x < x1 {
5535 if let Some(tz) = self.tax_zone_at(x, y) {
5536 max_bps = max_bps.max(tz.rate_bps);
5537 }
5538 x += 1.0;
5539 }
5540 y += 1.0;
5541 }
5542 max_bps
5543 }
5544
5545 pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5547 let mode = self.claim_mode.as_ref()?;
5548 let w = mode.width_m.max(1) as f32;
5549 let h = mode.height_m.max(1) as f32;
5550 Some((mode.anchor_x, mode.anchor_y, mode.anchor_x + w, mode.anchor_y + h))
5551 }
5552
5553 pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5555 let mode = self.relocate_mode.as_ref()?;
5556 let x0 = mode.cursor_x.floor();
5557 let y0 = mode.cursor_y.floor();
5558 Some((x0, y0, x0 + 1.0, y0 + 1.0))
5559 }
5560
5561 pub fn claim_quote(
5564 &self,
5565 ) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
5566 let mode = self.claim_mode.as_ref()?;
5567 let zone = self
5568 .property_zones
5569 .iter()
5570 .find(|z| z.id == mode.zone_id)?;
5571 let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
5572 let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
5573 let zone_area = zone_view_area_m2(zone).max(1.0);
5574 let area_frac = (area / zone_area).clamp(0.0, 1.0);
5575 let weight = self
5576 .property_plot_settings
5577 .as_ref()
5578 .map(|s| s.tax_premium_weight)
5579 .unwrap_or(0.5)
5580 .max(0.0);
5581 let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
5582 let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
5583 let purchase = ((zone.crown_price_copper as f64)
5584 * (area_frac as f64)
5585 * (premium as f64))
5586 .ceil()
5587 .max(0.0) as u64;
5588 let upkeep = if zone.upkeep_copper_per_day == 0 {
5589 0
5590 } else {
5591 ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
5592 .ceil()
5593 .max(1.0) as u64
5594 };
5595 let copper = crate::currency::copper_from_counts(&self.inventory);
5596 let can_afford = copper >= purchase;
5597 let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
5598 Some((purchase, upkeep, area, premium, can_afford, valid, reason))
5599 }
5600
5601 fn validate_claim_footprint(
5602 &self,
5603 zone: &flatland_protocol::PropertyZoneView,
5604 x0: f32,
5605 y0: f32,
5606 x1: f32,
5607 y1: f32,
5608 area: f32,
5609 ) -> (bool, String) {
5610 let min_area = self
5611 .property_plot_settings
5612 .as_ref()
5613 .map(|s| s.min_plot_area_m2)
5614 .unwrap_or(4.0);
5615 if area + f32::EPSILON < min_area {
5616 return (false, "plot too small".into());
5617 }
5618 if zone.max_area_m2.is_some_and(|m| area > m) {
5619 return (false, "plot exceeds max area".into());
5620 }
5621 if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
5622 return (false, "plot must lie inside the property zone".into());
5623 }
5624 if self.property_plots.iter().any(|p| {
5625 rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1)
5626 }) {
5627 return (false, "plot overlaps an existing claim".into());
5628 }
5629 (true, String::new())
5630 }
5631
5632 pub fn free_property_zone_under_player(
5634 &self,
5635 ) -> Option<&flatland_protocol::PropertyZoneView> {
5636 let (px, py) = self.player_position();
5637 let zone = self.property_zone_at(px, py)?;
5638 if self
5639 .property_plots
5640 .iter()
5641 .any(|p| point_in_plot(px, py, p))
5642 {
5643 return None;
5644 }
5645 Some(zone)
5646 }
5647
5648 pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5650 let (px, py) = self.player_position();
5651 self.property_plots
5652 .iter()
5653 .find(|p| p.is_mine && point_in_plot(px, py, p))
5654 }
5655
5656 pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5658 let (px, py) = self.player_position();
5659 self.property_plots
5660 .iter()
5661 .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
5662 }
5663
5664 pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
5666 if self.farmable_plot_under_player().is_none() {
5667 return None;
5668 }
5669 let (px, py) = self.player_position();
5670 Some((px.floor() as i32, py.floor() as i32))
5671 }
5672
5673 fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
5674 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5675 self.resource_nodes.iter().any(|n| {
5676 let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
5677 ncx == cx && ncy == cy
5678 || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
5679 })
5680 }
5681
5682 fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
5683 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5684 let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
5685 || self
5686 .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
5687 .is_some_and(|z| z.kind == TerrainKindView::Tilled);
5688 if !tilled {
5689 return false;
5690 }
5691 !self.resource_node_occupies_farm_cell(cx, cy)
5692 }
5693
5694 pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
5696 let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
5697 return false;
5698 };
5699 self.free_tilled_plant_slot_at(cx, cy)
5700 }
5701
5702 pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
5704 let (px, py) = self.player_position();
5705 for dy in -2..=2 {
5706 for dx in -2..=2 {
5707 let cx = px.floor() as i32 + dx;
5708 let cy = py.floor() as i32 + dy;
5709 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5710 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5711 continue;
5712 }
5713 if self.free_tilled_plant_slot_at(cx, cy) {
5714 return true;
5715 }
5716 }
5717 }
5718 false
5719 }
5720
5721 fn stack_is_farm_seed(stack: &flatland_protocol::ItemStack) -> bool {
5722 stack.quantity > 0
5723 && (stack.props.contains_key("seed_for")
5724 || stack.template_id.ends_with("_seed")
5725 || stack.template_id == "potato_seed"
5726 || stack.template_id == "carrot_seed")
5727 }
5728
5729 pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
5731 let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
5732 fn walk(
5733 stacks: &[flatland_protocol::ItemStack],
5734 counts: &mut std::collections::HashMap<String, u32>,
5735 ) {
5736 for s in stacks {
5737 if GameState::stack_is_farm_seed(s) {
5738 *counts.entry(s.template_id.clone()).or_default() += s.quantity;
5739 }
5740 walk(&s.contents, counts);
5741 }
5742 }
5743 walk(&self.inventory_stacks, &mut counts);
5744 for worn in self.worn.values() {
5745 walk(std::slice::from_ref(worn), &mut counts);
5746 }
5747 let mut out: Vec<_> = counts
5748 .into_iter()
5749 .map(|(template_id, quantity)| {
5750 let label = self
5751 .inventory_hints
5752 .get(&template_id)
5753 .map(|h| h.display_name.clone())
5754 .filter(|n| !n.trim().is_empty())
5755 .unwrap_or_else(|| humanize_template_id(&template_id));
5756 (template_id, quantity, label)
5757 })
5758 .collect();
5759 out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
5760 out
5761 }
5762
5763 pub fn first_farm_seed_template(&self) -> Option<String> {
5765 self.farm_seed_entries()
5766 .into_iter()
5767 .next()
5768 .map(|(id, _, _)| id)
5769 }
5770
5771 pub fn clamp_plant_menu(&mut self) {
5772 let n = self.farm_seed_entries().len();
5773 if n == 0 {
5774 self.plant_menu_index = 0;
5775 self.plant_quantity = 1;
5776 return;
5777 }
5778 self.plant_menu_index = self.plant_menu_index.min(n - 1);
5779 let max_qty = self
5780 .farm_seed_entries()
5781 .get(self.plant_menu_index)
5782 .map(|(_, q, _)| *q)
5783 .unwrap_or(1)
5784 .max(1);
5785 self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
5786 }
5787
5788 pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
5789 let entries = self.farm_seed_entries();
5790 let (id, max, label) = entries.get(self.plant_menu_index)?;
5791 let qty = self.plant_quantity.min(*max).max(1);
5792 Some((id.clone(), qty, label.clone()))
5793 }
5794
5795 pub fn location_context_lines(&self) -> Vec<ContextLine> {
5797 let (px, py) = self.player_position();
5798 let inside = self.effective_inside_building();
5799 let mut lines = Vec::new();
5800
5801 if let Some(kind) = self.terrain_at(px, py) {
5802 lines.push(ContextLine {
5803 on_top: true,
5804 text: format!("Terrain: {}", terrain_kind_label(kind)),
5805 });
5806 }
5807
5808 if let Some(id) = inside.as_ref() {
5809 if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
5810 lines.push(ContextLine {
5811 on_top: true,
5812 text: format!("Inside: {}", b.label),
5813 });
5814 }
5815 }
5816
5817 let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
5818
5819 for node in &self.resource_nodes {
5820 if node.id.starts_with("preview:") {
5821 continue;
5822 }
5823 let dist = distance(px, py, node.x, node.y);
5824 if dist > NEARBY_SCAN_M {
5825 continue;
5826 }
5827 let on_top = dist <= ON_TOP_RADIUS_M;
5828 let prefix = if on_top { "On" } else { "Near" };
5829 let name = resource_node_near_display_label(&node.label);
5830 let action = resource_node_near_action_suffix(node);
5831 nearby.push((
5832 dist,
5833 ContextLine {
5834 on_top,
5835 text: format!("{prefix}: {name} ({dist:.1}m){action}"),
5836 },
5837 ));
5838 }
5839
5840 for drop in &self.ground_drops {
5841 let dist = distance(px, py, drop.x, drop.y);
5842 if dist > INTERACTION_RADIUS_M {
5843 continue;
5844 }
5845 let on_top = dist <= ON_TOP_RADIUS_M;
5846 let name = self.template_display_name(&drop.template_id);
5847 let prefix = if on_top { "On" } else { "Near" };
5848 let qty = if drop.quantity > 1 {
5849 format!(" ×{}", drop.quantity)
5850 } else {
5851 String::new()
5852 };
5853 nearby.push((
5854 dist,
5855 ContextLine {
5856 on_top,
5857 text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
5858 },
5859 ));
5860 }
5861
5862 for c in &self.placed_containers {
5863 if !self.placed_container_in_current_space(c) {
5864 continue;
5865 }
5866 let dist = distance(px, py, c.x, c.y);
5867 if dist > CONTAINER_RANGE_M {
5868 continue;
5869 }
5870 let on_top = dist <= ON_TOP_RADIUS_M;
5871 let name = self.placed_container_public_label(c);
5872 let lock = if c.locked { " [locked]" } else { "" };
5873 let prefix = if on_top { "On" } else { "Near" };
5874 nearby.push((
5875 dist,
5876 ContextLine {
5877 on_top,
5878 text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
5879 },
5880 ));
5881 }
5882
5883 for npc in &self.npcs {
5884 let dist = distance(px, py, npc.x, npc.y);
5885 if dist > NEARBY_SCAN_M {
5886 continue;
5887 }
5888 let on_top = dist <= ON_TOP_RADIUS_M;
5889 let prefix = if on_top { "On" } else { "Near" };
5890 nearby.push((
5891 dist,
5892 ContextLine {
5893 on_top,
5894 text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
5895 },
5896 ));
5897 }
5898
5899 for door in &self.doors {
5900 let dist = distance(px, py, door.x, door.y);
5901 if dist > DOOR_INTERACTION_RADIUS_M {
5902 continue;
5903 }
5904 let building = self
5905 .buildings
5906 .iter()
5907 .find(|b| b.id == door.building_id)
5908 .map(|b| b.label.as_str())
5909 .unwrap_or(door.building_id.as_str());
5910 let player_house = self
5911 .buildings
5912 .iter()
5913 .find(|b| b.id == door.building_id)
5914 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
5915 let action = if inside.is_some() && door.portal.is_some() {
5916 if player_house {
5917 if door.locked {
5918 "locked — l unlock · Enter exit".to_string()
5919 } else if door.open {
5920 "close · Enter exit · l lock".to_string()
5921 } else {
5922 "open · Enter exit · l lock".to_string()
5923 }
5924 } else {
5925 "exit".to_string()
5926 }
5927 } else if player_house {
5928 if door.locked {
5929 "locked — l unlock".to_string()
5930 } else if door.open {
5931 "close · Enter go inside · l lock".to_string()
5932 } else {
5933 "open · l lock".to_string()
5934 }
5935 } else {
5936 "enter".to_string()
5937 };
5938 nearby.push((
5939 dist,
5940 ContextLine {
5941 on_top: dist <= ON_TOP_RADIUS_M,
5942 text: format!("{building} door ({dist:.1}m) — f {action}"),
5943 },
5944 ));
5945 }
5946
5947 if inside.is_none() {
5948 for inter in &self.interactables {
5949 if inter.kind != "quest_board" {
5950 continue;
5951 }
5952 let dist = distance(px, py, inter.x, inter.y);
5953 if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
5954 continue;
5955 }
5956 let on_top = dist <= ON_TOP_RADIUS_M;
5957 let prefix = if on_top { "On" } else { "Near" };
5958 let label = if inter.label.is_empty() {
5959 "Quest board".to_string()
5960 } else {
5961 inter.label.clone()
5962 };
5963 nearby.push((
5964 dist,
5965 ContextLine {
5966 on_top,
5967 text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
5968 },
5969 ));
5970 }
5971 }
5972
5973 if self.in_shallow_water() {
5974 let already = self
5975 .terrain_at(px, py)
5976 .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
5977 if !already {
5978 nearby.push((
5979 0.0,
5980 ContextLine {
5981 on_top: true,
5982 text: "Shallow water — f fill bottle".into(),
5983 },
5984 ));
5985 } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
5986 line.text.push_str(" — f fill bottle");
5987 }
5988 }
5989
5990 if self.claim_mode.is_some() {
5991 nearby.push((
5992 0.0,
5993 ContextLine {
5994 on_top: true,
5995 text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
5996 .into(),
5997 },
5998 ));
5999 } else if let Some(plot) = self.my_plot_under_player() {
6000 let name = plot_public_label(plot);
6001 let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
6002 format!("{name} — f again to sell to crown")
6003 } else {
6004 format!(
6005 "{name} — Shift+c till · p plant · f harvest · B build · l door lock · o farm access · Shift+n rename"
6006 )
6007 };
6008 nearby.push((
6009 0.0,
6010 ContextLine {
6011 on_top: true,
6012 text: prompt,
6013 },
6014 ));
6015 } else if let Some(plot) = self.farmable_plot_under_player() {
6016 let name = plot_public_label(plot);
6017 let disc = if plot.farm_public {
6018 plot.public_tax_discount_bps / 100
6019 } else {
6020 plot.farm_allow
6021 .iter()
6022 .find(|g| Some(g.character_id) == self.character_id)
6023 .map(|g| g.tax_discount_bps / 100)
6024 .unwrap_or(0)
6025 };
6026 nearby.push((
6027 0.0,
6028 ContextLine {
6029 on_top: true,
6030 text: format!(
6031 "{name} (farming · tax −{disc}%) — Shift+c till · p plant · f harvest"
6032 ),
6033 },
6034 ));
6035 } else if let Some(zone) = self.free_property_zone_under_player() {
6036 let label = zone
6037 .label
6038 .as_deref()
6039 .filter(|s| !s.trim().is_empty())
6040 .unwrap_or(zone.id.as_str());
6041 nearby.push((
6042 0.0,
6043 ContextLine {
6044 on_top: true,
6045 text: format!("Claimable land: {label} — k buy plot"),
6046 },
6047 ));
6048 }
6049
6050 for entity in &self.entities {
6051 if entity.id == self.entity_id {
6052 continue;
6053 }
6054 let dist = distance(
6055 px,
6056 py,
6057 entity.transform.position.x,
6058 entity.transform.position.y,
6059 );
6060 if dist > NEARBY_SCAN_M {
6061 continue;
6062 }
6063 let label = if entity.label.is_empty() {
6064 format!("entity {}", entity.id)
6065 } else {
6066 entity.label.clone()
6067 };
6068 nearby.push((
6069 dist,
6070 ContextLine {
6071 on_top: dist <= ON_TOP_RADIUS_M,
6072 text: format!("Near: {label} ({dist:.1}m)"),
6073 },
6074 ));
6075 }
6076
6077 nearby.sort_by(|a, b| {
6078 a.0.partial_cmp(&b.0)
6079 .unwrap_or(std::cmp::Ordering::Equal)
6080 .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
6081 });
6082 lines.extend(nearby.into_iter().map(|(_, l)| l));
6083
6084 if lines.is_empty() {
6085 lines.push(ContextLine {
6086 on_top: false,
6087 text: "(nothing notable nearby)".into(),
6088 });
6089 }
6090
6091 lines
6092 }
6093}
6094
6095#[derive(Debug, Clone)]
6097pub struct ContextLine {
6098 pub on_top: bool,
6099 pub text: String,
6100}
6101
6102const ON_TOP_RADIUS_M: f32 = 0.65;
6103const NEARBY_SCAN_M: f32 = 5.0;
6104
6105pub fn resource_node_near_display_label(label: &str) -> String {
6107 label
6108 .strip_suffix(" (growing)")
6109 .unwrap_or(label)
6110 .to_string()
6111}
6112
6113fn resource_label_looks_like_raw_id(label: &str, id: &str) -> bool {
6114 let t = label.trim();
6115 if t.is_empty() || t == id {
6116 return true;
6117 }
6118 let lower = t.to_ascii_lowercase();
6119 if lower.contains("_copy") {
6120 return true;
6121 }
6122 false
6123}
6124
6125fn humanize_item_template_label(template: &str) -> String {
6126 let base = template.rsplit('/').next().unwrap_or(template).trim();
6127 if base.is_empty() {
6128 return "Resource".into();
6129 }
6130 let stripped = base
6131 .strip_prefix("crop-")
6132 .or_else(|| base.strip_prefix("crop_"))
6133 .unwrap_or(base);
6134 stripped
6135 .split(|c: char| c == '-' || c == '_')
6136 .filter(|p| !p.is_empty())
6137 .map(|p| {
6138 let mut chars = p.chars();
6139 match chars.next() {
6140 Some(c) => format!("{}{}", c.to_ascii_uppercase(), chars.as_str()),
6141 None => String::new(),
6142 }
6143 })
6144 .collect::<Vec<_>>()
6145 .join(" ")
6146}
6147
6148pub fn resource_node_id_suffix(id: &str) -> String {
6150 let chars: Vec<char> = id
6151 .chars()
6152 .rev()
6153 .filter(|c| c.is_ascii_alphanumeric())
6154 .take(4)
6155 .collect();
6156 chars.into_iter().rev().collect()
6157}
6158
6159pub fn resource_node_route_label(node: &flatland_protocol::ResourceNodeView) -> String {
6161 resource_node_route_label_parts(&node.id, &node.label, &node.item_template)
6162}
6163
6164pub fn resource_node_route_label_parts(id: &str, label: &str, item_template: &str) -> String {
6165 let cleaned = resource_node_near_display_label(label);
6166 let friendly = if !resource_label_looks_like_raw_id(&cleaned, id) {
6167 cleaned
6168 } else if !item_template.trim().is_empty() {
6169 humanize_item_template_label(item_template)
6170 } else {
6171 id.to_string()
6172 };
6173 let suffix = resource_node_id_suffix(id);
6174 if suffix.is_empty() {
6175 friendly
6176 } else {
6177 format!("{friendly} ({suffix})")
6178 }
6179}
6180
6181pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
6183 use flatland_protocol::ResourceNodeState;
6184 if node.harvest_off {
6185 return " (decorative)".to_string();
6186 }
6187 if let Some(p) = node.growth_progress {
6188 if p < 1.0 - f32::EPSILON {
6189 let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
6190 return format!(" (growing, {pct}%)");
6191 }
6192 return " — f harvest".to_string();
6193 }
6194 match node.state {
6195 ResourceNodeState::Available => " — f harvest".to_string(),
6196 ResourceNodeState::Harvesting => " (being harvested)".to_string(),
6197 ResourceNodeState::Cooldown => " (depleted)".to_string(),
6198 }
6199}
6200
6201fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
6202 use flatland_protocol::TerrainKindView;
6203 match kind {
6204 TerrainKindView::Grass => "Grass",
6205 TerrainKindView::Dirt => "Dirt",
6206 TerrainKindView::Tilled => "Tilled",
6207 TerrainKindView::Desert => "Desert",
6208 TerrainKindView::Hill => "Hills",
6209 TerrainKindView::Bog => "Bog",
6210 TerrainKindView::Beach => "Beach",
6211 TerrainKindView::ShallowWater => "Shallow water",
6212 TerrainKindView::DeepWater => "Deep water",
6213 TerrainKindView::Trail => "Trail",
6214 TerrainKindView::Road => "Road",
6215 TerrainKindView::Rock => "Rock",
6216 }
6217}
6218
6219fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
6220 crate::world_zones::zone_rects_contain(rects, x, y)
6221}
6222
6223fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
6224 zone.rects
6225 .iter()
6226 .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
6227 .sum()
6228}
6229
6230fn claim_rect_fully_inside_zone(
6231 zone: &flatland_protocol::PropertyZoneView,
6232 x0: f32,
6233 y0: f32,
6234 x1: f32,
6235 y1: f32,
6236) -> bool {
6237 let mut y = y0 + 0.5;
6238 while y < y1 {
6239 let mut x = x0 + 0.5;
6240 while x < x1 {
6241 if !zone_rects_contain(&zone.rects, x, y) {
6242 return false;
6243 }
6244 x += 1.0;
6245 }
6246 y += 1.0;
6247 }
6248 true
6249}
6250
6251fn rects_overlap_half_open(
6252 ax0: f32,
6253 ay0: f32,
6254 ax1: f32,
6255 ay1: f32,
6256 bx0: f32,
6257 by0: f32,
6258 bx1: f32,
6259 by1: f32,
6260) -> bool {
6261 ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
6262}
6263
6264fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
6265 x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
6266}
6267
6268fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
6269 plot_public_label(p)
6270}
6271
6272pub fn plot_public_label(p: &flatland_protocol::PropertyPlotView) -> String {
6274 let zone = p
6275 .zone_label
6276 .as_deref()
6277 .filter(|s| !s.trim().is_empty())
6278 .unwrap_or_else(|| {
6279 if p.property_zone_id.is_empty() {
6280 "Homestead"
6281 } else {
6282 p.property_zone_id.as_str()
6283 }
6284 });
6285 let label = if p.label.trim().is_empty() {
6286 if p.plot_code.trim().is_empty() {
6287 p.plot_id.to_string()[..8.min(p.plot_id.to_string().len())].to_string()
6288 } else {
6289 p.plot_code.clone()
6290 }
6291 } else {
6292 p.label.clone()
6293 };
6294 match p.owner_label.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
6295 Some(owner) => format!("{owner} — {zone} — {label}"),
6296 None => format!("{zone} — {label}"),
6297 }
6298}
6299
6300fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
6302 let a = x0.min(x1).floor();
6303 let b = y0.min(y1).floor();
6304 let mut c = x0.max(x1).ceil();
6305 let mut d = y0.max(y1).ceil();
6306 if (c - a) < 1.0 {
6307 c = a + 1.0;
6308 }
6309 if (d - b) < 1.0 {
6310 d = b + 1.0;
6311 }
6312 (a, b, c, d)
6313}
6314
6315fn humanize_template_id(template_id: &str) -> String {
6316 if looks_like_template_uuid(template_id) {
6318 return "Unknown item".into();
6319 }
6320 template_id
6321 .split('_')
6322 .map(|word| {
6323 let mut chars = word.chars();
6324 match chars.next() {
6325 None => String::new(),
6326 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
6327 }
6328 })
6329 .collect::<Vec<_>>()
6330 .join(" ")
6331}
6332
6333fn looks_like_template_uuid(template_id: &str) -> bool {
6334 let bytes = template_id.as_bytes();
6335 if bytes.len() != 36 {
6336 return false;
6337 }
6338 let is_hex = |b: u8| b.is_ascii_hexdigit();
6339 let groups = [8usize, 4, 4, 4, 12];
6340 let mut i = 0;
6341 for (gi, &len) in groups.iter().enumerate() {
6342 if gi > 0 {
6343 if bytes.get(i) != Some(&b'-') {
6344 return false;
6345 }
6346 i += 1;
6347 }
6348 for _ in 0..len {
6349 if !bytes.get(i).copied().is_some_and(is_hex) {
6350 return false;
6351 }
6352 i += 1;
6353 }
6354 }
6355 true
6356}
6357
6358const HARVEST_RANGE_M: f32 = 1.5;
6360
6361pub struct GameClient<S: PlayConnection> {
6362 session: S,
6363 seq: Seq,
6364 pub state: GameState,
6365 last_move_forward: f32,
6366 last_move_strafe: f32,
6367}
6368
6369impl<S: PlayConnection> GameClient<S> {
6370 pub fn new(session: S) -> Self {
6371 let session_id = session.session_id();
6372 let entity_id = session.entity_id();
6373 let mut client = Self {
6374 session,
6375 seq: 0,
6376 last_move_forward: 0.0,
6377 last_move_strafe: 0.0,
6378 state: GameState {
6379 session_id,
6380 entity_id,
6381 character_id: None,
6382 tick: 0,
6383 chunk_rev: 0,
6384 content_rev: 0,
6385 publish_rev: 0,
6386 entities: Vec::new(),
6387 player: None,
6388 resource_nodes: Vec::new(),
6389 ground_drops: Vec::new(),
6390 placed_containers: Vec::new(),
6391 buildings: Vec::new(),
6392 doors: Vec::new(),
6393 interior_map: None,
6394 npcs: Vec::new(),
6395 blueprints: Vec::new(),
6396 building_materials: Vec::new(),
6397 world_x0: 0.0,
6398 world_y0: 0.0,
6399 world_width_m: 0.0,
6400 world_height_m: 0.0,
6401 terrain_zones: Vec::new(),
6402 z_platforms: Vec::new(),
6403 z_transitions: Vec::new(),
6404 z_bands_outdoor_backup: None,
6405 world_clock: flatland_protocol::WorldClock::default(),
6406 inventory: std::collections::HashMap::new(),
6407 inventory_hints: std::collections::HashMap::new(),
6408 logs: VecDeque::new(),
6409 intents_sent: 0,
6410 ticks_received: 0,
6411 connected: false,
6412 disconnect_reason: None,
6413 show_stats: false,
6414 hud_log_hidden: false,
6415 show_equip_menu: false,
6416 equip_menu_index: 0,
6417 show_craft_menu: false,
6418 show_plot_build_menu: false,
6419 plot_build_focus_wall: true,
6420 plot_build_wall_index: 0,
6421 plot_build_roof_index: 0,
6422 craft_menu_index: 0,
6423 craft_batch_quantity: 1,
6424 show_shop_menu: false,
6425 shop_catalog: None,
6426 bank_panel: None,
6427 bank_menu_index: 0,
6428 bank_ui_mode: BankUiMode::Menu,
6429 storage_panel: None,
6430 market_panel: None,
6431 market_menu_index: 0,
6432 market_filter: String::new(),
6433 market_filter_focused: false,
6434 market_category_filter: None,
6435 market_buy_confirm: None,
6436 market_ui_mode: MarketUiMode::Browse,
6437 storage_menu_index: 0,
6438 storage_ui_mode: StorageUiMode::Menu,
6439 shop_tab: ShopTab::default(),
6440 shop_menu_index: 0,
6441 shop_quantity: 1,
6442 shop_trade_log: VecDeque::new(),
6443 show_npc_verb_menu: false,
6444 npc_verb_target: None,
6445 npc_verb_index: 0,
6446 player_verbs: crate::social::PlayerVerbState::default(),
6447 social_chat: crate::social::SocialChatState::default(),
6448 trade_ui: crate::social::TradeUiState::default(),
6449 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
6450 show_npc_chat: false,
6451 npc_chat: None,
6452 show_inventory_menu: false,
6453 inventory_menu_index: 0,
6454 inventory_tab: InventoryTab::OnPerson,
6455 inventory_filter: String::new(),
6456 inventory_filter_focused: false,
6457 show_move_picker: false,
6458 show_rename_prompt: false,
6459 rename_plot_id: None,
6460 highlighted_plot_id: None,
6461 show_worker_rename: false,
6462 rename_buffer: String::new(),
6463 move_picker_index: 0,
6464 move_picker: None,
6465 show_grant_picker: false,
6466 grant_picker_index: 0,
6467 grant_picker: None,
6468 show_destroy_picker: false,
6469 destroy_confirm_pending: false,
6470 destroy_picker: None,
6471 combat_target: None,
6472 combat_target_label: None,
6473 ground_target: None,
6474 combat_fx: Vec::new(),
6475 property_zones: Vec::new(),
6476 tax_zones: Vec::new(),
6477 growth_zones: Vec::new(),
6478 biome_zones: Vec::new(),
6479 terrain_kind_nav: Vec::new(),
6480 property_plots: Vec::new(),
6481 property_plot_settings: None,
6482 claim_mode: None,
6483 relocate_mode: None,
6484 sell_plot_confirm: None,
6485 sell_plot_armed_at: None,
6486 show_plant_menu: false,
6487 plant_menu_index: 0,
6488 show_farm_access: false,
6489 farm_access_name_draft: String::new(),
6490 farm_access_discount_bps: 0,
6491 farm_access_index: 0,
6492 plant_quantity: 1,
6493 in_combat: false,
6494 auto_attack: true,
6495 combat_has_los: false,
6496 attack_cd_ticks: 0,
6497 gcd_ticks: 0,
6498 weapon_ability_id: "unarmed".into(),
6499 mainhand_template_id: None,
6500 mainhand_label: None,
6501 mainhand_instance_id: None,
6502 offhand_template_id: None,
6503 offhand_label: None,
6504 offhand_instance_id: None,
6505 mainhand_hand_slots: 1,
6506 defense: None,
6507 worn: BTreeMap::new(),
6508 carry_mass: 0.0,
6509 carry_mass_max: 0.0,
6510 encumbrance: flatland_protocol::EncumbranceState::Light,
6511 inventory_stacks: Vec::new(),
6512 keychain_stacks: Vec::new(),
6513 whisper_pouch_stacks: Vec::new(),
6514 combat_target_detail: None,
6515 statuses: Vec::new(),
6516 cast_progress: None,
6517 timed_channel: None,
6518 plot_build_offer: None,
6519 ability_cooldowns: Vec::new(),
6520 blocking_active: false,
6521 max_target_slots: 1,
6522 combat_slots: Vec::new(),
6523 rotation_presets: Vec::new(),
6524 known_abilities: Vec::new(),
6525 ability_meta: std::collections::HashMap::new(),
6526 ability_mastery: std::collections::HashMap::new(),
6527 hotbar: vec![None; 9],
6528 max_abilities_per_rotation: 0,
6529 show_loadout_menu: false,
6530 show_keychain_menu: false,
6531 keychain_menu_index: 0,
6532 show_rotation_editor: false,
6533 loadout_menu_index: 0,
6534 loadout_hotbar_slot: 1,
6535 loadout_ability_index: 0,
6536 loadout_focus_presets: false,
6537 rotation_editor: RotationEditorState::default(),
6538 harvest_in_progress: false,
6539 harvest_started_at: None,
6540 pending_craft_ack: None,
6541 pending_worker_job_ack: None,
6542 attending_worker_instance_id: None,
6543 quest_log: Vec::new(),
6544 interactables: Vec::new(),
6545 ledger: None,
6546 career: None,
6547 character_sheet_tab: CharacterSheetTab::Character,
6548 ledger_period: LedgerPeriod::Day,
6549 show_quest_offer: false,
6550 pending_quest_offer: None,
6551 show_quest_menu: false,
6552 quest_menu_index: 0,
6553 quest_withdraw_confirm: false,
6554 hired_workers: Vec::new(),
6555 show_workers_menu: false,
6556 workers_menu_index: 0,
6557 workers_menu_compact: false,
6558 worker_step_display: BTreeMap::new(),
6559 worker_error_display: BTreeMap::new(),
6560 show_worker_give_picker: false,
6561 worker_give_picker_index: 0,
6562 worker_give_picker: None,
6563 show_worker_give_target_picker: false,
6564 worker_give_target_picker_index: 0,
6565 worker_give_target_picker: None,
6566 show_worker_take_picker: false,
6567 worker_take_picker_index: 0,
6568 worker_take_picker: None,
6569 show_worker_teach_picker: false,
6570 worker_teach_picker_index: 0,
6571 worker_teach_picker: None,
6572 worker_route_editor: None,
6573 progression_curve: None,
6574 },
6575 };
6576 client.state.apply_client_ui_prefs();
6577 client
6578 }
6579
6580 pub fn entity_id(&self) -> EntityId {
6581 self.state.entity_id
6582 }
6583
6584 pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
6585 if self.state.connected {
6586 return Ok(());
6587 }
6588
6589 loop {
6590 match self.session.next_event().await {
6591 Some(SessionEvent::Welcome {
6592 session_id,
6593 entity_id,
6594 snapshot,
6595 }) => {
6596 self.state
6597 .restore_from_welcome(session_id, entity_id, &snapshot);
6598 self.state.apply_client_ui_prefs();
6599 self.state.push_log(format!(
6600 "Connected — session {session_id}, entity {entity_id}"
6601 ));
6602 return Ok(());
6603 }
6604 Some(SessionEvent::Disconnected { .. }) => {
6605 anyhow::bail!("disconnected before welcome");
6606 }
6607 Some(_) => continue,
6608 None => anyhow::bail!("session closed before welcome"),
6609 }
6610 }
6611 }
6612
6613 pub fn drain_events(&mut self) {
6615 while let Some(event) = self.session.try_next_event() {
6616 if self.handle_event_sync(event).is_err() {
6617 break;
6618 }
6619 }
6620 }
6621
6622 pub async fn next_event(&mut self) -> Option<SessionEvent> {
6624 self.session.next_event().await
6625 }
6626
6627 pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6628 self.handle_event_sync(event)
6629 }
6630
6631 fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6632 match event {
6633 SessionEvent::Welcome {
6634 session_id,
6635 entity_id,
6636 snapshot,
6637 } => {
6638 let resumed = self.state.connected;
6639 self.state
6640 .restore_from_welcome(session_id, entity_id, &snapshot);
6641 if resumed {
6642 self.state.push_log(format!(
6643 "Session restored — session {session_id}, entity {entity_id}"
6644 ));
6645 }
6646 }
6647 SessionEvent::ContentUpdated { snapshot } => {
6648 self.state
6649 .apply_snapshot_fields(&snapshot, self.state.entity_id);
6650 self.state.push_log(format!(
6651 "World updated (content rev {})",
6652 snapshot.content_rev
6653 ));
6654 }
6655 SessionEvent::Tick(delta) => {
6656 self.state.apply_tick_fields(&delta, self.state.entity_id);
6657 self.state.ticks_received += 1;
6658 }
6659 SessionEvent::IntentAck {
6660 entity_id,
6661 seq,
6662 tick,
6663 } => {
6664 crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
6665 if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
6666 if *craft_seq == seq {
6667 let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
6668 if batches > 1 {
6669 self.state.push_log(format!("Crafting {label} ×{batches}…"));
6670 } else {
6671 self.state.push_log(format!("Crafting {label}…"));
6672 }
6673 }
6674 }
6675 if self
6676 .state
6677 .pending_worker_job_ack
6678 .as_ref()
6679 .is_some_and(|p| p.seq == seq)
6680 {
6681 let pending = self.state.pending_worker_job_ack.take().unwrap();
6682 if pending.idle {
6683 self.state.push_log(format!(
6684 "Route cleared for {} — worker idle",
6685 pending.worker_label
6686 ));
6687 } else {
6688 self.state.push_log(format!(
6689 "Route saved for {} — {} stop(s), job loop active",
6690 pending.worker_label, pending.stop_count
6691 ));
6692 }
6693 if self
6694 .state
6695 .worker_route_editor
6696 .as_ref()
6697 .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
6698 {
6699 self.close_worker_route_editor();
6700 }
6701 }
6702 }
6703 SessionEvent::Chat(msg) => {
6704 let label = match msg.channel {
6705 flatland_protocol::ChatChannel::Nearby => "nearby",
6706 flatland_protocol::ChatChannel::Direct => "speak",
6707 flatland_protocol::ChatChannel::Whisper => "whisper",
6708 flatland_protocol::ChatChannel::WhisperStone => "stone",
6709 };
6710 let clarity = match msg.clarity {
6711 flatland_protocol::ChatClarity::Clear => "",
6712 flatland_protocol::ChatClarity::Partial => "~",
6713 flatland_protocol::ChatClarity::Heavy => "…",
6714 };
6715 self.state.push_log(format!(
6716 "[{label}{clarity}] {}: {}",
6717 msg.from_name, msg.text
6718 ));
6719 let now_ms = std::time::SystemTime::now()
6720 .duration_since(std::time::UNIX_EPOCH)
6721 .map(|d| d.as_millis() as u64)
6722 .unwrap_or(0);
6723 self.state
6724 .social_chat
6725 .note_speech(&msg, self.state.entity_id, now_ms);
6726 self.state
6727 .social_chat
6728 .push(crate::social::ChatLogEntry::from_message(
6729 msg,
6730 self.state.entity_id,
6731 ));
6732 }
6733 SessionEvent::TradeOpened(panel) => {
6734 self.state.social_chat.pending_trade = None;
6735 let peer = panel.peer_name.clone();
6736 self.state.trade_ui.open(panel);
6737 self.state
6738 .social_chat
6739 .push_system(format!("Trade open with {peer} — p present · r ready · Esc cancel"));
6740 self.state
6741 .social_chat
6742 .push_cue(crate::social::AudioCue::TradeOpened);
6743 }
6744 SessionEvent::TradeClosed { reason } => {
6745 self.state.push_log(reason.clone());
6746 self.state.social_chat.push_system(reason);
6747 self.state.trade_ui.close();
6748 }
6749 SessionEvent::HarvestResult(result) => {
6750 self.state.clear_harvest_state();
6751 crate::harvest_trace!(
6752 entity_id = self.state.entity_id,
6753 node_id = %result.node_id,
6754 template = %result.item_template,
6755 quantity = result.quantity,
6756 client_tick = self.state.tick,
6757 "client applied harvest result"
6758 );
6759 let msg = if result.quantity == 0 {
6760 format!(
6761 "Harvested {} x0 — nothing dropped (loot table rolled empty)",
6762 result.item_template
6763 )
6764 } else {
6765 format!(
6766 "Harvested {} x{} (on the ground — press P to pick up)",
6767 result.item_template, result.quantity
6768 )
6769 };
6770 self.state.push_log(msg);
6771 }
6772 SessionEvent::CraftResult(result) => {
6773 for stack in &result.consumed {
6774 if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
6775 *qty = qty.saturating_sub(stack.quantity);
6776 if *qty == 0 {
6777 self.state.inventory.remove(&stack.template_id);
6778 }
6779 }
6780 }
6781 for stack in &result.outputs {
6782 *self
6783 .state
6784 .inventory
6785 .entry(stack.template_id.clone())
6786 .or_insert(0) += stack.quantity;
6787 }
6788 if let Some(output) = result.outputs.first() {
6789 if result.batch_total > 1 {
6790 self.state.push_log(format!(
6791 "Crafted {} x{} ({}/{})",
6792 output.template_id,
6793 output.quantity,
6794 result.batch_index,
6795 result.batch_total
6796 ));
6797 } else {
6798 self.state.push_log(format!(
6799 "Crafted {} x{}",
6800 output.template_id, output.quantity
6801 ));
6802 }
6803 } else {
6804 self.state
6805 .push_log(format!("Craft finished: {}", result.blueprint_id));
6806 }
6807 }
6808 SessionEvent::Death(notice) => {
6809 self.state.clear_harvest_state();
6810 self.state.push_log(notice.message.clone());
6811 self.state.push_log(format!(
6812 "Respawned at ({:.1}, {:.1})",
6813 notice.respawn_x, notice.respawn_y
6814 ));
6815 }
6816 SessionEvent::Interaction(notice) => {
6817 if notice.message.starts_with("Harvest failed:") {
6818 self.state.clear_harvest_state();
6819 }
6820 if notice.message.starts_with("Can't do that:") {
6821 self.state.pending_craft_ack = None;
6822 if let Some(pending) = self.state.pending_worker_job_ack.take() {
6823 if let Some(w) = self
6824 .state
6825 .hired_workers
6826 .iter_mut()
6827 .find(|w| w.instance_id == pending.worker_instance_id)
6828 {
6829 w.route = pending.prev_route;
6830 w.mode = pending.prev_mode;
6831 w.step_label = pending.prev_step_label;
6832 w.last_error = pending.prev_last_error;
6833 }
6834 let reason = notice
6835 .message
6836 .strip_prefix("Can't do that:")
6837 .unwrap_or(¬ice.message)
6838 .trim();
6839 self.state.push_log(format!(
6840 "Route save failed for {}: {reason}",
6841 pending.worker_label
6842 ));
6843 }
6844 let reason = notice
6845 .message
6846 .strip_prefix("Can't do that:")
6847 .unwrap_or(¬ice.message)
6848 .trim();
6849 if reason.contains("already tilled") {
6850 if let Some(plot) = self.state.my_plot_under_player() {
6851 self.state.sell_plot_confirm = Some(plot.plot_id);
6852 self.state.sell_plot_armed_at = Some(Instant::now());
6853 }
6854 }
6855 }
6856 if notice.message.starts_with("Cast failed:") {
6857 self.state.cast_progress = None;
6858 }
6859 if notice.message.contains("slain the") {
6860 self.state.combat_target = None;
6861 self.state.combat_target_label = None;
6862 }
6863 if notice.message.contains("wants to trade") {
6865 if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
6866 let from_name = notice
6867 .message
6868 .split(" wants to trade")
6869 .next()
6870 .unwrap_or("Player")
6871 .to_string();
6872 self.state.social_chat.pending_trade =
6873 Some(crate::social::PendingTradeRequest {
6874 from_entity,
6875 from_name: from_name.clone(),
6876 });
6877 self.state.social_chat.push_system(format!(
6878 "{from_name} wants to trade — [Y] accept · [N] decline"
6879 ));
6880 self.state
6881 .social_chat
6882 .push_cue(crate::social::AudioCue::TradeOffer);
6883 }
6884 }
6885 if notice.message.starts_with("trade request declined") {
6886 self.state
6887 .social_chat
6888 .push_system(notice.message.clone());
6889 self.state
6890 .social_chat
6891 .push_cue(crate::social::AudioCue::TradeDeclined);
6892 }
6893 self.state.apply_interaction_notice(¬ice);
6894 self.state.push_log(notice.message.clone());
6895 }
6896 SessionEvent::ShopOpened(catalog) => {
6897 self.state.apply_shop_catalog(catalog);
6898 }
6899 SessionEvent::BankOpened(panel) => {
6900 self.state.apply_bank_panel(panel);
6901 }
6902 SessionEvent::StorageOpened(panel) => {
6903 self.state.apply_storage_panel(panel);
6904 }
6905 SessionEvent::MarketOpened(panel) => {
6906 self.state.apply_market_panel(panel);
6907 }
6908 SessionEvent::NpcTalkOpened(opened) => {
6909 self.state.show_npc_verb_menu = false;
6910 if self.state.npc_verb_target.is_none() {
6911 self.state.npc_verb_target = Some(opened.npc_id.clone());
6912 }
6913 let label = opened.npc_label.clone();
6914 let banner = if !opened.trade_allowed {
6915 Some("Trade is unavailable right now.".to_string())
6916 } else {
6917 None
6918 };
6919 self.state.show_npc_chat = true;
6920 self.state.npc_chat = Some(NpcChatState {
6921 npc_id: opened.npc_id,
6922 npc_label: opened.npc_label,
6923 lines: if opened.greeting.is_empty() {
6924 vec![]
6925 } else {
6926 vec![format!("{label}: {}", opened.greeting)]
6927 },
6928 input: String::new(),
6929 pending: opened.greeting.is_empty(),
6930 talk_depth: opened.talk_depth,
6931 trade_allowed: opened.trade_allowed,
6932 banner,
6933 });
6934 }
6935 SessionEvent::NpcTalkPending(_) => {
6936 if let Some(chat) = self.state.npc_chat.as_mut() {
6937 chat.pending = true;
6938 }
6939 }
6940 SessionEvent::NpcTalkReply(reply) => {
6941 if let Some(chat) = self.state.npc_chat.as_mut() {
6942 if chat.npc_id == reply.npc_id {
6943 chat.pending = false;
6944 if reply.trade_disabled {
6945 chat.trade_allowed = false;
6946 chat.banner = Some("Trade is unavailable right now.".to_string());
6947 }
6948 if reply.wind_down {
6949 chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
6950 if chat.banner.is_none() {
6951 chat.banner =
6952 Some("They're wrapping up — keep it brief.".to_string());
6953 }
6954 }
6955 chat.lines
6956 .push(format!("{}: {}", chat.npc_label, reply.line));
6957 }
6958 }
6959 }
6960 SessionEvent::NpcTalkClosed(closed) => {
6961 if self
6962 .state
6963 .npc_chat
6964 .as_ref()
6965 .is_some_and(|c| c.npc_id == closed.npc_id)
6966 {
6967 self.state.show_npc_chat = false;
6968 self.state.npc_chat = None;
6969 }
6970 }
6971 SessionEvent::NpcTalkError(err) => {
6972 self.state.push_log(format!("Talk failed: {}", err.reason));
6973 if let Some(chat) = self.state.npc_chat.as_mut() {
6974 chat.pending = false;
6975 }
6976 }
6977 SessionEvent::UseResult(result) => {
6978 if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
6981 *qty = qty.saturating_sub(1);
6982 if *qty == 0 {
6983 self.state.inventory.remove(&result.template_id);
6984 }
6985 }
6986 }
6987 SessionEvent::QuestOffer(offer) => {
6988 self.state.pending_quest_offer = Some(offer.clone());
6989 self.state.show_quest_offer = true;
6990 self.state
6991 .push_log(format!("Quest offered: {}", offer.title));
6992 }
6993 SessionEvent::QuestAccepted(notice) => {
6994 self.state.show_quest_offer = false;
6995 self.state.pending_quest_offer = None;
6996 self.state.push_log(notice.message);
6997 }
6998 SessionEvent::QuestWithdrawn(notice) => {
6999 self.state.show_quest_menu = false;
7000 self.state.quest_withdraw_confirm = false;
7001 self.state.push_log(notice.message);
7002 }
7003 SessionEvent::QuestStepCompleted(notice) => {
7004 self.state.push_log(notice.message);
7005 }
7006 SessionEvent::QuestCompleted(notice) => {
7007 self.state.push_log(notice.message);
7008 }
7009 SessionEvent::Disconnected { reason } => {
7010 self.state.clear_harvest_state();
7011 self.state.connected = false;
7012 self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
7013 if let Some(r) = &self.state.disconnect_reason {
7014 self.state.push_log(format!("Disconnected: {r}"));
7015 } else {
7016 self.state.push_log("Disconnected from server");
7017 }
7018 }
7019 }
7020 Ok(())
7021 }
7022
7023 pub fn is_connected(&self) -> bool {
7024 self.state.connected
7025 }
7026
7027 pub fn close_overlays(&mut self) {
7028 self.state.show_stats = false;
7029 self.state.show_craft_menu = false;
7030 self.state.show_plot_build_menu = false;
7031 self.state.show_shop_menu = false;
7032 self.state.shop_catalog = None;
7033 self.state.show_npc_verb_menu = false;
7034 self.state.npc_verb_target = None;
7035 self.state.show_npc_chat = false;
7036 self.state.npc_chat = None;
7037 self.state.show_inventory_menu = false;
7038 self.state.show_loadout_menu = false;
7039 self.state.show_rotation_editor = false;
7040 self.state.rotation_editor.reset();
7041 self.state.show_rename_prompt = false;
7042 self.state.show_worker_rename = false;
7043 self.state.rename_buffer.clear();
7044 self.state.show_move_picker = false;
7045 self.state.move_picker = None;
7046 self.state.show_destroy_picker = false;
7047 self.state.destroy_confirm_pending = false;
7048 self.state.destroy_picker = None;
7049 self.state.show_quest_offer = false;
7050 self.state.pending_quest_offer = None;
7051 self.state.show_quest_menu = false;
7052 self.state.quest_withdraw_confirm = false;
7053 self.state.show_workers_menu = false;
7054 self.close_worker_give_picker();
7055 self.close_worker_give_target_picker();
7056 self.close_worker_take_picker();
7057 self.close_worker_teach_picker();
7058 self.state.worker_route_editor = None;
7059 self.state.claim_mode = None;
7060 self.state.relocate_mode = None;
7061 self.state.sell_plot_confirm = None;
7062 self.state.sell_plot_armed_at = None;
7063 self.close_farm_access_panel();
7064 if self.state.show_plant_menu {
7065 self.close_plant_menu();
7066 }
7067 }
7068
7069 pub fn back_on_esc(&mut self) -> bool {
7071 if self.state.social_chat.composer_open() {
7072 self.state.social_chat.close_composer();
7073 return true;
7074 }
7075 if self.state.player_verbs.open {
7076 self.state.player_verbs.close();
7077 return true;
7078 }
7079 if self.state.whisper_pouch_ui.open {
7080 self.state.whisper_pouch_ui.open = false;
7081 return true;
7082 }
7083 if self.state.trade_ui.panel.is_some() {
7084 self.state.trade_ui.close();
7086 return true;
7087 }
7088 if self.state.show_rename_prompt {
7089 self.cancel_rename_prompt();
7090 return true;
7091 }
7092 if self.state.show_worker_rename {
7093 self.cancel_worker_rename();
7094 return true;
7095 }
7096 if self.state.show_destroy_picker {
7097 if self.state.destroy_confirm_pending {
7098 self.cancel_destroy_confirm();
7099 } else {
7100 self.close_destroy_picker();
7101 }
7102 return true;
7103 }
7104 if self.state.claim_mode.is_some() {
7105 self.cancel_claim_mode();
7106 return true;
7107 }
7108 if self.state.relocate_mode.is_some() {
7109 self.cancel_relocate_mode();
7110 return true;
7111 }
7112 if self.state.show_plant_menu {
7113 self.close_plant_menu();
7114 return true;
7115 }
7116 if self.state.show_farm_access {
7117 self.close_farm_access_panel();
7118 return true;
7119 }
7120 if self.state.sell_plot_confirm.is_some() {
7121 self.state.sell_plot_confirm = None;
7122 self.state.sell_plot_armed_at = None;
7123 self.state.push_log("Sell cancelled");
7124 return true;
7125 }
7126 if self.state.show_move_picker {
7127 self.close_move_picker();
7128 return true;
7129 }
7130 if self.state.show_rotation_editor {
7131 match self.state.rotation_editor.mode {
7132 RotationEditorMode::List => {
7133 self.state.show_rotation_editor = false;
7134 self.state.rotation_editor.reset();
7135 }
7136 RotationEditorMode::EditLabel => {
7137 self.state.rotation_editor.label_buffer.clear();
7138 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
7139 }
7140 RotationEditorMode::PickAbility => {
7141 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
7142 }
7143 RotationEditorMode::EditSequence => {
7144 self.state.rotation_editor.draft = None;
7145 self.state.rotation_editor.mode = RotationEditorMode::List;
7146 }
7147 }
7148 return true;
7149 }
7150 if self.state.show_inventory_menu {
7151 self.close_inventory_menu();
7152 return true;
7153 }
7154 if self.state.show_craft_menu {
7155 self.close_craft_menu();
7156 return true;
7157 }
7158 if self.state.show_plot_build_menu {
7159 self.close_plot_build_menu();
7160 return true;
7161 }
7162 if self.state.show_keychain_menu {
7163 self.close_keychain_menu();
7164 return true;
7165 }
7166 if self.state.show_quest_offer {
7167 self.quest_offer_decline();
7168 return true;
7169 }
7170 if self.state.show_shop_menu {
7171 return false;
7173 }
7174 if self.state.bank_panel.is_some() {
7175 return false;
7176 }
7177 if self.state.storage_panel.is_some() {
7178 return false;
7179 }
7180 if self.state.market_panel.is_some() {
7181 return false;
7182 }
7183 if self.state.show_npc_chat {
7184 return false;
7186 }
7187 if self.state.show_npc_verb_menu {
7188 self.state.show_npc_verb_menu = false;
7189 self.state.npc_verb_target = None;
7190 return true;
7191 }
7192 if self.state.show_quest_menu {
7193 if self.state.quest_withdraw_confirm {
7194 self.state.quest_withdraw_confirm = false;
7195 } else {
7196 self.state.show_quest_menu = false;
7197 }
7198 return true;
7199 }
7200 if self.state.worker_route_editor.is_some() {
7201 if self.re_at_root_sheet() {
7203 let reopen = self.state.attending_worker_instance_id.clone();
7204 self.close_worker_route_editor();
7205 if let Some(id) = reopen {
7206 if let Some(idx) = self
7207 .state
7208 .hired_workers
7209 .iter()
7210 .position(|w| w.instance_id == id)
7211 {
7212 self.state.workers_menu_index = idx;
7213 self.state.show_workers_menu = true;
7214 }
7215 }
7216 } else {
7217 self.re_sheet_back();
7218 }
7219 return true;
7220 }
7221 if self.state.show_worker_give_picker {
7222 self.close_worker_give_picker();
7223 return true;
7224 }
7225 if self.state.show_worker_give_target_picker {
7226 self.close_worker_give_target_picker();
7227 return true;
7228 }
7229 if self.state.show_worker_take_picker {
7230 self.close_worker_take_picker();
7231 return true;
7232 }
7233 if self.state.show_worker_teach_picker {
7234 self.close_worker_teach_picker();
7235 return true;
7236 }
7237 if self.state.show_workers_menu {
7238 self.close_workers_menu_ui();
7239 return true;
7240 }
7241 if self.state.show_loadout_menu {
7242 self.state.show_loadout_menu = false;
7243 return true;
7244 }
7245 if self.state.show_stats {
7246 self.state.show_stats = false;
7247 return true;
7248 }
7249 if self.state.show_equip_menu {
7250 self.state.show_equip_menu = false;
7251 return true;
7252 }
7253 false
7254 }
7255
7256 pub fn toggle_stats(&mut self) {
7257 self.state.show_stats = !self.state.show_stats;
7258 if self.state.show_stats {
7259 self.state.character_sheet_tab = CharacterSheetTab::Character;
7260 self.state.show_craft_menu = false;
7261 self.state.show_shop_menu = false;
7262 self.state.shop_catalog = None;
7263 self.state.show_inventory_menu = false;
7264 self.state.show_equip_menu = false;
7265 }
7266 }
7267
7268 pub fn toggle_equip_menu(&mut self) {
7269 self.state.show_equip_menu = !self.state.show_equip_menu;
7270 if self.state.show_equip_menu {
7271 self.state.show_stats = false;
7272 self.state.show_craft_menu = false;
7273 self.state.show_shop_menu = false;
7274 self.state.shop_catalog = None;
7275 self.state.show_inventory_menu = false;
7276 self.state.show_loadout_menu = false;
7277 }
7278 }
7279
7280 pub fn cycle_character_sheet_tab(&mut self) {
7281 if self.state.show_stats {
7282 self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
7283 }
7284 }
7285
7286 pub fn set_ledger_period_digit(&mut self, c: char) {
7287 if self.state.show_stats {
7288 if let Some(p) = LedgerPeriod::from_digit(c) {
7289 self.state.ledger_period = p;
7290 self.state.character_sheet_tab = CharacterSheetTab::Ledger;
7291 }
7292 }
7293 }
7294
7295 pub fn cycle_ledger_period(&mut self) {
7296 if self.state.show_stats
7297 && self.state.character_sheet_tab == CharacterSheetTab::Ledger
7298 {
7299 self.state.ledger_period = self.state.ledger_period.cycle();
7300 }
7301 }
7302
7303 pub fn open_inventory_menu(&mut self) {
7304 self.state.show_inventory_menu = true;
7305 self.state.show_craft_menu = false;
7306 self.state.show_shop_menu = false;
7307 self.state.shop_catalog = None;
7308 self.state.show_stats = false;
7309 self.state.show_move_picker = false;
7310 self.state.move_picker = None;
7311 self.state.show_destroy_picker = false;
7312 self.state.destroy_confirm_pending = false;
7313 self.state.destroy_picker = None;
7314 self.state.show_rename_prompt = false;
7315 self.state.rename_plot_id = None;
7316 self.state.rename_buffer.clear();
7317 self.state.inventory_filter_focused = false;
7318 self.state.clamp_inventory_indices();
7319 }
7320
7321 pub fn close_inventory_menu(&mut self) {
7322 self.state.show_inventory_menu = false;
7323 self.state.show_move_picker = false;
7324 self.state.move_picker = None;
7325 self.close_grant_picker();
7326 self.state.show_destroy_picker = false;
7327 self.state.destroy_confirm_pending = false;
7328 self.state.destroy_picker = None;
7329 self.state.show_rename_prompt = false;
7330 self.state.rename_plot_id = None;
7331 self.state.rename_buffer.clear();
7332 self.state.inventory_filter_focused = false;
7333 }
7334
7335 pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
7336 let Some(row) = self.state.inventory_selected_row() else {
7337 anyhow::bail!("inventory empty");
7338 };
7339 if GameState::is_property_deed_template(&row.stack.template_id) {
7340 let Some(plot_id) = GameState::deed_plot_id(&row.stack) else {
7341 anyhow::bail!("deed has no plot id");
7342 };
7343 let label = self
7344 .state
7345 .property_plots
7346 .iter()
7347 .find(|p| p.plot_id == plot_id)
7348 .map(|p| {
7349 if p.label.trim().is_empty() {
7350 p.plot_code.clone()
7351 } else {
7352 p.label.clone()
7353 }
7354 })
7355 .unwrap_or_else(|| {
7356 row.stack
7357 .display_name
7358 .clone()
7359 .unwrap_or_else(|| "plot".into())
7360 });
7361 self.state.rename_buffer = label;
7362 self.state.rename_plot_id = Some(plot_id);
7363 self.state.highlighted_plot_id = Some(plot_id);
7364 self.state.show_rename_prompt = true;
7365 self.state.show_worker_rename = false;
7366 self.state.show_move_picker = false;
7367 self.state.show_destroy_picker = false;
7368 self.state.destroy_confirm_pending = false;
7369 return Ok(());
7370 }
7371 if !self.state.row_is_renameable_container(&row) {
7372 anyhow::bail!("only storage containers or deeds can be renamed");
7373 }
7374 let current = row
7375 .stack
7376 .display_name
7377 .clone()
7378 .unwrap_or_else(|| row.stack.template_id.clone());
7379 self.state.rename_buffer = current;
7380 self.state.rename_plot_id = None;
7381 self.state.show_rename_prompt = true;
7382 self.state.show_worker_rename = false;
7383 self.state.show_move_picker = false;
7384 self.state.show_destroy_picker = false;
7385 self.state.destroy_confirm_pending = false;
7386 Ok(())
7387 }
7388
7389 pub fn open_plot_rename_under_player(&mut self) -> anyhow::Result<()> {
7391 let Some(plot) = self.state.my_plot_under_player().cloned() else {
7392 anyhow::bail!("stand on your plot to rename it");
7393 };
7394 let label = if plot.label.trim().is_empty() {
7395 plot.plot_code.clone()
7396 } else {
7397 plot.label.clone()
7398 };
7399 self.state.rename_buffer = label;
7400 self.state.rename_plot_id = Some(plot.plot_id);
7401 self.state.highlighted_plot_id = Some(plot.plot_id);
7402 self.state.show_rename_prompt = true;
7403 self.state.show_worker_rename = false;
7404 Ok(())
7405 }
7406
7407 pub fn cancel_rename_prompt(&mut self) {
7408 self.state.show_rename_prompt = false;
7409 self.state.rename_plot_id = None;
7410 self.state.rename_buffer.clear();
7411 }
7412
7413 pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
7414 let name = self.state.rename_buffer.trim().to_string();
7415 if name.is_empty() {
7416 anyhow::bail!("name cannot be empty");
7417 }
7418 if let Some(plot_id) = self.state.rename_plot_id {
7419 if name.chars().count() > 48 {
7420 anyhow::bail!("label must be 1–48 characters");
7421 }
7422 self.seq += 1;
7423 self.session
7424 .submit_intent(Intent::RenamePropertyPlot {
7425 entity_id: self.state.entity_id,
7426 plot_id,
7427 label: name,
7428 seq: self.seq,
7429 })
7430 .await?;
7431 self.state.intents_sent += 1;
7432 self.state.show_rename_prompt = false;
7433 self.state.rename_plot_id = None;
7434 self.state.rename_buffer.clear();
7435 return Ok(());
7436 }
7437 if name.chars().count() > 32 {
7438 anyhow::bail!("name must be 1–32 characters");
7439 }
7440 let Some(row) = self.state.inventory_selected_row() else {
7441 anyhow::bail!("inventory empty");
7442 };
7443 let Some(instance_id) = row.stack.item_instance_id else {
7444 anyhow::bail!("item has no instance id");
7445 };
7446 self.seq += 1;
7447 self.session
7448 .submit_intent(Intent::RenameContainer {
7449 entity_id: self.state.entity_id,
7450 item_instance_id: instance_id,
7451 location: row.from.clone(),
7452 name,
7453 seq: self.seq,
7454 })
7455 .await?;
7456 self.state.intents_sent += 1;
7457 self.state.show_rename_prompt = false;
7458 self.state.rename_buffer.clear();
7459 Ok(())
7460 }
7461
7462 pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
7463 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
7464 anyhow::bail!("no worker selected");
7465 };
7466 self.state.rename_buffer = worker.label.clone();
7467 self.state.show_worker_rename = true;
7468 self.state.show_rename_prompt = false;
7469 Ok(())
7470 }
7471
7472 pub fn cancel_worker_rename(&mut self) {
7473 self.state.show_worker_rename = false;
7474 self.state.rename_buffer.clear();
7475 }
7476
7477 pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
7478 let name = self.state.rename_buffer.trim().to_string();
7479 if name.is_empty() {
7480 anyhow::bail!("name cannot be empty");
7481 }
7482 if name.chars().count() > 32 {
7483 anyhow::bail!("name must be 1–32 characters");
7484 }
7485 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
7486 anyhow::bail!("no worker selected");
7487 };
7488 let worker_instance_id = worker.instance_id.clone();
7489 self.seq += 1;
7490 self.session
7491 .submit_intent(Intent::RenameHiredWorker {
7492 entity_id: self.state.entity_id,
7493 worker_instance_id: worker_instance_id.clone(),
7494 name: name.clone(),
7495 seq: self.seq,
7496 })
7497 .await?;
7498 self.state.intents_sent += 1;
7499 if let Some(w) = self
7500 .state
7501 .hired_workers
7502 .iter_mut()
7503 .find(|w| w.instance_id == worker_instance_id)
7504 {
7505 w.label = name.clone();
7506 }
7507 if let Some(ed) = self.state.worker_route_editor.as_mut() {
7508 if ed.worker_instance_id == worker_instance_id {
7509 ed.worker_label = name.clone();
7510 }
7511 }
7512 self.state.show_worker_rename = false;
7513 self.state.rename_buffer.clear();
7514 self.state.push_log(format!("Renamed worker to \"{name}\""));
7515 Ok(())
7516 }
7517
7518 pub fn toggle_inventory_menu(&mut self) {
7519 if self.state.show_inventory_menu {
7520 self.close_inventory_menu();
7521 } else {
7522 self.open_inventory_menu();
7523 }
7524 }
7525
7526 pub fn inventory_menu_move(&mut self, delta: i32) {
7528 if self.state.show_grant_picker {
7529 let Some(picker) = self.state.grant_picker.as_ref() else {
7530 return;
7531 };
7532 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7533 let filter = picker.filter.clone();
7534 let n = labels.len();
7535 if n == 0 {
7536 return;
7537 }
7538 self.state.grant_picker_index = step_filtered_index(
7539 self.state.grant_picker_index,
7540 delta,
7541 n,
7542 |i| list_label_matches(&labels[i], &filter),
7543 );
7544 return;
7545 }
7546 if self.state.show_move_picker {
7547 let Some(picker) = self.state.move_picker.as_ref() else {
7548 return;
7549 };
7550 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7551 let filter = picker.filter.clone();
7552 let n = labels.len();
7553 if n == 0 {
7554 return;
7555 }
7556 self.state.move_picker_index = step_filtered_index(
7557 self.state.move_picker_index,
7558 delta,
7559 n,
7560 |i| list_label_matches(&labels[i], &filter),
7561 );
7562 self.state.clamp_move_picker_quantity();
7563 return;
7564 }
7565 let n = self.state.inventory_selectable_rows().len();
7566 if n == 0 {
7567 return;
7568 }
7569 let idx = self.state.inventory_menu_index as i32;
7570 self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
7571 }
7572
7573 pub fn inventory_menu_page(&mut self, pages: i32) {
7575 if self.state.show_grant_picker {
7576 let Some(picker) = self.state.grant_picker.as_ref() else {
7577 return;
7578 };
7579 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7580 let filter = picker.filter.clone();
7581 let n = labels.len();
7582 self.state.grant_picker_index = page_filtered_index(
7583 self.state.grant_picker_index,
7584 pages,
7585 n,
7586 |i| list_label_matches(&labels[i], &filter),
7587 );
7588 return;
7589 }
7590 if self.state.show_move_picker {
7591 let Some(picker) = self.state.move_picker.as_ref() else {
7592 return;
7593 };
7594 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7595 let filter = picker.filter.clone();
7596 let n = labels.len();
7597 self.state.move_picker_index = page_filtered_index(
7598 self.state.move_picker_index,
7599 pages,
7600 n,
7601 |i| list_label_matches(&labels[i], &filter),
7602 );
7603 self.state.clamp_move_picker_quantity();
7604 return;
7605 }
7606 let n = self.state.inventory_selectable_rows().len();
7607 self.state.inventory_menu_index =
7608 page_list_index(self.state.inventory_menu_index, pages, n);
7609 }
7610
7611 pub fn cycle_inventory_tab(&mut self, forward: bool) {
7612 if self.state.show_move_picker
7613 || self.state.show_grant_picker
7614 || self.state.show_destroy_picker
7615 || self.state.show_rename_prompt
7616 || self.state.inventory_filter_focused
7617 {
7618 return;
7619 }
7620 self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
7621 self.state.inventory_menu_index = 0;
7622 self.state.clamp_inventory_indices();
7623 }
7624
7625 pub fn focus_inventory_filter(&mut self) {
7626 if self.state.show_grant_picker {
7627 if let Some(p) = self.state.grant_picker.as_mut() {
7628 p.filter_focused = true;
7629 }
7630 return;
7631 }
7632 if self.state.show_move_picker {
7633 if let Some(p) = self.state.move_picker.as_mut() {
7634 p.filter_focused = true;
7635 }
7636 return;
7637 }
7638 self.state.inventory_filter_focused = true;
7639 }
7640
7641 pub fn set_inventory_filter(&mut self, filter: String) {
7642 self.state.inventory_filter = filter;
7643 self.state.inventory_menu_index = 0;
7644 self.state.clamp_inventory_indices();
7645 }
7646
7647 pub fn append_inventory_filter_char(&mut self, ch: char) {
7648 if ch.is_control() {
7649 return;
7650 }
7651 if self.state.show_grant_picker {
7652 if let Some(p) = self.state.grant_picker.as_mut() {
7653 if p.filter_focused {
7654 p.filter.push(ch);
7655 self.state.grant_picker_index = 0;
7656 }
7657 }
7658 return;
7659 }
7660 if self.state.show_move_picker {
7661 if let Some(p) = self.state.move_picker.as_mut() {
7662 if p.filter_focused {
7663 p.filter.push(ch);
7664 self.state.move_picker_index = 0;
7665 self.state.clamp_move_picker_quantity();
7666 }
7667 }
7668 return;
7669 }
7670 if !self.state.inventory_filter_focused {
7671 return;
7672 }
7673 self.state.inventory_filter.push(ch);
7674 self.state.inventory_menu_index = 0;
7675 self.state.clamp_inventory_indices();
7676 }
7677
7678 pub fn inventory_filter_backspace(&mut self) {
7679 if self.state.show_grant_picker {
7680 if let Some(p) = self.state.grant_picker.as_mut() {
7681 if p.filter_focused {
7682 p.filter.pop();
7683 self.state.grant_picker_index = 0;
7684 }
7685 }
7686 return;
7687 }
7688 if self.state.show_move_picker {
7689 if let Some(p) = self.state.move_picker.as_mut() {
7690 if p.filter_focused {
7691 p.filter.pop();
7692 self.state.move_picker_index = 0;
7693 self.state.clamp_move_picker_quantity();
7694 }
7695 }
7696 return;
7697 }
7698 if !self.state.inventory_filter_focused {
7699 return;
7700 }
7701 self.state.inventory_filter.pop();
7702 self.state.inventory_menu_index = 0;
7703 self.state.clamp_inventory_indices();
7704 }
7705
7706 pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
7708 if self.state.show_grant_picker {
7709 if let Some(p) = self.state.grant_picker.as_mut() {
7710 if p.filter_focused {
7711 if !p.filter.is_empty() {
7712 p.filter.clear();
7713 self.state.grant_picker_index = 0;
7714 } else {
7715 p.filter_focused = false;
7716 }
7717 return true;
7718 }
7719 if !p.filter.is_empty() {
7720 p.filter.clear();
7721 self.state.grant_picker_index = 0;
7722 return true;
7723 }
7724 }
7725 return false;
7726 }
7727 if self.state.show_move_picker {
7728 if let Some(p) = self.state.move_picker.as_mut() {
7729 if p.filter_focused {
7730 if !p.filter.is_empty() {
7731 p.filter.clear();
7732 self.state.move_picker_index = 0;
7733 self.state.clamp_move_picker_quantity();
7734 } else {
7735 p.filter_focused = false;
7736 }
7737 return true;
7738 }
7739 if !p.filter.is_empty() {
7740 p.filter.clear();
7741 self.state.move_picker_index = 0;
7742 self.state.clamp_move_picker_quantity();
7743 return true;
7744 }
7745 }
7746 return false;
7747 }
7748 if self.state.inventory_filter_focused {
7749 if !self.state.inventory_filter.is_empty() {
7750 self.state.inventory_filter.clear();
7751 self.state.inventory_menu_index = 0;
7752 self.state.clamp_inventory_indices();
7753 } else {
7754 self.state.inventory_filter_focused = false;
7755 }
7756 return true;
7757 }
7758 if !self.state.inventory_filter.is_empty() {
7759 self.state.inventory_filter.clear();
7760 self.state.inventory_menu_index = 0;
7761 self.state.clamp_inventory_indices();
7762 return true;
7763 }
7764 false
7765 }
7766
7767 pub fn craft_menu_page(&mut self, pages: i32) {
7768 let n = self.state.blueprints.len();
7769 self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
7770 self.state.clamp_craft_batch_quantity();
7771 }
7772
7773 pub fn shop_menu_page(&mut self, pages: i32) {
7774 let n = self.state.shop_list_len();
7775 self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
7776 self.state.clamp_shop_quantity();
7777 }
7778
7779 pub fn workers_menu_page(&mut self, pages: i32) {
7780 let n = self.state.hired_workers.len();
7781 self.state.workers_menu_index =
7782 page_list_index(self.state.workers_menu_index, pages, n);
7783 }
7784
7785 pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
7790 if self.state.show_destroy_picker {
7791 if self.state.destroy_confirm_pending {
7792 return self.confirm_destroy_item().await;
7793 }
7794 return self.request_destroy_confirm();
7795 }
7796 if self.state.show_grant_picker {
7797 return self.confirm_grant_picker().await;
7798 }
7799 if self.state.show_move_picker {
7800 return self.confirm_move_picker().await;
7801 }
7802 let Some(row) = self.state.inventory_selected_row() else {
7803 anyhow::bail!("inventory empty");
7804 };
7805 if row.is_equip_shell {
7806 let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
7807 anyhow::bail!("not a worn item");
7808 };
7809 return self.equip_worn(slot, None).await;
7810 }
7811 if row.is_chest_shell {
7812 return self.open_chest_pickup_picker();
7813 }
7814 let template_id = row.stack.template_id.clone();
7815 let instance_id = row.stack.item_instance_id;
7816 let category = self.state.inventory_item_category(&template_id);
7817 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7818
7819 if category == Some("weapon") {
7820 return self.equip_mainhand(Some(template_id)).await;
7821 }
7822 if category == Some("lodging") && on_person {
7823 if let Some(inst) = instance_id {
7824 return self.place_container(inst).await;
7825 }
7826 }
7827 if (category == Some("container") || category == Some("armor")) && on_person {
7828 if let Some(inst) = instance_id {
7829 let world_placeable = row.stack.world_placeable == Some(true)
7830 || template_id.contains("chest");
7831 if world_placeable {
7832 return self.place_container(inst).await;
7833 }
7834 if let Some(slot) = guess_body_slot(&template_id) {
7838 return self.equip_worn(slot, Some(inst)).await;
7839 }
7840 }
7841 }
7842 self.open_move_picker()
7846 }
7847
7848 pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
7850 let Some(row) = self.state.inventory_selected_row() else {
7851 anyhow::bail!("inventory empty");
7852 };
7853 if row.from != flatland_protocol::InventoryLocation::Root {
7854 anyhow::bail!("select a consumable on your person");
7855 }
7856 if GameState::stack_is_item_grant(&row.stack) {
7857 return self.open_grant_target_picker();
7858 }
7859 if GameState::is_property_deed_template(&row.stack.template_id) {
7860 return self.open_move_picker();
7861 }
7862 let category = self
7863 .state
7864 .inventory_item_category(&row.stack.template_id);
7865 if category != Some("consumable") {
7866 anyhow::bail!("selected item is not consumable");
7867 }
7868 self.use_item(&row.stack.template_id).await
7869 }
7870
7871 pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
7873 let Some(row) = self.state.inventory_selected_row() else {
7874 anyhow::bail!("inventory empty");
7875 };
7876 if row.from != flatland_protocol::InventoryLocation::Root {
7877 anyhow::bail!("select a grant item on your person");
7878 }
7879 if !GameState::stack_is_item_grant(&row.stack) {
7880 anyhow::bail!("selected item does not grant onto gear");
7881 }
7882 let Some(grant_instance_id) = row.stack.item_instance_id else {
7883 anyhow::bail!("grant has no instance id");
7884 };
7885 let effect_id = GameState::grant_effect_id(&row.stack)
7886 .unwrap_or("?")
7887 .to_string();
7888 let mode = GameState::grant_mode(&row.stack).to_string();
7889 let options = self.state.grant_target_options(&row.stack);
7890 if options.is_empty() {
7891 anyhow::bail!("no valid gear to apply {effect_id} to");
7892 }
7893 let grant_label = row
7894 .stack
7895 .display_name
7896 .clone()
7897 .unwrap_or_else(|| row.stack.template_id.clone());
7898 self.state.show_grant_picker = true;
7899 self.state.grant_picker_index = 0;
7900 self.state.grant_picker = Some(GrantTargetPicker {
7901 grant_instance_id,
7902 grant_label,
7903 effect_id,
7904 mode,
7905 options,
7906 filter: String::new(),
7907 filter_focused: false,
7908 });
7909 Ok(())
7910 }
7911
7912 pub fn close_grant_picker(&mut self) {
7913 self.state.show_grant_picker = false;
7914 self.state.grant_picker = None;
7915 self.state.grant_picker_index = 0;
7916 }
7917
7918 pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
7919 let Some(picker) = self.state.grant_picker.clone() else {
7920 self.close_grant_picker();
7921 return Ok(());
7922 };
7923 let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
7924 self.close_grant_picker();
7925 return Ok(());
7926 };
7927 self.close_grant_picker();
7928 self.use_grant(picker.grant_instance_id, opt.target_instance_id)
7929 .await?;
7930 self.state.push_log(format!(
7931 "Applying {} onto {}…",
7932 picker.effect_id, opt.label
7933 ));
7934 Ok(())
7935 }
7936
7937 pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
7941 let Some(row) = self.state.inventory_selected_row() else {
7942 anyhow::bail!("inventory empty");
7943 };
7944 if row.is_equip_shell {
7945 anyhow::bail!("this is a worn bag — press Enter to unequip it");
7946 }
7947 if row.is_chest_shell {
7948 return self.open_chest_pickup_picker();
7949 }
7950 let Some(instance_id) = row.stack.item_instance_id else {
7951 anyhow::bail!("item has no instance id");
7952 };
7953 let mut options = self.state.move_destinations_for(
7954 &row.from,
7955 row.from_parent_instance_id,
7956 row.stack.item_instance_id,
7957 &row.stack.template_id,
7958 );
7959 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7960 let category = self.state.inventory_item_category(&row.stack.template_id);
7961 if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
7962 if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
7963 options.insert(
7964 0,
7965 MoveOption {
7966 label: "Sell plot to crown…".into(),
7967 kind: MoveOptionKind::SellPlotToCrown { plot_id },
7968 },
7969 );
7970 }
7971 }
7972 if on_person && category == Some("consumable") {
7973 if GameState::stack_is_item_grant(&row.stack) {
7974 options.insert(
7975 0,
7976 MoveOption {
7977 label: "Apply onto gear…".into(),
7978 kind: MoveOptionKind::GrantApply,
7979 },
7980 );
7981 } else {
7982 options.insert(
7983 0,
7984 MoveOption {
7985 label: "Use (eat / drink)".into(),
7986 kind: MoveOptionKind::Use,
7987 },
7988 );
7989 }
7990 }
7991 let item_label = row
7992 .stack
7993 .display_name
7994 .clone()
7995 .unwrap_or_else(|| row.stack.template_id.clone());
7996 let initial_qty = if row.stack.quantity > 1 { 1 } else { row.stack.quantity };
7999 self.state.move_picker = Some(MovePicker {
8000 item_instance_id: instance_id,
8001 from: row.from,
8002 item_label,
8003 template_id: row.stack.template_id.clone(),
8004 stack_quantity: row.stack.quantity,
8005 quantity: initial_qty.max(1),
8006 options,
8007 filter: String::new(),
8008 filter_focused: false,
8009 });
8010 self.state.move_picker_index = 0;
8011 self.state.show_move_picker = true;
8012 self.state.show_destroy_picker = false;
8013 self.state.destroy_confirm_pending = false;
8014 self.state.destroy_picker = None;
8015 self.state.clamp_move_picker_quantity();
8016 Ok(())
8017 }
8018
8019 pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
8021 let Some(row) = self.state.inventory_selected_row() else {
8022 anyhow::bail!("inventory empty");
8023 };
8024 if !row.is_chest_shell {
8025 anyhow::bail!("not a placed chest");
8026 }
8027 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
8028 anyhow::bail!("not a placed chest");
8029 };
8030 let Some(instance_id) = row.stack.item_instance_id else {
8031 anyhow::bail!("chest has no instance id");
8032 };
8033 let chest = self
8034 .state
8035 .placed_containers
8036 .iter()
8037 .find(|c| c.id == *container_id)
8038 .cloned()
8039 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
8040 let (px, py) = self.state.player_position();
8041 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
8042 anyhow::bail!("too far from {}", chest.display_name);
8043 }
8044 if chest.locked && !chest.accessible {
8045 anyhow::bail!(
8046 "need the matching key for {} before picking it up",
8047 chest.display_name
8048 );
8049 }
8050 let options = self.state.chest_pickup_destinations(container_id);
8051 let item_label = row
8052 .stack
8053 .display_name
8054 .clone()
8055 .unwrap_or_else(|| row.stack.template_id.clone());
8056 self.state.move_picker = Some(MovePicker {
8057 item_instance_id: instance_id,
8058 from: row.from.clone(),
8059 item_label,
8060 template_id: row.stack.template_id.clone(),
8061 stack_quantity: 1,
8062 quantity: 1,
8063 options,
8064 filter: String::new(),
8065 filter_focused: false,
8066 });
8067 self.state.move_picker_index = 0;
8068 self.state.show_move_picker = true;
8069 self.state.show_destroy_picker = false;
8070 self.state.destroy_confirm_pending = false;
8071 self.state.destroy_picker = None;
8072 Ok(())
8073 }
8074
8075 pub fn close_move_picker(&mut self) {
8076 self.state.show_move_picker = false;
8077 self.state.move_picker = None;
8078 }
8079
8080 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
8081 self.state.move_picker_adjust_quantity(delta);
8082 }
8083
8084 pub fn move_picker_set_quantity_max(&mut self) {
8085 self.state.move_picker_set_quantity_max();
8086 }
8087
8088 pub fn move_picker_set_quantity_min(&mut self) {
8089 self.state.move_picker_set_quantity_min();
8090 }
8091
8092 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
8093 self.state.destroy_picker_adjust_quantity(delta);
8094 }
8095
8096 pub fn destroy_picker_set_quantity_max(&mut self) {
8097 self.state.destroy_picker_set_quantity_max();
8098 }
8099
8100 pub fn destroy_picker_set_quantity_min(&mut self) {
8101 self.state.destroy_picker_set_quantity_min();
8102 }
8103
8104 async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
8105 let Some(picker) = self.state.move_picker.clone() else {
8106 self.close_move_picker();
8107 return Ok(());
8108 };
8109 let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
8110 self.close_move_picker();
8111 return Ok(());
8112 };
8113 match option.kind {
8114 MoveOptionKind::Cancel => {
8115 self.close_move_picker();
8116 }
8117 MoveOptionKind::Use => {
8118 self.close_move_picker();
8119 self.use_item(&picker.template_id).await?;
8120 }
8121 MoveOptionKind::GrantApply => {
8122 self.close_move_picker();
8123 self.open_grant_target_picker()?;
8124 }
8125 MoveOptionKind::SellPlotToCrown { plot_id } => {
8126 self.close_move_picker();
8127 self.confirm_sell_plot_to_crown(plot_id).await?;
8128 }
8129 MoveOptionKind::RelocatePlaced { container_id } => {
8130 self.close_move_picker();
8131 self.state.show_inventory_menu = false;
8132 self.begin_relocate_container(&container_id)?;
8133 }
8134 MoveOptionKind::Drop => {
8135 self.close_move_picker();
8136 if self
8137 .state
8138 .hand_equipped_instance_ids()
8139 .contains(&picker.item_instance_id)
8140 {
8141 anyhow::bail!("unequip that item first");
8142 }
8143 if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
8144 if self.state.deed_bound(&stack) {
8145 anyhow::bail!(
8146 "cannot drop a property deed — store it or trade it to another player"
8147 );
8148 }
8149 if self.state.key_drop_blocked(&stack) {
8150 anyhow::bail!("cannot drop the key while its chest is locked");
8151 }
8152 }
8153 self.drop_item(picker.item_instance_id, picker.from).await?;
8154 self.state
8155 .push_log(format!("Dropped {}", picker.item_label));
8156 }
8157 MoveOptionKind::PickupPlaced {
8158 container_id,
8159 nest_location,
8160 nest_parent_instance_id,
8161 } => {
8162 self.close_move_picker();
8163 self.pickup_container(container_id.clone()).await?;
8164 let nest_into_bag = nest_parent_instance_id.is_some()
8165 || !matches!(
8166 nest_location,
8167 flatland_protocol::InventoryLocation::Root
8168 );
8169 if nest_into_bag {
8170 self.move_item(
8171 picker.item_instance_id,
8172 flatland_protocol::InventoryLocation::Root,
8173 nest_location,
8174 nest_parent_instance_id,
8175 None,
8176 )
8177 .await?;
8178 self.state
8179 .push_log(format!("Picked up {} into bag", picker.item_label));
8180 } else {
8181 self.state
8182 .push_log(format!("Picked up {}", picker.item_label));
8183 }
8184 }
8185 MoveOptionKind::Move {
8186 location,
8187 parent_instance_id,
8188 } => {
8189 self.close_move_picker();
8190 let qty = if picker.quantity >= picker.stack_quantity {
8191 None
8192 } else {
8193 Some(picker.quantity)
8194 };
8195 self.move_item(
8196 picker.item_instance_id,
8197 picker.from,
8198 location,
8199 parent_instance_id,
8200 qty,
8201 )
8202 .await?;
8203 let moved = qty.unwrap_or(picker.stack_quantity);
8204 if moved >= picker.stack_quantity {
8205 self.state.push_log(format!("Moved {}", picker.item_label));
8206 } else {
8207 self.state.push_log(format!(
8208 "Moved {} ×{} of {}",
8209 picker.item_label, moved, picker.stack_quantity
8210 ));
8211 }
8212 }
8213 }
8214 Ok(())
8215 }
8216
8217 pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
8219 let Some(row) = self.state.inventory_selected_row() else {
8220 anyhow::bail!("inventory empty");
8221 };
8222 if row.is_equip_shell {
8223 anyhow::bail!("unequip the bag first (Enter), then drop from your person");
8224 }
8225 if row.is_chest_shell {
8226 anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
8227 }
8228 let Some(inst) = row.stack.item_instance_id else {
8229 anyhow::bail!("item has no instance id");
8230 };
8231 if self.state.hand_equipped_instance_ids().contains(&inst) {
8232 anyhow::bail!("unequip that item first");
8233 }
8234 if self.state.deed_bound(&row.stack) {
8235 anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
8236 }
8237 if self.state.key_drop_blocked(&row.stack) {
8238 anyhow::bail!("cannot drop the key while its chest is locked");
8239 }
8240 let label = row
8241 .stack
8242 .display_name
8243 .clone()
8244 .unwrap_or_else(|| row.stack.template_id.clone());
8245 self.drop_item(inst, row.from).await?;
8246 self.state.push_log(format!("Dropped {label}"));
8247 Ok(())
8248 }
8249
8250 pub async fn drop_item(
8251 &mut self,
8252 item_instance_id: uuid::Uuid,
8253 from: flatland_protocol::InventoryLocation,
8254 ) -> anyhow::Result<()> {
8255 self.seq += 1;
8256 self.session
8257 .submit_intent(Intent::DropItem {
8258 entity_id: self.state.entity_id,
8259 item_instance_id,
8260 from,
8261 seq: self.seq,
8262 })
8263 .await?;
8264 self.state.intents_sent += 1;
8265 Ok(())
8266 }
8267
8268 pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
8270 let Some(row) = self.state.inventory_selected_row() else {
8271 anyhow::bail!("inventory empty");
8272 };
8273 if row.is_equip_shell {
8274 anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
8275 }
8276 if row.is_chest_shell {
8277 anyhow::bail!("can't destroy a placed chest from the inventory list");
8278 }
8279 let Some(instance_id) = row.stack.item_instance_id else {
8280 anyhow::bail!("item has no instance id");
8281 };
8282 if self.state.hand_equipped_instance_ids().contains(&instance_id) {
8283 anyhow::bail!("unequip that item first");
8284 }
8285 if self.state.deed_bound(&row.stack) {
8286 anyhow::bail!(
8287 "cannot destroy a property deed — store it or trade it to another player"
8288 );
8289 }
8290 if self.state.key_drop_blocked(&row.stack) {
8291 anyhow::bail!("cannot destroy the key while its chest is locked");
8292 }
8293 let item_label = row
8294 .stack
8295 .display_name
8296 .clone()
8297 .unwrap_or_else(|| row.stack.template_id.clone());
8298 self.state.destroy_picker = Some(DestroyPicker {
8299 item_instance_id: instance_id,
8300 from: row.from,
8301 item_label,
8302 stack_quantity: row.stack.quantity,
8303 quantity: row.stack.quantity,
8304 });
8305 self.state.destroy_confirm_pending = false;
8306 self.state.show_destroy_picker = true;
8307 self.state.show_move_picker = false;
8308 self.state.move_picker = None;
8309 Ok(())
8310 }
8311
8312 pub fn close_destroy_picker(&mut self) {
8313 self.state.show_destroy_picker = false;
8314 self.state.destroy_confirm_pending = false;
8315 self.state.destroy_picker = None;
8316 }
8317
8318 pub fn cancel_destroy_confirm(&mut self) {
8319 self.state.destroy_confirm_pending = false;
8320 }
8321
8322 pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
8323 if self.state.destroy_picker.is_none() {
8324 self.close_destroy_picker();
8325 return Ok(());
8326 }
8327 self.state.destroy_confirm_pending = true;
8328 Ok(())
8329 }
8330
8331 pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
8332 let Some(picker) = self.state.destroy_picker.clone() else {
8333 self.close_destroy_picker();
8334 return Ok(());
8335 };
8336 let qty = if picker.quantity >= picker.stack_quantity {
8337 None
8338 } else {
8339 Some(picker.quantity)
8340 };
8341 self.destroy_item(picker.item_instance_id, picker.from, qty)
8342 .await?;
8343 let destroyed = qty.unwrap_or(picker.stack_quantity);
8344 if destroyed >= picker.stack_quantity {
8345 self.state
8346 .push_log(format!("Destroyed {}", picker.item_label));
8347 } else {
8348 self.state.push_log(format!(
8349 "Destroyed {} ×{} of {}",
8350 picker.item_label, destroyed, picker.stack_quantity
8351 ));
8352 }
8353 self.close_destroy_picker();
8354 Ok(())
8355 }
8356
8357 pub async fn destroy_item(
8358 &mut self,
8359 item_instance_id: uuid::Uuid,
8360 from: flatland_protocol::InventoryLocation,
8361 quantity: Option<u32>,
8362 ) -> anyhow::Result<()> {
8363 self.seq += 1;
8364 self.session
8365 .submit_intent(Intent::DestroyItem {
8366 entity_id: self.state.entity_id,
8367 item_instance_id,
8368 from,
8369 quantity,
8370 seq: self.seq,
8371 })
8372 .await?;
8373 self.state.intents_sent += 1;
8374 Ok(())
8375 }
8376
8377 pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
8379 if let Some(row) = self.state.inventory_selected_row() {
8380 if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
8381 return self.toggle_placed_chest_lock(container_id).await;
8382 }
8383 }
8384 self.toggle_nearby_chest_lock().await
8385 }
8386
8387 pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
8388 let chest = self
8389 .state
8390 .placed_containers
8391 .iter()
8392 .find(|c| c.id == container_id)
8393 .cloned()
8394 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
8395 let (px, py) = self.state.player_position();
8396 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
8397 anyhow::bail!("too far from {}", chest.display_name);
8398 }
8399 if !chest.accessible && chest.locked {
8400 anyhow::bail!(
8401 "need the matching key for {} (each crafted chest has its own key)",
8402 chest.display_name
8403 );
8404 }
8405 let lock = !chest.locked;
8406 self.set_container_locked(
8407 flatland_protocol::InventoryLocation::Placed {
8408 container_id: chest.id.clone(),
8409 },
8410 lock,
8411 )
8412 .await?;
8413 self.state.push_log(if lock {
8414 format!("Locked {}", chest.display_name)
8415 } else {
8416 format!("Unlocked {}", chest.display_name)
8417 });
8418 Ok(())
8419 }
8420
8421 pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
8423 let chest = self
8424 .state
8425 .nearest_placed_container(CONTAINER_RANGE_M)
8426 .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
8427 self.toggle_placed_chest_lock(&chest.id).await
8428 }
8429
8430 pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
8431 self.equip_mainhand(None).await
8432 }
8433
8434 pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
8435 if !self.state.is_alive() {
8436 anyhow::bail!("you are dead");
8437 }
8438 self.seq += 1;
8439 self.session
8440 .submit_intent(Intent::EquipOffhand {
8441 entity_id: self.state.entity_id,
8442 template_id,
8443 instance_id: None,
8444 seq: self.seq,
8445 })
8446 .await?;
8447 self.state.intents_sent += 1;
8448 Ok(())
8449 }
8450
8451 pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
8452 self.equip_offhand(None).await
8453 }
8454
8455 pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
8456 let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
8457 for slot in slots {
8458 self.equip_worn(slot, None).await?;
8459 }
8460 Ok(())
8461 }
8462
8463 pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
8464 let (px, py) = self.state.player_position();
8465 let nearest = self
8466 .state
8467 .placed_containers
8468 .iter()
8469 .min_by(|a, b| {
8470 let da = (a.x - px).hypot(a.y - py);
8471 let db = (b.x - px).hypot(b.y - py);
8472 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
8473 })
8474 .cloned();
8475 let Some(chest) = nearest else {
8476 anyhow::bail!("no chest nearby");
8477 };
8478 if (chest.x - px).hypot(chest.y - py) > 2.0 {
8479 anyhow::bail!("too far from chest");
8480 }
8481 self.pickup_container(chest.id).await
8482 }
8483
8484 pub async fn equip_worn(
8485 &mut self,
8486 slot: BodySlot,
8487 instance_id: Option<uuid::Uuid>,
8488 ) -> anyhow::Result<()> {
8489 self.seq += 1;
8490 self.session
8491 .submit_intent(Intent::EquipWorn {
8492 entity_id: self.state.entity_id,
8493 slot,
8494 instance_id,
8495 seq: self.seq,
8496 })
8497 .await?;
8498 self.state.intents_sent += 1;
8499 Ok(())
8500 }
8501
8502 pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
8503 self.seq += 1;
8504 self.session
8505 .submit_intent(Intent::PlaceContainer {
8506 entity_id: self.state.entity_id,
8507 item_instance_id,
8508 seq: self.seq,
8509 })
8510 .await?;
8511 self.state.intents_sent += 1;
8512 Ok(())
8513 }
8514
8515 pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
8516 self.seq += 1;
8517 self.session
8518 .submit_intent(Intent::PickupContainer {
8519 entity_id: self.state.entity_id,
8520 container_id,
8521 seq: self.seq,
8522 })
8523 .await?;
8524 self.state.intents_sent += 1;
8525 Ok(())
8526 }
8527
8528 pub async fn move_item(
8529 &mut self,
8530 item_instance_id: uuid::Uuid,
8531 from: flatland_protocol::InventoryLocation,
8532 to: flatland_protocol::InventoryLocation,
8533 to_parent_instance_id: Option<uuid::Uuid>,
8534 quantity: Option<u32>,
8535 ) -> anyhow::Result<()> {
8536 self.seq += 1;
8537 self.session
8538 .submit_intent(Intent::MoveItem {
8539 entity_id: self.state.entity_id,
8540 item_instance_id,
8541 from,
8542 to,
8543 to_parent_instance_id,
8544 quantity,
8545 seq: self.seq,
8546 })
8547 .await?;
8548 self.state.intents_sent += 1;
8549 Ok(())
8550 }
8551
8552 pub async fn set_container_locked(
8553 &mut self,
8554 location: flatland_protocol::InventoryLocation,
8555 locked: bool,
8556 ) -> anyhow::Result<()> {
8557 self.seq += 1;
8558 self.session
8559 .submit_intent(Intent::SetContainerLocked {
8560 entity_id: self.state.entity_id,
8561 location,
8562 locked,
8563 seq: self.seq,
8564 })
8565 .await?;
8566 self.state.intents_sent += 1;
8567 Ok(())
8568 }
8569
8570 pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
8571 if !self.state.is_alive() {
8572 anyhow::bail!("you are dead");
8573 }
8574 self.seq += 1;
8575 self.session
8576 .submit_intent(Intent::Use {
8577 entity_id: self.state.entity_id,
8578 template_id: template_id.to_string(),
8579 seq: self.seq,
8580 })
8581 .await?;
8582 self.state.intents_sent += 1;
8583 Ok(())
8584 }
8585
8586 pub async fn use_grant(
8588 &mut self,
8589 grant_instance_id: uuid::Uuid,
8590 target_instance_id: uuid::Uuid,
8591 ) -> anyhow::Result<()> {
8592 if !self.state.is_alive() {
8593 anyhow::bail!("you are dead");
8594 }
8595 self.seq += 1;
8596 self.session
8597 .submit_intent(Intent::UseGrant {
8598 entity_id: self.state.entity_id,
8599 grant_instance_id,
8600 target_instance_id,
8601 seq: self.seq,
8602 })
8603 .await?;
8604 self.state.intents_sent += 1;
8605 Ok(())
8606 }
8607
8608 pub fn open_craft_menu(&mut self) {
8609 self.state.show_craft_menu = true;
8610 self.state.show_shop_menu = false;
8611 self.state.shop_catalog = None;
8612 self.state.show_stats = false;
8613 self.state.show_inventory_menu = false;
8614 if self.state.blueprints.is_empty() {
8615 self.state.craft_menu_index = 0;
8616 self.state.craft_batch_quantity = 1;
8617 return;
8618 }
8619 self.state.craft_menu_index = self
8620 .state
8621 .craft_menu_index
8622 .min(self.state.blueprints.len() - 1);
8623 if let Some(idx) = self
8624 .state
8625 .blueprints
8626 .iter()
8627 .position(|bp| self.state.can_craft_blueprint(bp))
8628 {
8629 self.state.craft_menu_index = idx;
8630 }
8631 self.state.clamp_craft_batch_quantity();
8632 }
8633
8634 pub fn close_craft_menu(&mut self) {
8635 self.state.show_craft_menu = false;
8636 }
8637
8638 pub fn toggle_keychain_menu(&mut self) {
8639 if self.state.show_keychain_menu {
8640 self.close_keychain_menu();
8641 } else {
8642 self.state.show_keychain_menu = true;
8643 self.state.show_craft_menu = false;
8644 self.state.show_shop_menu = false;
8645 self.state.show_inventory_menu = false;
8646 let n = self.state.keychain_entries().len();
8647 if n == 0 {
8648 self.state.keychain_menu_index = 0;
8649 } else {
8650 self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
8651 }
8652 }
8653 }
8654
8655 pub fn close_keychain_menu(&mut self) {
8656 self.state.show_keychain_menu = false;
8657 }
8658
8659 pub fn keychain_menu_move(&mut self, delta: i32) {
8660 let n = self.state.keychain_entries().len();
8661 if n == 0 {
8662 self.state.keychain_menu_index = 0;
8663 return;
8664 }
8665 let idx = self.state.keychain_menu_index as i32 + delta;
8666 self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
8667 }
8668
8669 pub fn keychain_menu_page(&mut self, pages: i32) {
8670 let n = self.state.keychain_entries().len();
8671 self.state.keychain_menu_index =
8672 page_list_index(self.state.keychain_menu_index, pages, n);
8673 }
8674
8675 pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
8676 if !self.state.is_alive() {
8677 anyhow::bail!("you are dead");
8678 }
8679 let entries = self.state.keychain_entries();
8680 let Some(entry) = entries.get(self.state.keychain_menu_index) else {
8681 anyhow::bail!("nothing selected");
8682 };
8683 let Some(instance_id) = entry.stack.item_instance_id else {
8684 anyhow::bail!("key has no instance id");
8685 };
8686 if entry.stowed {
8687 self.move_item(
8688 instance_id,
8689 flatland_protocol::InventoryLocation::Keychain,
8690 flatland_protocol::InventoryLocation::Root,
8691 None,
8692 Some(1),
8693 )
8694 .await
8695 } else {
8696 self.move_item(
8697 instance_id,
8698 flatland_protocol::InventoryLocation::Root,
8699 flatland_protocol::InventoryLocation::Keychain,
8700 None,
8701 Some(1),
8702 )
8703 .await
8704 }
8705 }
8706
8707 pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
8708 let npc_id = self
8709 .state
8710 .shop_catalog
8711 .as_ref()
8712 .map(|c| c.npc_id.clone());
8713 self.state.show_shop_menu = false;
8714 self.state.shop_catalog = None;
8715 self.state.clear_shop_trade_log();
8716 if let Some(npc_id) = npc_id {
8717 self.seq += 1;
8718 self.session
8719 .submit_intent(Intent::ShopClose {
8720 entity_id: self.state.entity_id,
8721 npc_id,
8722 seq: self.seq,
8723 })
8724 .await?;
8725 self.state.intents_sent += 1;
8726 }
8727 Ok(())
8728 }
8729
8730 pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8731 let Some(panel) = self.state.bank_panel.clone() else {
8732 return Ok(());
8733 };
8734 self.seq += 1;
8735 self.session
8736 .submit_intent(Intent::BankDeposit {
8737 entity_id: self.state.entity_id,
8738 npc_id: panel.npc_id,
8739 amount_copper,
8740 seq: self.seq,
8741 })
8742 .await?;
8743 self.state.intents_sent += 1;
8744 Ok(())
8745 }
8746
8747 pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8748 let Some(panel) = self.state.bank_panel.clone() else {
8749 return Ok(());
8750 };
8751 self.seq += 1;
8752 self.session
8753 .submit_intent(Intent::BankWithdraw {
8754 entity_id: self.state.entity_id,
8755 npc_id: panel.npc_id,
8756 amount_copper,
8757 seq: self.seq,
8758 })
8759 .await?;
8760 self.state.intents_sent += 1;
8761 Ok(())
8762 }
8763
8764 pub async fn bank_transfer(
8765 &mut self,
8766 to_character_id: Option<uuid::Uuid>,
8767 to_name: String,
8768 amount_copper: u64,
8769 ) -> anyhow::Result<()> {
8770 let Some(panel) = self.state.bank_panel.clone() else {
8771 return Ok(());
8772 };
8773 self.seq += 1;
8774 self.session
8775 .submit_intent(Intent::BankTransfer {
8776 entity_id: self.state.entity_id,
8777 npc_id: panel.npc_id,
8778 to_character_id,
8779 to_name,
8780 amount_copper,
8781 seq: self.seq,
8782 })
8783 .await?;
8784 self.state.intents_sent += 1;
8785 Ok(())
8786 }
8787
8788 pub fn bank_menu_move(&mut self, delta: i32) {
8789 let n = self.state.bank_menu_options().len();
8790 if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
8791 return;
8792 }
8793 let idx = self.state.bank_menu_index as i32;
8794 self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8795 }
8796
8797 pub fn storage_menu_move(&mut self, delta: i32) {
8798 let n = self.state.storage_menu_options().len();
8799 if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
8800 return;
8801 }
8802 let idx = self.state.storage_menu_index as i32;
8803 self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8804 }
8805
8806 pub fn storage_pick_move(&mut self, delta: i32) {
8807 let n = match &self.state.storage_ui_mode {
8808 StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
8809 StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
8810 self.state.storage_vault_options().len()
8811 }
8812 StorageUiMode::Menu
8813 | StorageUiMode::StoreAmount { .. }
8814 | StorageUiMode::TakeAmount { .. }
8815 | StorageUiMode::ShipAmount { .. } => 0,
8816 };
8817 if n == 0 {
8818 return;
8819 }
8820 match &mut self.state.storage_ui_mode {
8821 StorageUiMode::StorePick { index }
8822 | StorageUiMode::TakePick { index }
8823 | StorageUiMode::ShipPick { index, .. } => {
8824 *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8825 }
8826 StorageUiMode::Menu
8827 | StorageUiMode::StoreAmount { .. }
8828 | StorageUiMode::TakeAmount { .. }
8829 | StorageUiMode::ShipAmount { .. } => {}
8830 }
8831 }
8832
8833 pub fn storage_ui_back(&mut self) {
8834 self.state.storage_ui_mode = match &self.state.storage_ui_mode {
8835 StorageUiMode::StoreAmount { pick_index, .. } => StorageUiMode::StorePick {
8836 index: *pick_index,
8837 },
8838 StorageUiMode::TakeAmount { pick_index, .. } => StorageUiMode::TakePick {
8839 index: *pick_index,
8840 },
8841 StorageUiMode::ShipAmount {
8842 dest_building_id,
8843 dest_label,
8844 pick_index,
8845 ..
8846 } => StorageUiMode::ShipPick {
8847 dest_building_id: dest_building_id.clone(),
8848 dest_label: dest_label.clone(),
8849 index: *pick_index,
8850 },
8851 StorageUiMode::StorePick { .. }
8852 | StorageUiMode::TakePick { .. }
8853 | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
8854 StorageUiMode::Menu => StorageUiMode::Menu,
8855 };
8856 }
8857
8858 pub fn storage_amount_append_char(&mut self, c: char) {
8859 match &mut self.state.storage_ui_mode {
8860 StorageUiMode::StoreAmount { input, .. }
8861 | StorageUiMode::TakeAmount { input, .. }
8862 | StorageUiMode::ShipAmount { input, .. } => {
8863 if c.is_ascii_digit() && input.len() < 8 {
8864 input.push(c);
8865 }
8866 }
8867 _ => {}
8868 }
8869 }
8870
8871 pub fn storage_amount_backspace(&mut self) {
8872 match &mut self.state.storage_ui_mode {
8873 StorageUiMode::StoreAmount { input, .. }
8874 | StorageUiMode::TakeAmount { input, .. }
8875 | StorageUiMode::ShipAmount { input, .. } => {
8876 input.pop();
8877 }
8878 _ => {}
8879 }
8880 }
8881
8882 pub fn storage_ui_typing(&self) -> bool {
8883 matches!(
8884 self.state.storage_ui_mode,
8885 StorageUiMode::StoreAmount { .. }
8886 | StorageUiMode::TakeAmount { .. }
8887 | StorageUiMode::ShipAmount { .. }
8888 )
8889 }
8890
8891 pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
8892 match self.state.storage_ui_mode.clone() {
8893 StorageUiMode::Menu => {
8894 let index = self.state.storage_menu_index;
8895 match index {
8896 0 => {
8897 let opts = self.state.storage_store_options();
8898 if opts.is_empty() {
8899 self.state.push_log("Nothing loose to store.");
8900 return Ok(());
8901 }
8902 self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
8903 }
8904 1 => {
8905 let opts = self.state.storage_vault_options();
8906 if opts.is_empty() {
8907 self.state.push_log("Vault is empty.");
8908 return Ok(());
8909 }
8910 self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
8911 }
8912 n => {
8913 let dest = self
8914 .state
8915 .storage_panel
8916 .as_ref()
8917 .and_then(|p| p.ship_destinations.get(n - 2))
8918 .cloned();
8919 let Some(dest) = dest else {
8920 return Ok(());
8921 };
8922 let opts = self.state.storage_vault_options();
8923 if opts.is_empty() {
8924 self.state
8925 .push_log("Vault is empty — nothing to ship.");
8926 return Ok(());
8927 }
8928 self.state.storage_ui_mode = StorageUiMode::ShipPick {
8929 dest_building_id: dest.building_id,
8930 dest_label: dest.label,
8931 index: 0,
8932 };
8933 }
8934 }
8935 }
8936 StorageUiMode::StorePick { index } => {
8937 let opts = self.state.storage_store_options();
8938 let Some(opt) = opts.get(index) else {
8939 self.state.push_log("Nothing loose to store.");
8940 self.state.storage_ui_mode = StorageUiMode::Menu;
8941 return Ok(());
8942 };
8943 self.state.storage_ui_mode = StorageUiMode::StoreAmount {
8944 pick_index: index,
8945 item_instance_id: opt.item_instance_id,
8946 label: opt.label.clone(),
8947 max_qty: opt.quantity.max(1),
8948 input: String::new(),
8949 };
8950 }
8951 StorageUiMode::TakePick { index } => {
8952 let opts = self.state.storage_vault_options();
8953 let Some(opt) = opts.get(index) else {
8954 self.state.push_log("Vault is empty.");
8955 self.state.storage_ui_mode = StorageUiMode::Menu;
8956 return Ok(());
8957 };
8958 self.state.storage_ui_mode = StorageUiMode::TakeAmount {
8959 pick_index: index,
8960 item_instance_id: opt.item_instance_id,
8961 label: opt.label.clone(),
8962 max_qty: opt.quantity.max(1),
8963 input: String::new(),
8964 };
8965 }
8966 StorageUiMode::ShipPick {
8967 dest_building_id,
8968 dest_label,
8969 index,
8970 } => {
8971 let opts = self.state.storage_vault_options();
8972 let Some(opt) = opts.get(index) else {
8973 self.state
8974 .push_log("Vault is empty — nothing to ship.");
8975 self.state.storage_ui_mode = StorageUiMode::Menu;
8976 return Ok(());
8977 };
8978 self.state.storage_ui_mode = StorageUiMode::ShipAmount {
8979 dest_building_id,
8980 dest_label,
8981 pick_index: index,
8982 item_instance_id: opt.item_instance_id,
8983 label: opt.label.clone(),
8984 max_qty: opt.quantity.max(1),
8985 input: String::new(),
8986 };
8987 }
8988 StorageUiMode::StoreAmount {
8989 item_instance_id,
8990 max_qty,
8991 input,
8992 ..
8993 } => {
8994 let Some(qty) = parse_storage_quantity(&input) else {
8995 self.state
8996 .push_log("Enter a quantity (blank or 0 = all).");
8997 return Ok(());
8998 };
8999 let qty = qty.map(|n| n.min(max_qty).max(1));
9000 self.storage_store(item_instance_id, qty).await?;
9001 self.state.storage_ui_mode = StorageUiMode::Menu;
9002 }
9003 StorageUiMode::TakeAmount {
9004 item_instance_id,
9005 max_qty,
9006 input,
9007 ..
9008 } => {
9009 let Some(qty) = parse_storage_quantity(&input) else {
9010 self.state
9011 .push_log("Enter a quantity (blank or 0 = all).");
9012 return Ok(());
9013 };
9014 let qty = qty.map(|n| n.min(max_qty).max(1));
9015 self.storage_take(item_instance_id, qty).await?;
9016 self.state.storage_ui_mode = StorageUiMode::Menu;
9017 }
9018 StorageUiMode::ShipAmount {
9019 dest_building_id,
9020 item_instance_id,
9021 max_qty,
9022 input,
9023 ..
9024 } => {
9025 let Some(qty) = parse_storage_quantity(&input) else {
9026 self.state
9027 .push_log("Enter a quantity (blank or 0 = all).");
9028 return Ok(());
9029 };
9030 let qty = qty.map(|n| n.min(max_qty).max(1));
9031 self.storage_ship(dest_building_id, item_instance_id, qty)
9032 .await?;
9033 self.state.storage_ui_mode = StorageUiMode::Menu;
9034 }
9035 }
9036 Ok(())
9037 }
9038
9039 pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
9040 match self.state.bank_ui_mode.clone() {
9041 BankUiMode::Menu => {
9042 let choice = self
9043 .state
9044 .bank_menu_options()
9045 .get(self.state.bank_menu_index)
9046 .copied()
9047 .unwrap_or("Deposit…");
9048 match choice {
9049 "Withdraw…" => {
9050 self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
9051 input: String::new(),
9052 };
9053 }
9054 "Deposit all" => self.bank_deposit(0).await?,
9055 "Withdraw all" => self.bank_withdraw(0).await?,
9056 "Transfer…" => {
9057 self.state.bank_ui_mode = BankUiMode::TransferName {
9058 input: String::new(),
9059 };
9060 }
9061 _ => {
9062 self.state.bank_ui_mode = BankUiMode::DepositAmount {
9063 input: String::new(),
9064 };
9065 }
9066 }
9067 }
9068 BankUiMode::DepositAmount { input } => {
9069 let Some(amount) = parse_bank_copper_amount(&input) else {
9070 self.state
9071 .push_log("Enter a copper amount (blank or 0 = everything on person).");
9072 return Ok(());
9073 };
9074 self.bank_deposit(amount).await?;
9075 self.state.bank_ui_mode = BankUiMode::Menu;
9076 }
9077 BankUiMode::WithdrawAmount { input } => {
9078 let Some(amount) = parse_bank_copper_amount(&input) else {
9079 self.state
9080 .push_log("Enter a copper amount (blank or 0 = full ledger).");
9081 return Ok(());
9082 };
9083 self.bank_withdraw(amount).await?;
9084 self.state.bank_ui_mode = BankUiMode::Menu;
9085 }
9086 BankUiMode::TransferName { input } => {
9087 let name = input.trim().to_string();
9088 if name.is_empty() {
9089 self.state.push_log("Enter the recipient character name.");
9090 return Ok(());
9091 }
9092 self.state.bank_ui_mode = BankUiMode::TransferAmount {
9093 to_name: name,
9094 input: String::new(),
9095 };
9096 }
9097 BankUiMode::TransferAmount { to_name, input } => {
9098 let amount: u64 = match input.trim().parse() {
9099 Ok(v) if v > 0 => v,
9100 _ => {
9101 self.state
9102 .push_log("Enter a positive copper amount to transfer.");
9103 return Ok(());
9104 }
9105 };
9106 self.bank_transfer(None, to_name, amount).await?;
9107 self.state.bank_ui_mode = BankUiMode::Menu;
9108 }
9109 }
9110 Ok(())
9111 }
9112
9113 pub fn bank_transfer_back(&mut self) {
9114 match &self.state.bank_ui_mode {
9115 BankUiMode::TransferAmount { to_name, .. } => {
9116 self.state.bank_ui_mode = BankUiMode::TransferName {
9117 input: to_name.clone(),
9118 };
9119 }
9120 BankUiMode::TransferName { .. }
9121 | BankUiMode::DepositAmount { .. }
9122 | BankUiMode::WithdrawAmount { .. } => {
9123 self.state.bank_ui_mode = BankUiMode::Menu;
9124 }
9125 BankUiMode::Menu => {}
9126 }
9127 }
9128
9129 pub fn bank_transfer_append_char(&mut self, c: char) {
9130 match &mut self.state.bank_ui_mode {
9131 BankUiMode::TransferName { input } => {
9132 if input.len() < 32 && !c.is_control() {
9133 input.push(c);
9134 }
9135 }
9136 BankUiMode::DepositAmount { input }
9137 | BankUiMode::WithdrawAmount { input }
9138 | BankUiMode::TransferAmount { input, .. } => {
9139 if c.is_ascii_digit() && input.len() < 12 {
9140 input.push(c);
9141 }
9142 }
9143 BankUiMode::Menu => {}
9144 }
9145 }
9146
9147 pub fn bank_transfer_backspace(&mut self) {
9148 match &mut self.state.bank_ui_mode {
9149 BankUiMode::TransferName { input }
9150 | BankUiMode::DepositAmount { input }
9151 | BankUiMode::WithdrawAmount { input }
9152 | BankUiMode::TransferAmount { input, .. } => {
9153 input.pop();
9154 }
9155 BankUiMode::Menu => {}
9156 }
9157 }
9158
9159 pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
9160 let npc_id = self
9161 .state
9162 .bank_panel
9163 .as_ref()
9164 .map(|p| p.npc_id.clone());
9165 self.state.clear_bank_panel();
9166 if let Some(npc_id) = npc_id {
9167 self.seq += 1;
9168 self.session
9169 .submit_intent(Intent::BankClose {
9170 entity_id: self.state.entity_id,
9171 npc_id,
9172 seq: self.seq,
9173 })
9174 .await?;
9175 self.state.intents_sent += 1;
9176 }
9177 Ok(())
9178 }
9179
9180 pub async fn storage_store(
9181 &mut self,
9182 item_instance_id: uuid::Uuid,
9183 quantity: Option<u32>,
9184 ) -> anyhow::Result<()> {
9185 let Some(panel) = self.state.storage_panel.clone() else {
9186 return Ok(());
9187 };
9188 self.seq += 1;
9189 self.session
9190 .submit_intent(Intent::StorageStore {
9191 entity_id: self.state.entity_id,
9192 npc_id: panel.npc_id,
9193 item_instance_id,
9194 quantity,
9195 seq: self.seq,
9196 })
9197 .await?;
9198 self.state.intents_sent += 1;
9199 Ok(())
9200 }
9201
9202 pub async fn storage_take(
9203 &mut self,
9204 item_instance_id: uuid::Uuid,
9205 quantity: Option<u32>,
9206 ) -> anyhow::Result<()> {
9207 let Some(panel) = self.state.storage_panel.clone() else {
9208 return Ok(());
9209 };
9210 self.seq += 1;
9211 self.session
9212 .submit_intent(Intent::StorageTake {
9213 entity_id: self.state.entity_id,
9214 npc_id: panel.npc_id,
9215 item_instance_id,
9216 quantity,
9217 seq: self.seq,
9218 })
9219 .await?;
9220 self.state.intents_sent += 1;
9221 Ok(())
9222 }
9223
9224 pub async fn storage_ship(
9225 &mut self,
9226 dest_building_id: String,
9227 item_instance_id: uuid::Uuid,
9228 quantity: Option<u32>,
9229 ) -> anyhow::Result<()> {
9230 let Some(panel) = self.state.storage_panel.clone() else {
9231 return Ok(());
9232 };
9233 self.seq += 1;
9234 self.session
9235 .submit_intent(Intent::StorageShip {
9236 entity_id: self.state.entity_id,
9237 npc_id: panel.npc_id,
9238 dest_building_id,
9239 item_instance_id,
9240 quantity,
9241 seq: self.seq,
9242 })
9243 .await?;
9244 self.state.intents_sent += 1;
9245 Ok(())
9246 }
9247
9248 pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
9249 let npc_id = self
9250 .state
9251 .storage_panel
9252 .as_ref()
9253 .map(|p| p.npc_id.clone());
9254 self.state.clear_storage_panel();
9255 if let Some(npc_id) = npc_id {
9256 self.seq += 1;
9257 self.session
9258 .submit_intent(Intent::StorageClose {
9259 entity_id: self.state.entity_id,
9260 npc_id,
9261 seq: self.seq,
9262 })
9263 .await?;
9264 self.state.intents_sent += 1;
9265 }
9266 Ok(())
9267 }
9268
9269 pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
9270 let npc_id = self
9271 .state
9272 .market_panel
9273 .as_ref()
9274 .map(|p| p.npc_id.clone());
9275 self.state.clear_market_panel();
9276 if let Some(npc_id) = npc_id {
9277 self.seq += 1;
9278 self.session
9279 .submit_intent(Intent::MarketClose {
9280 entity_id: self.state.entity_id,
9281 npc_id,
9282 seq: self.seq,
9283 })
9284 .await?;
9285 self.state.intents_sent += 1;
9286 }
9287 Ok(())
9288 }
9289
9290 pub fn market_move_selection(&mut self, delta: i32) {
9291 let indices = self.state.market_filtered_listing_indices();
9292 let n = indices.len();
9293 if n == 0 {
9294 self.state.market_menu_index = 0;
9295 return;
9296 }
9297 let cur = self.state.market_menu_index as i32;
9298 self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
9299 }
9300
9301 pub fn market_page_selection(&mut self, pages: i32) {
9302 let indices = self.state.market_filtered_listing_indices();
9303 let n = indices.len();
9304 if n == 0 {
9305 self.state.market_menu_index = 0;
9306 return;
9307 }
9308 self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
9309 }
9310
9311 pub fn market_list_page(&mut self, pages: i32) {
9312 match &self.state.market_ui_mode {
9313 MarketUiMode::ListSource { index } => {
9314 let n = self.state.market_list_source_options().len();
9315 if n == 0 {
9316 return;
9317 }
9318 let next = page_list_index(*index, pages, n);
9319 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
9320 }
9321 MarketUiMode::ListPricingMode { index, .. } => {
9322 let next = page_list_index(*index, pages, 2);
9323 if let MarketUiMode::ListPricingMode { index, .. } =
9324 &mut self.state.market_ui_mode
9325 {
9326 *index = next;
9327 }
9328 }
9329 MarketUiMode::ListPick { source, index } => {
9330 let opts = self.state.market_list_item_options(source);
9331 let n = opts.len();
9332 if n == 0 {
9333 return;
9334 }
9335 let next = page_list_index(*index, pages, n);
9336 self.state.market_ui_mode = MarketUiMode::ListPick {
9337 source: source.clone(),
9338 index: next,
9339 };
9340 }
9341 _ => {}
9342 }
9343 }
9344
9345 pub fn market_cycle_category(&mut self, delta: i32) {
9346 let groups = self.state.market_available_category_groups();
9347 let mut labels: Vec<Option<&'static str>> = vec![None];
9349 labels.extend(groups.into_iter().map(Some));
9350 let n = labels.len() as i32;
9351 let cur = labels
9352 .iter()
9353 .position(|g| *g == self.state.market_category_filter)
9354 .unwrap_or(0) as i32;
9355 let next = (cur + delta).rem_euclid(n) as usize;
9356 self.state.market_category_filter = labels[next];
9357 self.state.market_menu_index = 0;
9358 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9359 let source = source.clone();
9360 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9361 }
9362 }
9363
9364 pub fn focus_market_filter(&mut self) {
9365 self.state.market_filter_focused = true;
9366 }
9367
9368 pub fn append_market_filter_char(&mut self, ch: char) {
9369 if !self.state.market_filter_focused {
9370 return;
9371 }
9372 if ch.is_control() {
9373 return;
9374 }
9375 if self.state.market_filter.len() < 48 {
9376 self.state.market_filter.push(ch);
9377 self.state.market_menu_index = 0;
9378 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9379 let source = source.clone();
9380 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9381 }
9382 }
9383 }
9384
9385 pub fn market_filter_backspace(&mut self) {
9386 if !self.state.market_filter_focused {
9387 return;
9388 }
9389 self.state.market_filter.pop();
9390 self.state.market_menu_index = 0;
9391 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9392 let source = source.clone();
9393 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9394 }
9395 }
9396
9397 pub fn clear_or_blur_market_filter(&mut self) -> bool {
9399 if self.state.market_filter_focused {
9400 if !self.state.market_filter.is_empty() {
9401 self.state.market_filter.clear();
9402 self.state.market_menu_index = 0;
9403 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9404 let source = source.clone();
9405 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9406 }
9407 return true;
9408 }
9409 self.state.market_filter_focused = false;
9410 return true;
9411 }
9412 if !self.state.market_filter.is_empty() {
9413 self.state.market_filter.clear();
9414 self.state.market_menu_index = 0;
9415 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9416 let source = source.clone();
9417 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9418 }
9419 return true;
9420 }
9421 false
9422 }
9423
9424 pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
9425 if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
9426 return self.market_confirm_buy(listing_id, qty).await;
9427 }
9428 let Some(panel) = self.state.market_panel.clone() else {
9429 return Ok(());
9430 };
9431 let indices = self.state.market_filtered_listing_indices();
9432 let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
9433 return Ok(());
9434 };
9435 let Some(listing) = panel.listings.get(raw_idx) else {
9436 return Ok(());
9437 };
9438 if listing.mine {
9439 self.seq += 1;
9440 self.session
9441 .submit_intent(Intent::MarketDelist {
9442 entity_id: self.state.entity_id,
9443 npc_id: panel.npc_id.clone(),
9444 listing_id: listing.listing_id,
9445 dest: flatland_protocol::GoodsLocation::Person,
9446 seq: self.seq,
9447 })
9448 .await?;
9449 self.state.intents_sent += 1;
9450 return Ok(());
9451 }
9452 if listing.npc_price {
9453 self.state
9454 .push_log("NPC-price listings are bought by merchants only.");
9455 return Ok(());
9456 }
9457 let qty = 1u32.min(listing.quantity).max(1);
9458 let line = listing.unit_price_copper.saturating_mul(qty as u64);
9459 self.state.market_buy_confirm = Some((
9460 listing.listing_id,
9461 qty,
9462 listing.unit_price_copper,
9463 line,
9464 listing.display_name.clone(),
9465 ));
9466 Ok(())
9467 }
9468
9469 pub async fn market_confirm_buy(
9470 &mut self,
9471 listing_id: uuid::Uuid,
9472 quantity: u32,
9473 ) -> anyhow::Result<()> {
9474 let Some(panel) = self.state.market_panel.clone() else {
9475 self.state.market_buy_confirm = None;
9476 return Ok(());
9477 };
9478 self.state.market_buy_confirm = None;
9479 self.seq += 1;
9480 self.session
9481 .submit_intent(Intent::MarketBuy {
9482 entity_id: self.state.entity_id,
9483 npc_id: panel.npc_id,
9484 listing_id,
9485 quantity,
9486 dest: flatland_protocol::GoodsLocation::Person,
9487 seq: self.seq,
9488 })
9489 .await?;
9490 self.state.intents_sent += 1;
9491 Ok(())
9492 }
9493
9494 pub fn market_begin_list(&mut self) {
9496 if self.state.market_panel.is_none() {
9497 return;
9498 }
9499 let sources = self.state.market_list_source_options();
9500 if sources.is_empty() {
9501 self.state.push_log("Nothing to list from.");
9502 return;
9503 }
9504 if sources.len() == 1 {
9506 let (source, _) = sources[0].clone();
9507 let opts = self.state.market_list_item_options(&source);
9508 if opts.is_empty() {
9509 self.state.push_log("Nothing loose to list.");
9510 return;
9511 }
9512 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9513 self.state.market_buy_confirm = None;
9514 return;
9515 }
9516 self.state.market_buy_confirm = None;
9517 self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
9518 }
9519
9520 pub fn market_ui_back(&mut self) {
9521 self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
9522 MarketUiMode::Browse => MarketUiMode::Browse,
9523 MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
9524 MarketUiMode::ListPick { .. } => {
9525 if self.state.market_list_source_options().len() <= 1 {
9526 MarketUiMode::Browse
9527 } else {
9528 MarketUiMode::ListSource { index: 0 }
9529 }
9530 }
9531 MarketUiMode::ListAmount {
9532 source,
9533 pick_index,
9534 ..
9535 } => MarketUiMode::ListPick {
9536 source,
9537 index: pick_index,
9538 },
9539 MarketUiMode::ListPricingMode {
9540 source,
9541 item_instance_id,
9542 label,
9543 max_qty,
9544 quantity,
9545 ..
9546 } => {
9547 let input = quantity.map(|q| q.to_string()).unwrap_or_default();
9548 MarketUiMode::ListAmount {
9549 source,
9550 pick_index: 0,
9551 item_instance_id,
9552 label,
9553 max_qty,
9554 input,
9555 }
9556 }
9557 MarketUiMode::ListPrice {
9558 source,
9559 item_instance_id,
9560 label,
9561 max_qty,
9562 quantity,
9563 ..
9564 } => MarketUiMode::ListPricingMode {
9565 source,
9566 item_instance_id,
9567 label,
9568 quantity,
9569 max_qty,
9570 index: 1,
9571 },
9572 };
9573 }
9574
9575 pub fn market_list_move(&mut self, delta: i32) {
9576 match &self.state.market_ui_mode {
9577 MarketUiMode::ListSource { index } => {
9578 let n = self.state.market_list_source_options().len();
9579 if n == 0 {
9580 return;
9581 }
9582 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9583 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
9584 }
9585 MarketUiMode::ListPricingMode { index, .. } => {
9586 let next = (*index as i32 + delta).rem_euclid(2) as usize;
9587 if let MarketUiMode::ListPricingMode { index, .. } =
9588 &mut self.state.market_ui_mode
9589 {
9590 *index = next;
9591 }
9592 }
9593 MarketUiMode::ListPick { source, index } => {
9594 let opts = self.state.market_list_item_options(source);
9595 let n = opts.len();
9596 if n == 0 {
9597 return;
9598 }
9599 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9600 self.state.market_ui_mode = MarketUiMode::ListPick {
9601 source: source.clone(),
9602 index: next,
9603 };
9604 }
9605 _ => {}
9606 }
9607 }
9608
9609 pub fn market_list_amount_append_char(&mut self, c: char) {
9610 if !c.is_ascii_digit() {
9611 return;
9612 }
9613 match &mut self.state.market_ui_mode {
9614 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
9615 if input.len() < 12 {
9616 input.push(c);
9617 }
9618 }
9619 _ => {}
9620 }
9621 }
9622
9623 pub fn market_list_amount_backspace(&mut self) {
9624 match &mut self.state.market_ui_mode {
9625 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
9626 input.pop();
9627 }
9628 _ => {}
9629 }
9630 }
9631
9632 pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
9633 match self.state.market_ui_mode.clone() {
9634 MarketUiMode::Browse => Ok(()),
9635 MarketUiMode::ListSource { index } => {
9636 let sources = self.state.market_list_source_options();
9637 let Some((source, _)) = sources.get(index).cloned() else {
9638 return Ok(());
9639 };
9640 let opts = self.state.market_list_item_options(&source);
9641 if opts.is_empty() {
9642 self.state.push_log("Nothing to list from that source.");
9643 return Ok(());
9644 }
9645 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9646 Ok(())
9647 }
9648 MarketUiMode::ListPick { source, index } => {
9649 let opts = self.state.market_list_item_options(&source);
9650 let Some(opt) = opts.get(index) else {
9651 self.state.push_log("Nothing to list.");
9652 self.state.market_ui_mode = MarketUiMode::Browse;
9653 return Ok(());
9654 };
9655 self.state.market_ui_mode = MarketUiMode::ListAmount {
9656 source,
9657 pick_index: index,
9658 item_instance_id: opt.item_instance_id,
9659 label: opt.label.clone(),
9660 max_qty: opt.quantity.max(1),
9661 input: String::new(),
9662 };
9663 Ok(())
9664 }
9665 MarketUiMode::ListAmount {
9666 source,
9667 item_instance_id,
9668 label,
9669 max_qty,
9670 input,
9671 ..
9672 } => {
9673 let Some(qty_opt) = parse_storage_quantity(&input) else {
9674 self.state.push_log("Enter a quantity (blank = all).");
9675 return Ok(());
9676 };
9677 if let Some(q) = qty_opt {
9678 if q > max_qty {
9679 self.state
9680 .push_log(format!("Only {max_qty} available."));
9681 return Ok(());
9682 }
9683 }
9684 self.state.market_ui_mode = MarketUiMode::ListPricingMode {
9685 source,
9686 item_instance_id,
9687 label,
9688 quantity: qty_opt,
9689 max_qty,
9690 index: 0,
9691 };
9692 Ok(())
9693 }
9694 MarketUiMode::ListPricingMode {
9695 source,
9696 item_instance_id,
9697 label,
9698 quantity,
9699 max_qty,
9700 index,
9701 } => {
9702 if index == 0 {
9703 return self
9704 .submit_market_list_intent(
9705 source,
9706 item_instance_id,
9707 quantity,
9708 0,
9709 true,
9710 &label,
9711 )
9712 .await;
9713 }
9714 self.state.market_ui_mode = MarketUiMode::ListPrice {
9715 source,
9716 item_instance_id,
9717 label,
9718 quantity,
9719 max_qty,
9720 input: String::new(),
9721 };
9722 Ok(())
9723 }
9724 MarketUiMode::ListPrice {
9725 source,
9726 item_instance_id,
9727 label,
9728 quantity,
9729 input,
9730 ..
9731 } => {
9732 let price = input.trim().parse::<u64>().unwrap_or(0);
9733 if price == 0 {
9734 self.state.push_log("Enter a unit price of at least 1 copper.");
9735 return Ok(());
9736 }
9737 self.submit_market_list_intent(
9738 source,
9739 item_instance_id,
9740 quantity,
9741 price,
9742 false,
9743 &label,
9744 )
9745 .await
9746 }
9747 }
9748 }
9749
9750 async fn submit_market_list_intent(
9751 &mut self,
9752 source: MarketListSourceKind,
9753 item_instance_id: uuid::Uuid,
9754 quantity: Option<u32>,
9755 unit_price_copper: u64,
9756 npc_price: bool,
9757 label: &str,
9758 ) -> anyhow::Result<()> {
9759 let Some(panel) = self.state.market_panel.clone() else {
9760 self.state.market_ui_mode = MarketUiMode::Browse;
9761 return Ok(());
9762 };
9763 let goods = match source {
9764 MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
9765 MarketListSourceKind::TownStorage { building_id } => {
9766 flatland_protocol::GoodsLocation::TownStorage { building_id }
9767 }
9768 };
9769 self.seq += 1;
9770 self.session
9771 .submit_intent(Intent::MarketList {
9772 entity_id: self.state.entity_id,
9773 npc_id: panel.npc_id,
9774 source: goods,
9775 item_instance_id,
9776 quantity,
9777 unit_price_copper,
9778 npc_price,
9779 seq: self.seq,
9780 })
9781 .await?;
9782 self.state.intents_sent += 1;
9783 if npc_price {
9784 self.state.push_log(format!("Listing {label} at NPC price…"));
9785 } else {
9786 self.state
9787 .push_log(format!("Listing {label} @ {unit_price_copper} cp…"));
9788 }
9789 self.state.market_ui_mode = MarketUiMode::Browse;
9790 Ok(())
9791 }
9792
9793 pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
9795 let return_to_verbs = self.state.npc_verb_target.is_some();
9796 self.close_shop_menu().await?;
9797 if return_to_verbs {
9798 self.state.show_npc_verb_menu = true;
9799 }
9800 Ok(())
9801 }
9802
9803 pub fn shop_tab_toggle(&mut self) {
9804 self.state.shop_tab = match self.state.shop_tab {
9805 ShopTab::Buy => ShopTab::Sell,
9806 ShopTab::Sell => ShopTab::Buy,
9807 };
9808 self.state.shop_menu_index = 0;
9809 if self.state.shop_tab == ShopTab::Sell {
9810 self.state.shop_quantity_set_max();
9811 }
9812 self.state.clamp_shop_selection();
9813 }
9814
9815 pub fn shop_menu_move(&mut self, delta: i32) {
9816 self.state.shop_menu_move(delta);
9817 }
9818
9819 pub fn shop_quantity_adjust(&mut self, delta: i32) {
9820 self.state.shop_quantity_adjust(delta);
9821 }
9822
9823 pub fn shop_quantity_set_max(&mut self) {
9824 self.state.shop_quantity_set_max();
9825 }
9826
9827 pub fn shop_quantity_set_min(&mut self) {
9828 self.state.shop_quantity_set_min();
9829 }
9830
9831 pub fn toggle_quest_menu(&mut self) {
9832 self.state.show_quest_menu = !self.state.show_quest_menu;
9833 if self.state.show_quest_menu {
9834 self.state.quest_menu_index = 0;
9835 self.state.quest_withdraw_confirm = false;
9836 self.state.show_workers_menu = false;
9837 }
9838 }
9839
9840 pub fn toggle_workers_menu(&mut self) {
9841 if self.state.show_workers_menu {
9842 self.close_workers_menu_ui();
9843 } else {
9844 self.state.show_workers_menu = true;
9845 self.state.workers_menu_index = 0;
9846 self.state.show_quest_menu = false;
9847 self.close_worker_give_picker();
9848 self.close_worker_give_target_picker();
9849 self.close_worker_take_picker();
9850 self.close_worker_teach_picker();
9851 self.cancel_worker_rename();
9852 }
9853 }
9854
9855 pub fn close_workers_menu_ui(&mut self) {
9857 self.state.show_workers_menu = false;
9858 self.close_worker_give_picker();
9859 self.close_worker_give_target_picker();
9860 self.close_worker_take_picker();
9861 self.close_worker_teach_picker();
9862 self.cancel_worker_rename();
9863 }
9864
9865 pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
9867 let Some(idx) = self
9868 .state
9869 .hired_workers
9870 .iter()
9871 .position(|w| w.instance_id == instance_id)
9872 else {
9873 anyhow::bail!("worker not found");
9874 };
9875 let label = self.state.hired_workers[idx].label.clone();
9876 self.state.show_workers_menu = true;
9877 self.state.workers_menu_index = idx;
9878 self.state.show_quest_menu = false;
9879 self.close_worker_give_picker();
9880 self.close_worker_give_target_picker();
9881 self.close_worker_take_picker();
9882 self.close_worker_teach_picker();
9883 self.cancel_worker_rename();
9884 self.set_worker_attending(instance_id, true).await?;
9885 self.state
9886 .push_log(format!("Managing {label} — job paused while menu is open"));
9887 Ok(())
9888 }
9889
9890 pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
9892 self.close_workers_menu_ui();
9893 self.release_worker_attend().await
9894 }
9895
9896 async fn set_worker_attending(
9897 &mut self,
9898 instance_id: &str,
9899 attending: bool,
9900 ) -> anyhow::Result<()> {
9901 if attending {
9902 if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9903 return Ok(());
9904 }
9905 if let Some(prev) = self.state.attending_worker_instance_id.clone() {
9907 if prev != instance_id {
9908 self.send_attend_hired_worker(&prev, false).await?;
9909 }
9910 }
9911 self.send_attend_hired_worker(instance_id, true).await?;
9912 self.state.attending_worker_instance_id = Some(instance_id.to_string());
9913 } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9914 self.send_attend_hired_worker(instance_id, false).await?;
9915 self.state.attending_worker_instance_id = None;
9916 }
9917 Ok(())
9918 }
9919
9920 pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
9921 let Some(id) = self.state.attending_worker_instance_id.take() else {
9922 return Ok(());
9923 };
9924 self.send_attend_hired_worker(&id, false).await
9925 }
9926
9927 async fn send_attend_hired_worker(
9928 &mut self,
9929 worker_instance_id: &str,
9930 attending: bool,
9931 ) -> anyhow::Result<()> {
9932 self.seq += 1;
9933 self.session
9934 .submit_intent(Intent::AttendHiredWorker {
9935 entity_id: self.state.entity_id,
9936 worker_instance_id: worker_instance_id.to_string(),
9937 attending,
9938 seq: self.seq,
9939 })
9940 .await?;
9941 self.state.intents_sent += 1;
9942 Ok(())
9943 }
9944
9945 pub fn workers_menu_move(&mut self, delta: i32) {
9946 let n = self.state.hired_workers.len();
9947 if n == 0 {
9948 return;
9949 }
9950 let idx = self.state.workers_menu_index as i32;
9951 self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9952 }
9953
9954 pub fn toggle_workers_menu_compact(&mut self) {
9955 self.state.workers_menu_compact = !self.state.workers_menu_compact;
9956 let mut cfg = crate::client_config::ClientConfig::load();
9957 let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
9958 }
9959
9960 pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
9961 let Some(worker) = self
9962 .state
9963 .hired_workers
9964 .get(self.state.workers_menu_index)
9965 .cloned()
9966 else {
9967 anyhow::bail!("no worker selected");
9968 };
9969 self.seq += 1;
9970 self.session
9971 .submit_intent(Intent::DismissWorker {
9972 entity_id: self.state.entity_id,
9973 worker_instance_id: worker.instance_id.clone(),
9974 seq: self.seq,
9975 })
9976 .await?;
9977 self.state.intents_sent += 1;
9978 self.state
9979 .hired_workers
9980 .retain(|w| w.instance_id != worker.instance_id);
9981 if self.state.workers_menu_index >= self.state.hired_workers.len() {
9982 self.state.workers_menu_index = self
9983 .state
9984 .hired_workers
9985 .len()
9986 .saturating_sub(1);
9987 }
9988 self.state.push_log(format!("Dismissed {}", worker.label));
9989 Ok(())
9990 }
9991
9992 pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
9993 let Some(worker) = self
9994 .state
9995 .hired_workers
9996 .get(self.state.workers_menu_index)
9997 .cloned()
9998 else {
9999 anyhow::bail!("no worker selected");
10000 };
10001 let mode = match worker.mode {
10002 flatland_protocol::WorkerModeView::Companion => "job_loop",
10003 flatland_protocol::WorkerModeView::JobLoop => "idle",
10004 flatland_protocol::WorkerModeView::Idle => "companion",
10005 };
10006 self.seq += 1;
10007 self.session
10008 .submit_intent(Intent::SetWorkerMode {
10009 entity_id: self.state.entity_id,
10010 worker_instance_id: worker.instance_id,
10011 mode: mode.into(),
10012 seq: self.seq,
10013 })
10014 .await?;
10015 self.state.intents_sent += 1;
10016 Ok(())
10017 }
10018
10019 pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
10020 if self.state.hired_workers.is_empty() {
10021 return self.hire_worker_laborer().await;
10022 }
10023 self.workers_toggle_mode_selected().await
10024 }
10025
10026 pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
10029 let row = self
10030 .state
10031 .inventory_selected_row()
10032 .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
10033 .clone();
10034 if row.from != flatland_protocol::InventoryLocation::Root {
10035 anyhow::bail!("select a carried item to give");
10036 }
10037 let Some(instance_id) = row.stack.item_instance_id else {
10038 anyhow::bail!("that stack can't be given");
10039 };
10040 let options = self.nearby_worker_give_targets();
10041 if options.is_empty() {
10042 anyhow::bail!(
10043 "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
10044 );
10045 }
10046 let item_label = row
10047 .stack
10048 .display_name
10049 .as_deref()
10050 .unwrap_or(&row.stack.template_id)
10051 .to_string();
10052 self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
10053 item_instance_id: instance_id,
10054 item_label,
10055 quantity: None,
10056 options,
10057 });
10058 self.state.worker_give_target_picker_index = 0;
10059 self.state.show_worker_give_target_picker = true;
10060 self.state.show_inventory_menu = false;
10062 Ok(())
10063 }
10064
10065 pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
10067 let (px, py, _) = self.state.player_position_with_z();
10068 let mut options: Vec<WorkerGiveTargetOption> = self
10069 .state
10070 .hired_workers
10071 .iter()
10072 .filter_map(|w| {
10073 let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
10074 if dist > WORKER_GIVE_RANGE_M {
10075 return None;
10076 }
10077 Some(WorkerGiveTargetOption {
10078 instance_id: w.instance_id.clone(),
10079 label: w.label.clone(),
10080 distance_m: dist,
10081 })
10082 })
10083 .collect();
10084 options.sort_by(|a, b| {
10085 a.distance_m
10086 .partial_cmp(&b.distance_m)
10087 .unwrap_or(std::cmp::Ordering::Equal)
10088 });
10089 options
10090 }
10091
10092 pub fn close_worker_give_target_picker(&mut self) {
10093 self.state.show_worker_give_target_picker = false;
10094 self.state.worker_give_target_picker = None;
10095 self.state.worker_give_target_picker_index = 0;
10096 }
10097
10098 pub fn worker_give_target_picker_move(&mut self, delta: i32) {
10099 let Some(picker) = &self.state.worker_give_target_picker else {
10100 return;
10101 };
10102 let n = picker.options.len();
10103 if n == 0 {
10104 return;
10105 }
10106 let idx = self.state.worker_give_target_picker_index as i32;
10107 self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10108 }
10109
10110 pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
10111 let Some(picker) = self.state.worker_give_target_picker.clone() else {
10112 anyhow::bail!("give target picker not open");
10113 };
10114 let Some(opt) = picker
10115 .options
10116 .get(self.state.worker_give_target_picker_index)
10117 .cloned()
10118 else {
10119 anyhow::bail!("no worker selected");
10120 };
10121 let Some(worker) = self
10122 .state
10123 .hired_workers
10124 .iter()
10125 .find(|w| w.instance_id == opt.instance_id)
10126 .cloned()
10127 else {
10128 self.close_worker_give_target_picker();
10129 anyhow::bail!("worker no longer hired");
10130 };
10131 self.give_item_to_worker(
10132 &worker.instance_id,
10133 &worker.label,
10134 worker.x,
10135 worker.y,
10136 picker.item_instance_id,
10137 &picker.item_label,
10138 picker.quantity,
10139 )
10140 .await?;
10141 self.close_worker_give_target_picker();
10142 Ok(())
10143 }
10144
10145 pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
10147 self.open_worker_give_target_picker()
10148 }
10149
10150 pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
10152 let Some(worker) = self
10153 .state
10154 .hired_workers
10155 .get(self.state.workers_menu_index)
10156 .cloned()
10157 else {
10158 anyhow::bail!("select a hired worker first");
10159 };
10160 let (px, py, _) = self.state.player_position_with_z();
10161 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10162 if dist > WORKER_GIVE_RANGE_M {
10163 anyhow::bail!(
10164 "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
10165 worker.label
10166 );
10167 }
10168 let options = self.state.giveable_inventory_options();
10169 if options.is_empty() {
10170 anyhow::bail!("nothing in inventory to give");
10171 }
10172 self.state.worker_give_picker = Some(WorkerGivePicker {
10173 worker_instance_id: worker.instance_id,
10174 worker_label: worker.label,
10175 options,
10176 });
10177 self.state.worker_give_picker_index = 0;
10178 self.state.show_worker_give_picker = true;
10179 Ok(())
10180 }
10181
10182 pub fn close_worker_give_picker(&mut self) {
10183 self.state.show_worker_give_picker = false;
10184 self.state.worker_give_picker = None;
10185 self.state.worker_give_picker_index = 0;
10186 }
10187
10188 pub fn worker_give_picker_move(&mut self, delta: i32) {
10189 let Some(picker) = &self.state.worker_give_picker else {
10190 return;
10191 };
10192 let n = picker.options.len();
10193 if n == 0 {
10194 return;
10195 }
10196 let idx = self.state.worker_give_picker_index as i32;
10197 self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10198 }
10199
10200 pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
10202 let Some(picker) = self.state.worker_give_picker.clone() else {
10203 anyhow::bail!("give picker not open");
10204 };
10205 let Some(opt) = picker.options.get(self.state.worker_give_picker_index).cloned() else {
10206 anyhow::bail!("no item selected");
10207 };
10208 let Some(worker) = self
10209 .state
10210 .hired_workers
10211 .iter()
10212 .find(|w| w.instance_id == picker.worker_instance_id)
10213 .cloned()
10214 else {
10215 self.close_worker_give_picker();
10216 anyhow::bail!("worker no longer hired");
10217 };
10218 self.give_item_to_worker(
10219 &worker.instance_id,
10220 &worker.label,
10221 worker.x,
10222 worker.y,
10223 opt.item_instance_id,
10224 &opt.label,
10225 None,
10226 )
10227 .await?;
10228 let options = self.state.giveable_inventory_options();
10230 if options.is_empty() {
10231 self.close_worker_give_picker();
10232 } else {
10233 self.state.worker_give_picker = Some(WorkerGivePicker {
10234 worker_instance_id: picker.worker_instance_id,
10235 worker_label: picker.worker_label,
10236 options,
10237 });
10238 if self.state.worker_give_picker_index
10239 >= self
10240 .state
10241 .worker_give_picker
10242 .as_ref()
10243 .map(|p| p.options.len())
10244 .unwrap_or(0)
10245 {
10246 self.state.worker_give_picker_index = self
10247 .state
10248 .worker_give_picker
10249 .as_ref()
10250 .map(|p| p.options.len().saturating_sub(1))
10251 .unwrap_or(0);
10252 }
10253 }
10254 Ok(())
10255 }
10256
10257 pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
10259 let Some(worker) = self
10260 .state
10261 .hired_workers
10262 .get(self.state.workers_menu_index)
10263 .cloned()
10264 else {
10265 anyhow::bail!("select a hired worker first");
10266 };
10267 let (px, py, _) = self.state.player_position_with_z();
10268 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10269 if dist > WORKER_GIVE_RANGE_M {
10270 anyhow::bail!(
10271 "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
10272 worker.label
10273 );
10274 }
10275 let options = self.state.teachable_blueprint_options(&worker);
10276 if options.is_empty() {
10277 anyhow::bail!("no recipes you know that {} still needs", worker.label);
10278 }
10279 self.state.worker_teach_picker = Some(WorkerTeachPicker {
10280 worker_instance_id: worker.instance_id,
10281 worker_label: worker.label,
10282 worker_level: worker.level,
10283 options,
10284 });
10285 self.state.worker_teach_picker_index = 0;
10286 self.state.show_worker_teach_picker = true;
10287 Ok(())
10288 }
10289
10290 pub fn close_worker_teach_picker(&mut self) {
10291 self.state.show_worker_teach_picker = false;
10292 self.state.worker_teach_picker = None;
10293 self.state.worker_teach_picker_index = 0;
10294 }
10295
10296 pub fn worker_teach_picker_move(&mut self, delta: i32) {
10297 let Some(picker) = &self.state.worker_teach_picker else {
10298 return;
10299 };
10300 let n = picker.options.len();
10301 if n == 0 {
10302 return;
10303 }
10304 let idx = self.state.worker_teach_picker_index as i32;
10305 self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10306 }
10307
10308 pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
10309 let Some(picker) = self.state.worker_teach_picker.clone() else {
10310 anyhow::bail!("teach picker not open");
10311 };
10312 let Some(opt) = picker.options.get(self.state.worker_teach_picker_index).cloned() else {
10313 anyhow::bail!("nothing selected");
10314 };
10315 if !opt.level_ok {
10316 anyhow::bail!(
10317 "{} needs level {} (is level {})",
10318 picker.worker_label,
10319 opt.min_level,
10320 opt.worker_level
10321 );
10322 }
10323 if !opt.can_afford {
10324 anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
10325 }
10326 let Some(worker) = self
10327 .state
10328 .hired_workers
10329 .iter()
10330 .find(|w| w.instance_id == picker.worker_instance_id)
10331 .cloned()
10332 else {
10333 anyhow::bail!("worker gone");
10334 };
10335 let (px, py, _) = self.state.player_position_with_z();
10336 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10337 if dist > WORKER_GIVE_RANGE_M {
10338 anyhow::bail!("worker {} too far — stand next to them", worker.label);
10339 }
10340 self.seq += 1;
10341 self.session
10342 .submit_intent(Intent::TeachWorkerBlueprint {
10343 entity_id: self.state.entity_id,
10344 worker_instance_id: picker.worker_instance_id.clone(),
10345 blueprint_id: opt.blueprint_id.clone(),
10346 seq: self.seq,
10347 })
10348 .await?;
10349 self.state.intents_sent += 1;
10350 self.state.push_log(format!(
10351 "Teaching {} to {} ({} cp)",
10352 opt.label, picker.worker_label, opt.cost_copper
10353 ));
10354 self.close_worker_teach_picker();
10355 Ok(())
10356 }
10357
10358 async fn give_item_to_worker(
10359 &mut self,
10360 worker_instance_id: &str,
10361 worker_label: &str,
10362 worker_x: f32,
10363 worker_y: f32,
10364 item_instance_id: uuid::Uuid,
10365 item_label: &str,
10366 quantity: Option<u32>,
10367 ) -> anyhow::Result<()> {
10368 let (px, py, _) = self.state.player_position_with_z();
10369 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
10370 if dist > WORKER_GIVE_RANGE_M {
10371 anyhow::bail!("worker {worker_label} too far — stand next to them");
10372 }
10373 self.seq += 1;
10374 self.session
10375 .submit_intent(Intent::GiveWorkerItem {
10376 entity_id: self.state.entity_id,
10377 worker_instance_id: worker_instance_id.to_string(),
10378 item_instance_id,
10379 quantity,
10380 seq: self.seq,
10381 })
10382 .await?;
10383 self.state.intents_sent += 1;
10384 self.state
10385 .push_log(format!("Gave {item_label} to {worker_label}"));
10386 Ok(())
10387 }
10388
10389 pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
10391 let Some(worker) = self
10392 .state
10393 .hired_workers
10394 .get(self.state.workers_menu_index)
10395 .cloned()
10396 else {
10397 anyhow::bail!("select a hired worker first");
10398 };
10399 let (px, py, _) = self.state.player_position_with_z();
10400 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10401 if dist > WORKER_GIVE_RANGE_M {
10402 anyhow::bail!(
10403 "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
10404 worker.label
10405 );
10406 }
10407 let options = Self::worker_inventory_options(&worker);
10408 if options.is_empty() {
10409 anyhow::bail!("{} isn't carrying anything", worker.label);
10410 }
10411 let initial_qty = options
10412 .first()
10413 .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
10414 .unwrap_or(1);
10415 self.state.worker_take_picker = Some(WorkerTakePicker {
10416 worker_instance_id: worker.instance_id,
10417 worker_label: worker.label,
10418 options,
10419 quantity: initial_qty,
10420 });
10421 self.state.worker_take_picker_index = 0;
10422 self.state.show_worker_take_picker = true;
10423 Ok(())
10424 }
10425
10426 fn worker_inventory_options(
10427 worker: &flatland_protocol::HiredWorkerView,
10428 ) -> Vec<WorkerGiveOption> {
10429 worker
10430 .inventory
10431 .iter()
10432 .filter_map(|stack| {
10433 let item_instance_id = stack.item_instance_id?;
10434 let label = stack
10435 .display_name
10436 .clone()
10437 .unwrap_or_else(|| stack.template_id.clone());
10438 let label = if stack.quantity > 1 {
10439 format!("{label} ×{}", stack.quantity)
10440 } else {
10441 label
10442 };
10443 Some(WorkerGiveOption {
10444 item_instance_id,
10445 label,
10446 quantity: stack.quantity,
10447 template_id: stack.template_id.clone(),
10448 })
10449 })
10450 .collect()
10451 }
10452
10453 pub fn close_worker_take_picker(&mut self) {
10454 self.state.show_worker_take_picker = false;
10455 self.state.worker_take_picker = None;
10456 self.state.worker_take_picker_index = 0;
10457 }
10458
10459 pub fn worker_take_picker_move(&mut self, delta: i32) {
10460 let Some(picker) = &self.state.worker_take_picker else {
10461 return;
10462 };
10463 let n = picker.options.len();
10464 if n == 0 {
10465 return;
10466 }
10467 let idx = self.state.worker_take_picker_index as i32;
10468 self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10469 self.clamp_worker_take_quantity();
10470 }
10471
10472 pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
10473 let Some(picker) = &mut self.state.worker_take_picker else {
10474 return;
10475 };
10476 let max = picker
10477 .options
10478 .get(self.state.worker_take_picker_index)
10479 .map(|o| o.quantity.max(1))
10480 .unwrap_or(1);
10481 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
10482 picker.quantity = next as u32;
10483 }
10484
10485 pub fn worker_take_picker_set_quantity_max(&mut self) {
10486 let Some(picker) = &mut self.state.worker_take_picker else {
10487 return;
10488 };
10489 let max = picker
10490 .options
10491 .get(self.state.worker_take_picker_index)
10492 .map(|o| o.quantity.max(1))
10493 .unwrap_or(1);
10494 picker.quantity = max;
10495 }
10496
10497 pub fn worker_take_picker_set_quantity_min(&mut self) {
10498 let Some(picker) = &mut self.state.worker_take_picker else {
10499 return;
10500 };
10501 picker.quantity = 1;
10502 self.clamp_worker_take_quantity();
10503 }
10504
10505 fn clamp_worker_take_quantity(&mut self) {
10506 let Some(picker) = &mut self.state.worker_take_picker else {
10507 return;
10508 };
10509 let max = picker
10510 .options
10511 .get(self.state.worker_take_picker_index)
10512 .map(|o| o.quantity.max(1))
10513 .unwrap_or(1);
10514 if picker.quantity == 0 || picker.quantity > max {
10515 picker.quantity = if max > 1 { 1 } else { max };
10516 }
10517 }
10518
10519 pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
10520 let Some(picker) = self.state.worker_take_picker.clone() else {
10521 anyhow::bail!("take picker not open");
10522 };
10523 let Some(opt) = picker.options.get(self.state.worker_take_picker_index).cloned() else {
10524 anyhow::bail!("no item selected");
10525 };
10526 let Some(worker) = self
10527 .state
10528 .hired_workers
10529 .iter()
10530 .find(|w| w.instance_id == picker.worker_instance_id)
10531 .cloned()
10532 else {
10533 self.close_worker_take_picker();
10534 anyhow::bail!("worker no longer hired");
10535 };
10536 let qty = picker.quantity.clamp(1, opt.quantity.max(1));
10537 let intent_qty = if qty >= opt.quantity {
10538 None
10539 } else {
10540 Some(qty)
10541 };
10542 self.take_item_from_worker(
10543 &worker.instance_id,
10544 &worker.label,
10545 worker.x,
10546 worker.y,
10547 opt.item_instance_id,
10548 &opt.label,
10549 intent_qty,
10550 )
10551 .await?;
10552 Ok(())
10555 }
10556
10557 async fn take_item_from_worker(
10558 &mut self,
10559 worker_instance_id: &str,
10560 worker_label: &str,
10561 worker_x: f32,
10562 worker_y: f32,
10563 item_instance_id: uuid::Uuid,
10564 item_label: &str,
10565 quantity: Option<u32>,
10566 ) -> anyhow::Result<()> {
10567 let (px, py, _) = self.state.player_position_with_z();
10568 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
10569 if dist > WORKER_GIVE_RANGE_M {
10570 anyhow::bail!("worker {worker_label} too far — stand next to them");
10571 }
10572 self.seq += 1;
10573 self.session
10574 .submit_intent(Intent::TakeWorkerItem {
10575 entity_id: self.state.entity_id,
10576 worker_instance_id: worker_instance_id.to_string(),
10577 item_instance_id,
10578 quantity,
10579 seq: self.seq,
10580 })
10581 .await?;
10582 self.state.intents_sent += 1;
10583 let qty_note = quantity
10584 .map(|q| format!(" ×{q}"))
10585 .unwrap_or_default();
10586 self.state
10587 .push_log(format!("Taking {item_label}{qty_note} from {worker_label}…"));
10588 Ok(())
10589 }
10590
10591 pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
10592 if !self.state.has_worker_lodging() {
10593 anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
10594 }
10595 self.seq += 1;
10596 self.session
10597 .submit_intent(Intent::HireWorker {
10598 entity_id: self.state.entity_id,
10599 def_id: "worker_laborer".into(),
10600 wage_copper_per_interval: 8,
10601 lodging_container_id: None,
10602 job_yaml: None,
10603 seq: self.seq,
10604 })
10605 .await?;
10606 self.state.intents_sent += 1;
10607 Ok(())
10608 }
10609
10610 pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
10611 let Some(worker) = self
10612 .state
10613 .hired_workers
10614 .get(self.state.workers_menu_index)
10615 .cloned()
10616 else {
10617 anyhow::bail!("select a hired worker first");
10618 };
10619 let lodging = worker.lodging_container_id.clone().or_else(|| {
10620 crate::worker_route_editor::owned_lodging_container_ids(
10621 &self.state.placed_containers,
10622 self.state.character_id,
10623 )
10624 .into_iter()
10625 .next()
10626 .map(|(id, _)| id)
10627 });
10628 let label = worker.label.clone();
10629 let editor = if let Some(route) = &worker.route {
10630 crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
10631 worker.instance_id,
10632 worker.label,
10633 route,
10634 lodging,
10635 )
10636 } else {
10637 crate::worker_route_editor::WorkerRouteEditorState::new(
10638 worker.instance_id,
10639 worker.label,
10640 lodging,
10641 )
10642 };
10643 self.state.worker_route_editor = Some(editor);
10644 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10645 if let Some(collapsed) =
10646 crate::client_config::ClientConfig::load().worker_route_panel_collapsed
10647 {
10648 ed.panel_collapsed = collapsed;
10649 }
10650 }
10651 self.state.show_workers_menu = false;
10652 self.state.push_log(format!(
10653 "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
10654 ));
10655 Ok(())
10656 }
10657
10658 pub fn close_worker_route_editor(&mut self) {
10659 self.state.worker_route_editor = None;
10660 }
10661
10662 pub fn worker_route_editor_toggle_panel(&mut self) {
10663 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10664 ed.toggle_panel_collapsed();
10665 let collapsed = ed.panel_collapsed;
10666 let mut cfg = crate::client_config::ClientConfig::load();
10667 let _ = cfg.save_worker_route_panel_collapsed(collapsed);
10668 }
10669 }
10670
10671 pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
10672 let n = {
10673 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10674 return;
10675 };
10676 ed.append_waypoint(x, y, z);
10677 ed.stop_count()
10678 };
10679 self.state
10680 .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
10681 }
10682
10683 fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
10686 let (px, py, _) = self.state.player_position_with_z();
10687 let inside = self.state.effective_inside_building();
10688 crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
10689 &self.state.placed_containers,
10690 &self.state.buildings,
10691 self.state.character_id,
10692 px,
10693 py,
10694 &self.state.hired_workers,
10695 inside.as_deref(),
10696 )
10697 }
10698
10699 fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
10700 self.state.route_editor_node_candidates()
10701 }
10702
10703 fn re_open_harvest_picker(
10704 &mut self,
10705 index: usize,
10706 picked: std::collections::BTreeSet<String>,
10707 ) {
10708 use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
10709 let nodes = self.state.route_editor_node_candidates();
10710 let index = if nodes.is_empty() {
10711 ROUTE_PICKER_DONE_ROW
10712 } else {
10713 index.max(1).min(nodes.len())
10714 };
10715 self.re_open_sheet(S::HarvestPicker {
10716 index,
10717 picked,
10718 nodes,
10719 });
10720 }
10721
10722 fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
10723 let (px, py, _) = self.state.player_position_with_z();
10724 crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
10725 }
10726
10727 fn re_template_candidates(&self) -> Vec<String> {
10728 let mut extra = Vec::new();
10729 if let Some(ed) = self.state.worker_route_editor.as_ref() {
10730 for stop in &ed.stops {
10731 match stop {
10732 crate::worker_route_editor::WorkerRouteStop::DepositAt {
10733 filter: Some(filter),
10734 ..
10735 } => extra.extend(filter.iter().cloned()),
10736 crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
10737 extra.push(template.clone());
10738 }
10739 crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
10740 if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint) {
10741 extra.push(bp.output.clone());
10742 for input in &bp.inputs {
10743 extra.push(input.template_id.clone());
10744 }
10745 }
10746 }
10747 crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
10748 for it in items {
10749 extra.push(it.template.clone());
10750 }
10751 }
10752 _ => {}
10753 }
10754 }
10755 if let Some(worker) = self
10757 .state
10758 .hired_workers
10759 .iter()
10760 .find(|w| w.instance_id == ed.worker_instance_id)
10761 {
10762 for recipe in &worker.known_blueprint_ids {
10763 if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
10764 extra.push(bp.output.clone());
10765 }
10766 }
10767 }
10768 }
10769 crate::worker_route_editor::route_item_template_candidates(
10770 &self.state.placed_containers,
10771 self.state.character_id,
10772 &self.state.inventory,
10773 &self.state.blueprints,
10774 &self.state.resource_nodes,
10775 &extra,
10776 )
10777 }
10778
10779 fn re_blueprint_ids(&self) -> Vec<String> {
10780 let worker_known: Option<&[String]> = self
10781 .state
10782 .worker_route_editor
10783 .as_ref()
10784 .and_then(|ed| {
10785 self.state
10786 .hired_workers
10787 .iter()
10788 .find(|w| w.instance_id == ed.worker_instance_id)
10789 })
10790 .map(|w| w.known_blueprint_ids.as_slice());
10791 crate::worker_route_editor::worker_craft_blueprint_ids(
10792 &self.state.blueprints,
10793 worker_known,
10794 )
10795 }
10796
10797 fn re_bed_candidates(&self) -> Vec<(String, String)> {
10798 crate::worker_route_editor::owned_lodging_container_ids(
10799 &self.state.placed_containers,
10800 self.state.character_id,
10801 )
10802 }
10803
10804 fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
10805 self.state
10806 .placed_containers
10807 .iter()
10808 .find(|c| c.id == container_id)
10809 .map(|c| c.contents.clone())
10810 .unwrap_or_default()
10811 }
10812
10813 fn re_sheet_supports_filter(&self) -> bool {
10816 use crate::worker_route_editor::RouteEditorSheet as S;
10817 self.state
10818 .worker_route_editor
10819 .as_ref()
10820 .is_some_and(|ed| {
10821 matches!(
10822 ed.sheet,
10823 S::HarvestPicker { .. }
10824 | S::SellItem { .. }
10825 | S::DepositFilter { .. }
10826 | S::WithdrawItems { .. }
10827 | S::WithdrawContainers { .. }
10828 | S::DepositContainers { .. }
10829 | S::SellNpcs { .. }
10830 | S::CraftBlueprint { .. }
10831 | S::BedPicker { .. }
10832 )
10833 })
10834 }
10835
10836 pub fn re_sheet_row_visible(&self, row: usize) -> bool {
10838 use crate::worker_route_editor::{
10839 harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
10840 ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
10841 };
10842 let Some(ed) = self.state.worker_route_editor.as_ref() else {
10843 return false;
10844 };
10845 let filter = &ed.sheet_filter;
10846 match &ed.sheet {
10847 S::HarvestPicker { nodes, .. } => {
10848 harvest_picker_row_matches(nodes, row, filter)
10849 }
10850 S::SellItem { templates, .. } => {
10851 if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
10852 return true;
10853 }
10854 let slot = row.saturating_sub(2);
10855 templates.get(slot).is_some_and(|t| {
10856 let label = self.state.template_display_name(t);
10857 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10858 })
10859 }
10860 S::DepositFilter { rows, .. } => {
10861 if row >= rows.len() {
10862 return true;
10863 }
10864 rows.get(row).is_some_and(|(t, _)| {
10865 let label = self.state.template_display_name(t);
10866 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10867 })
10868 }
10869 S::WithdrawItems { lines, .. } => {
10870 if row >= lines.len() {
10871 return true;
10872 }
10873 lines.get(row).is_some_and(|l| {
10874 let label = self.state.template_display_name(&l.template);
10875 list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
10876 })
10877 }
10878 S::WithdrawContainers { .. } | S::DepositContainers { .. } => self
10879 .re_container_candidates()
10880 .get(row)
10881 .is_some_and(|c| {
10882 list_filter_row_matches(
10883 filter,
10884 Some(c.dist),
10885 &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
10886 )
10887 }),
10888 S::SellNpcs { .. } => {
10889 if row == 0 {
10890 return true;
10891 }
10892 self.re_npc_candidates().get(row - 1).is_some_and(|n| {
10893 list_filter_row_matches(filter, Some(n.dist), &[n.label.as_str(), n.id.as_str()])
10894 })
10895 }
10896 S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
10897 let label = self
10898 .state
10899 .blueprints
10900 .iter()
10901 .find(|b| &b.id == id)
10902 .map(|b| {
10903 if b.label.is_empty() {
10904 id.as_str()
10905 } else {
10906 b.label.as_str()
10907 }
10908 })
10909 .unwrap_or(id.as_str());
10910 list_filter_row_matches(filter, None, &[id.as_str(), label])
10911 }),
10912 S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
10913 list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
10914 }),
10915 _ => true,
10916 }
10917 }
10918
10919 fn re_sheet_clamp_index(&mut self) {
10920 let count = self.re_sheet_row_count();
10921 if count == 0 {
10922 return;
10923 }
10924 let cur = self.re_sheet_index();
10925 if self.re_sheet_row_visible(cur) {
10926 return;
10927 }
10928 for offset in 1..count {
10929 if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
10930 self.re_sheet_set_index(cur + offset);
10931 return;
10932 }
10933 if cur >= offset && self.re_sheet_row_visible(cur - offset) {
10934 self.re_sheet_set_index(cur - offset);
10935 return;
10936 }
10937 }
10938 }
10939
10940 fn re_sheet_set_index(&mut self, index: usize) {
10941 use crate::worker_route_editor::RouteEditorSheet as S;
10942 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10943 return;
10944 };
10945 match &mut ed.sheet {
10946 S::AddMenu { index: slot }
10947 | S::WaypointMenu { index: slot }
10948 | S::HarvestPicker { index: slot, .. }
10949 | S::WithdrawContainers { index: slot }
10950 | S::DepositContainers { index: slot }
10951 | S::SellNpcs { index: slot }
10952 | S::CraftBlueprint { index: slot }
10953 | S::BedPicker { index: slot }
10954 | S::FarmPlotPicker { index: slot, .. }
10955 | S::FarmPlantSeed { index: slot, .. }
10956 | S::WithdrawItems { index: slot, .. }
10957 | S::DepositFilter { index: slot, .. }
10958 | S::SellItem { index: slot, .. } => *slot = index,
10959 _ => {}
10960 }
10961 }
10962
10963 pub fn re_focus_sheet_filter(&mut self) {
10964 if !self.re_sheet_supports_filter() {
10965 return;
10966 }
10967 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10968 ed.sheet_filter_focused = true;
10969 }
10970 }
10971
10972 pub fn re_blur_sheet_filter_keep_text(&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_focused = false;
10980 self.re_sheet_clamp_index();
10981 }
10982
10983 pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
10984 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10985 return false;
10986 };
10987 if ed.sheet_filter_focused {
10988 ed.sheet_filter_focused = false;
10989 self.re_sheet_clamp_index();
10990 return true;
10991 }
10992 if !ed.sheet_filter.is_empty() {
10993 ed.sheet_filter.clear();
10994 self.re_sheet_clamp_index();
10995 return true;
10996 }
10997 false
10998 }
10999
11000 pub fn re_append_sheet_filter_char(&mut self, ch: char) {
11001 if ch.is_control() {
11002 return;
11003 }
11004 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11005 return;
11006 };
11007 if !ed.sheet_filter_focused {
11008 return;
11009 }
11010 ed.sheet_filter.push(ch);
11011 self.re_sheet_set_index(0);
11012 self.re_sheet_clamp_index();
11013 }
11014
11015 pub fn re_sheet_filter_backspace(&mut self) {
11016 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11017 return;
11018 };
11019 if !ed.sheet_filter_focused {
11020 return;
11021 }
11022 ed.sheet_filter.pop();
11023 self.re_sheet_set_index(0);
11024 self.re_sheet_clamp_index();
11025 }
11026
11027 pub fn re_sheet_row_count(&self) -> usize {
11029 use crate::worker_route_editor::{
11030 harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
11031 };
11032 let Some(ed) = self.state.worker_route_editor.as_ref() else {
11033 return 0;
11034 };
11035 match &ed.sheet {
11036 S::Stops => ed.stops.len(),
11037 S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
11038 S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
11039 S::WaypointMapPick => 0,
11040 S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
11041 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
11042 self.re_container_candidates().len()
11043 }
11044 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()),
11048 S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
11049 S::WaitEntry { .. } => 1,
11050 S::BedPicker { .. } => self.re_bed_candidates().len(),
11051 S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
11052 S::FarmPlantSeed { seeds, .. } => seeds.len(),
11053 }
11054 }
11055
11056 pub fn re_sheet_index(&self) -> usize {
11058 use crate::worker_route_editor::RouteEditorSheet as S;
11059 let Some(ed) = self.state.worker_route_editor.as_ref() else {
11060 return 0;
11061 };
11062 match &ed.sheet {
11063 S::AddMenu { index }
11064 | S::WaypointMenu { index }
11065 | S::HarvestPicker { index, .. }
11066 | S::WithdrawContainers { index }
11067 | S::DepositContainers { index }
11068 | S::SellNpcs { index }
11069 | S::CraftBlueprint { index }
11070 | S::BedPicker { index }
11071 | S::FarmPlotPicker { index, .. }
11072 | S::FarmPlantSeed { index, .. }
11073 | S::WithdrawItems { index, .. }
11074 | S::DepositFilter { index, .. }
11075 | S::SellItem { index, .. } => *index,
11076 _ => 0,
11077 }
11078 }
11079
11080 pub fn re_sheet_move(&mut self, delta: i32) {
11082 let count = self.re_sheet_row_count();
11083 if count == 0 {
11084 return;
11085 }
11086 let cur = self.re_sheet_index();
11087 let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
11088 self.re_sheet_set_index(next);
11089 }
11090
11091 pub fn re_sheet_page(&mut self, pages: i32) {
11092 let count = self.re_sheet_row_count();
11093 if count == 0 {
11094 return;
11095 }
11096 let cur = self.re_sheet_index();
11097 let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
11098 self.re_sheet_set_index(next);
11099 }
11100
11101 pub fn re_sheet_adjust(&mut self, delta: i32) {
11103 use crate::worker_route_editor::RouteEditorSheet as S;
11104 let index = self.re_sheet_index();
11105 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11106 return;
11107 };
11108 match &mut ed.sheet {
11109 S::WithdrawItems { lines, .. } => {
11110 if let Some(line) = lines.get_mut(index) {
11111 line.adjust_qty(delta);
11112 }
11113 }
11114 S::WaitEntry { ticks } => {
11115 *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
11116 }
11117 _ => {}
11118 }
11119 }
11120
11121 pub fn re_sheet_back(&mut self) {
11122 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11123 return;
11124 };
11125 use crate::worker_route_editor::RouteEditorSheet as S;
11126 let was_editing = ed.editing_index.is_some();
11127 let from_top_picker = matches!(
11128 ed.sheet,
11129 S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
11130 );
11131 ed.sheet_back();
11132 if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
11133 self.state
11135 .push_log("Route: left edit sheet — press s to save current stops".to_string());
11136 }
11137 }
11138
11139 pub fn re_at_root_sheet(&self) -> bool {
11141 self.state
11142 .worker_route_editor
11143 .as_ref()
11144 .is_some_and(|ed| matches!(ed.sheet, crate::worker_route_editor::RouteEditorSheet::Stops))
11145 }
11146
11147 pub fn re_open_add_menu(&mut self) {
11148 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11149 ed.open_add_menu();
11150 }
11151 }
11152
11153 pub fn re_open_bed_picker(&mut self) {
11154 let beds = self.re_bed_candidates();
11155 if beds.is_empty() {
11156 self.state
11157 .push_log("Route: place a camp bed first".to_string());
11158 return;
11159 }
11160 let current = self
11161 .state
11162 .worker_route_editor
11163 .as_ref()
11164 .and_then(|ed| ed.lodging_container_id.clone());
11165 let index = current
11166 .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
11167 .unwrap_or(0);
11168 self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
11169 }
11170
11171 fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
11172 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11173 ed.open_sheet(sheet);
11174 }
11175 }
11176
11177 fn re_confirm_stop(
11179 &mut self,
11180 stop: crate::worker_route_editor::WorkerRouteStop,
11181 what: String,
11182 ) {
11183 let appended = self
11184 .state
11185 .worker_route_editor
11186 .as_mut()
11187 .is_some_and(|ed| ed.confirm_stop(stop));
11188 if appended {
11189 self.state.push_log(format!("Route: + {what}"));
11190 } else {
11191 self.state
11192 .push_log(format!("Route: {what} already in route — selected it"));
11193 }
11194 }
11195
11196 fn re_open_withdraw_items(&mut self, container_id: String) {
11197 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
11198 let contents = self.re_container_contents(&container_id);
11199 let existing = self
11203 .state
11204 .worker_route_editor
11205 .as_ref()
11206 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11207 .and_then(|stop| match stop {
11208 WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
11209 _ => None,
11210 })
11211 .unwrap_or_default();
11212 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
11213 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11216 let _ = ed.retarget_withdraw_container(container_id.clone());
11217 }
11218 self.re_open_sheet(S::WithdrawItems {
11219 container_id,
11220 lines,
11221 index: 0,
11222 });
11223 }
11224
11225 fn re_withdraw_items_activate(&mut self, index: usize) {
11226 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
11227 enum Outcome {
11228 Cycled,
11229 Confirmed(String),
11230 Empty,
11231 }
11232 let outcome = {
11233 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11234 return;
11235 };
11236 let S::WithdrawItems {
11237 container_id,
11238 lines,
11239 index: sheet_index,
11240 } = &mut ed.sheet
11241 else {
11242 return;
11243 };
11244 *sheet_index = index;
11245 if index < lines.len() {
11246 lines[index].cycle();
11247 Outcome::Cycled
11248 } else {
11249 let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
11250 if items.is_empty() {
11251 Outcome::Empty
11252 } else {
11253 let stop = WorkerRouteStop::WithdrawFrom {
11254 container_id: container_id.clone(),
11255 items,
11256 };
11257 let summary = stop.summary();
11258 ed.confirm_stop(stop);
11259 Outcome::Confirmed(summary)
11260 }
11261 }
11262 };
11263 match outcome {
11264 Outcome::Cycled => {}
11265 Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
11266 Outcome::Empty => self
11267 .state
11268 .push_log("Route: pick at least one item (Space/Enter toggles All/qty)".to_string()),
11269 }
11270 }
11271
11272 fn re_open_deposit_filter(&mut self, container_id: String) {
11273 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11274 let existing_filter = self
11276 .state
11277 .worker_route_editor
11278 .as_ref()
11279 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11280 .and_then(|stop| match stop {
11281 WorkerRouteStop::DepositAt { filter, .. } => {
11282 Some(filter.clone().unwrap_or_default())
11283 }
11284 _ => None,
11285 });
11286 let mut candidates = self.re_template_candidates();
11287 if let Some(ref chosen) = existing_filter {
11288 for t in chosen {
11289 if !candidates.iter().any(|c| c == t) {
11290 candidates.push(t.clone());
11291 }
11292 }
11293 candidates.sort();
11294 candidates.dedup();
11295 }
11296 let rows: Vec<(String, bool)> = match existing_filter {
11297 Some(chosen) => candidates
11298 .iter()
11299 .map(|t| (t.clone(), chosen.contains(t)))
11300 .collect(),
11301 None => candidates.into_iter().map(|t| (t, false)).collect(),
11302 };
11303 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11304 let _ = ed.retarget_deposit_container(container_id.clone());
11305 }
11306 self.re_open_sheet(S::DepositFilter {
11307 container_id,
11308 rows,
11309 index: 0,
11310 });
11311 }
11312
11313 fn re_deposit_filter_activate(&mut self, index: usize) {
11314 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11315 let mut confirmed: Option<String> = None;
11316 {
11317 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11318 return;
11319 };
11320 let S::DepositFilter {
11321 container_id,
11322 rows,
11323 index: sheet_index,
11324 } = &mut ed.sheet
11325 else {
11326 return;
11327 };
11328 *sheet_index = index;
11329 if index < rows.len() {
11330 rows[index].1 = !rows[index].1;
11331 } else {
11332 let chosen: Vec<String> = rows
11334 .iter()
11335 .filter(|(_, on)| *on)
11336 .map(|(t, _)| t.clone())
11337 .collect();
11338 let filter = if chosen.is_empty() { None } else { Some(chosen) };
11339 let stop = WorkerRouteStop::DepositAt {
11340 container_id: container_id.clone(),
11341 filter,
11342 };
11343 confirmed = Some(stop.summary());
11344 ed.confirm_stop(stop);
11345 }
11346 }
11347 if let Some(what) = confirmed {
11348 self.state.push_log(format!("Route: + {what}"));
11349 }
11350 }
11351
11352 fn re_open_sell_item(&mut self, npc_id: Option<String>) {
11353 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11354 let templates = self.re_template_candidates();
11355 if templates.is_empty() {
11356 self.state.push_log(
11357 "Route: no item templates available — learn a craft recipe or place a harvest node first"
11358 .to_string(),
11359 );
11360 return;
11361 }
11362 let (pre_npc, pre_template, pre_all) = self
11364 .state
11365 .worker_route_editor
11366 .as_ref()
11367 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11368 .and_then(|stop| match stop {
11369 WorkerRouteStop::TradeWith {
11370 npc_id,
11371 template,
11372 sell_all,
11373 } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
11374 _ => None,
11375 })
11376 .unwrap_or((None, None, true));
11377 let npc_id = npc_id.or(pre_npc);
11378 let mut picked = std::collections::BTreeSet::new();
11379 if let Some(t) = pre_template {
11380 picked.insert(t);
11381 }
11382 self.re_open_sheet(S::SellItem {
11383 npc_id,
11384 templates,
11385 index: if picked.is_empty() {
11386 crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
11387 } else {
11388 2
11389 },
11390 sell_all: pre_all,
11391 picked,
11392 });
11393 }
11394
11395 fn re_sell_item_activate(&mut self, index: usize) {
11396 use crate::worker_route_editor::{
11397 RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
11398 };
11399 let mut batch_log: Option<String> = None;
11400 {
11401 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11402 return;
11403 };
11404 let S::SellItem {
11405 npc_id,
11406 templates,
11407 index: sheet_index,
11408 sell_all,
11409 picked,
11410 } = &mut ed.sheet
11411 else {
11412 return;
11413 };
11414 *sheet_index = index;
11415 if index == ROUTE_PICKER_DONE_ROW {
11416 if picked.is_empty() {
11417 batch_log = Some(
11418 "Route: pick at least one item (Space toggles, Done confirms)".into(),
11419 );
11420 } else {
11421 let picks: Vec<String> = picked.iter().cloned().collect();
11422 let npc = npc_id.clone();
11423 let all = *sell_all;
11424 let added = ed.confirm_trade_picks(npc, &picks, all);
11425 batch_log = Some(format!("Route: + {added} sell stop(s)"));
11426 }
11427 } else if index == SELL_ITEM_TOGGLE_ROW {
11428 *sell_all = !*sell_all;
11429 } else if let Some(template) = templates.get(index.saturating_sub(2)) {
11430 if picked.contains(template) {
11431 picked.remove(template);
11432 } else {
11433 picked.insert(template.clone());
11434 }
11435 }
11436 }
11437 if let Some(msg) = batch_log {
11438 self.state.push_log(msg);
11439 }
11440 }
11441
11442 pub fn re_edit_selected_stop(&mut self) {
11444 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11445 let Some(stop) = self
11446 .state
11447 .worker_route_editor
11448 .as_ref()
11449 .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
11450 else {
11451 self.state
11452 .push_log("Route: no stop selected — press a to add one".to_string());
11453 return;
11454 };
11455 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11456 ed.begin_edit_selected();
11457 }
11458 match stop {
11459 WorkerRouteStop::Waypoint { .. } => {
11460 self.re_open_sheet(S::WaypointMenu { index: 0 });
11461 }
11462 WorkerRouteStop::HarvestNode { node_id } => {
11463 let nodes = self.state.route_editor_node_candidates();
11464 if nodes.is_empty() {
11465 self.re_cancel_edit();
11466 self.state
11467 .push_log("Route: no harvestable nodes visible to retarget".to_string());
11468 } else {
11469 let mut picked = std::collections::BTreeSet::new();
11470 picked.insert(node_id.clone());
11471 let index = nodes
11472 .iter()
11473 .position(|n| n.id == node_id)
11474 .map(|i| i + 1)
11475 .unwrap_or(1);
11476 self.re_open_harvest_picker(index, picked);
11477 }
11478 }
11479 WorkerRouteStop::WithdrawFrom { container_id, .. } => {
11480 let containers = self.re_container_candidates();
11483 if containers.is_empty() {
11484 self.re_cancel_edit();
11485 self.state
11486 .push_log("Route: place a storage chest first".to_string());
11487 } else {
11488 let index = containers
11489 .iter()
11490 .position(|c| c.id == container_id)
11491 .unwrap_or(0);
11492 self.re_open_sheet(S::WithdrawContainers { index });
11493 }
11494 }
11495 WorkerRouteStop::DepositAt { container_id, .. } => {
11496 let containers = self.re_container_candidates();
11497 if containers.is_empty() {
11498 self.re_cancel_edit();
11499 self.state
11500 .push_log("Route: place a storage chest first".to_string());
11501 } else {
11502 let index = containers
11503 .iter()
11504 .position(|c| c.id == container_id)
11505 .unwrap_or(0);
11506 self.re_open_sheet(S::DepositContainers { index });
11507 }
11508 }
11509 WorkerRouteStop::TradeWith { npc_id, .. } => {
11510 let npcs = self.re_npc_candidates();
11511 let index = npc_id
11513 .as_ref()
11514 .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
11515 .unwrap_or(0);
11516 self.re_open_sheet(S::SellNpcs { index });
11517 }
11518 WorkerRouteStop::CraftAt { blueprint, .. } => {
11519 let bps = self.re_blueprint_ids();
11520 let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
11521 if bps.is_empty() {
11522 self.re_cancel_edit();
11523 self.state
11524 .push_log("Route: no known blueprints to retarget".to_string());
11525 } else {
11526 self.re_open_sheet(S::CraftBlueprint { index });
11527 }
11528 }
11529 WorkerRouteStop::CultivatePlot { .. } => {
11530 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate);
11531 }
11532 WorkerRouteStop::PlantPlot { .. } => {
11533 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
11534 }
11535 WorkerRouteStop::HarvestPlot { .. } => {
11536 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
11537 }
11538 WorkerRouteStop::RestIfNeeded => {
11539 self.re_cancel_edit();
11540 self.state
11541 .push_log("Route: rest has no settings (change the bed with l)".to_string());
11542 }
11543 WorkerRouteStop::Wait { wait_ticks } => {
11544 self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
11545 }
11546 }
11547 }
11548
11549 fn re_cancel_edit(&mut self) {
11550 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11551 ed.editing_index = None;
11552 }
11553 }
11554
11555 pub fn worker_route_editor_ui_click(
11558 &mut self,
11559 click: crate::worker_route_editor::RouteEditorClick,
11560 ) {
11561 use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
11562 match click {
11563 RouteEditorClick::SelectStop(i) => {
11564 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11565 ed.sheet = S::Stops;
11566 ed.select_stop(i);
11567 }
11568 }
11569 RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
11570 RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
11571 RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
11572 }
11573 }
11574
11575 pub fn re_sheet_row_activate(&mut self, row: usize) {
11577 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11578 let Some(sheet) = self
11579 .state
11580 .worker_route_editor
11581 .as_ref()
11582 .map(|ed| ed.sheet.clone())
11583 else {
11584 return;
11585 };
11586 match sheet {
11587 S::Stops => {
11588 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11589 ed.select_stop(row);
11590 }
11591 }
11592 S::AddMenu { .. } => match row {
11593 0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
11594 1 => {
11595 if self.re_node_candidates().is_empty() {
11596 self.state
11597 .push_log("Route: no harvestable nodes visible in this region".to_string());
11598 } else {
11599 self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
11600 }
11601 }
11602 2 | 3 => {
11603 if self.re_container_candidates().is_empty() {
11604 self.state
11605 .push_log("Route: place a storage chest first".to_string());
11606 } else if row == 2 {
11607 self.re_open_sheet(S::WithdrawContainers { index: 0 });
11608 } else {
11609 self.re_open_sheet(S::DepositContainers { index: 0 });
11610 }
11611 }
11612 4 => {
11613 if self.re_template_candidates().is_empty() {
11614 self.state.push_log(
11615 "Route: no item templates available — learn a craft recipe or place a harvest node first"
11616 .to_string(),
11617 );
11618 } else {
11619 self.re_open_sheet(S::SellNpcs { index: 0 });
11620 }
11621 }
11622 5 => {
11623 if self.re_blueprint_ids().is_empty() {
11624 self.state.push_log(
11625 "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
11626 .to_string(),
11627 );
11628 } else {
11629 self.re_open_sheet(S::CraftBlueprint { index: 0 });
11630 }
11631 }
11632 6 => self.re_confirm_stop(
11633 WorkerRouteStop::RestIfNeeded,
11634 "rest at lodging (if needed)".into(),
11635 ),
11636 7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
11637 8 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate),
11638 9 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant),
11639 10 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
11640 _ => {}
11641 },
11642 S::WaypointMenu { .. } => match row {
11643 0 => {
11644 let (x, y, z) = self.state.player_position_with_z();
11645 let stop = WorkerRouteStop::Waypoint { x, y, z };
11646 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
11647 }
11648 1 => {
11649 self.re_open_sheet(S::WaypointMapPick);
11650 self.state.push_log("Route: click the map to place the waypoint (Esc to finish)".to_string());
11651 }
11652 _ => {}
11653 },
11654 S::HarvestPicker { .. } => {
11655 let mut log: Option<String> = None;
11656 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11657 let S::HarvestPicker {
11658 index: sheet_index,
11659 picked,
11660 nodes,
11661 } = &mut ed.sheet
11662 else {
11663 return;
11664 };
11665 *sheet_index = row;
11666 if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
11667 if picked.is_empty() {
11668 log = Some(
11669 "Route: pick at least one node (Space toggles, Done confirms)"
11670 .into(),
11671 );
11672 } else {
11673 let ids: Vec<String> = picked.iter().cloned().collect();
11674 let added = ed.confirm_harvest_picks(&ids);
11675 log = Some(format!("Route: + {added} harvest stop(s)"));
11676 }
11677 } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
11678 if picked.contains(&n.id) {
11679 picked.remove(&n.id);
11680 } else {
11681 picked.insert(n.id.clone());
11682 }
11683 }
11684 }
11685 if let Some(msg) = log {
11686 self.state.push_log(msg);
11687 }
11688 }
11689 S::WithdrawContainers { .. } => {
11690 let containers = self.re_container_candidates();
11691 if let Some(c) = containers.get(row) {
11692 let id = c.id.clone();
11693 self.re_open_withdraw_items(id);
11694 }
11695 }
11696 S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
11697 S::DepositContainers { .. } => {
11698 let containers = self.re_container_candidates();
11699 if let Some(c) = containers.get(row) {
11700 let id = c.id.clone();
11701 self.re_open_deposit_filter(id);
11702 }
11703 }
11704 S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
11705 S::SellNpcs { .. } => {
11706 let npcs = self.re_npc_candidates();
11707 let npc_id = if row == 0 {
11708 None
11709 } else {
11710 npcs.get(row - 1).map(|n| n.id.clone())
11711 };
11712 if row == 0 || npc_id.is_some() {
11713 self.re_open_sell_item(npc_id);
11714 }
11715 }
11716 S::SellItem { .. } => self.re_sell_item_activate(row),
11717 S::CraftBlueprint { .. } => {
11718 let bps = self.re_blueprint_ids();
11719 if let Some(bp) = bps.get(row) {
11720 let stop = WorkerRouteStop::CraftAt {
11721 device: "hand".into(),
11722 blueprint: bp.clone(),
11723 qty: None,
11724 };
11725 self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
11726 }
11727 }
11728 S::WaitEntry { ticks } => {
11729 let stop = WorkerRouteStop::Wait {
11730 wait_ticks: ticks,
11731 };
11732 self.re_confirm_stop(stop, format!("wait {ticks}t"));
11733 }
11734 S::BedPicker { .. } => {
11735 let beds = self.re_bed_candidates();
11736 if let Some((id, name)) = beds.get(row) {
11737 let (id, name) = (id.clone(), name.clone());
11738 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11739 ed.lodging_container_id = Some(id.clone());
11740 ed.sheet = S::Stops;
11741 }
11742 self.state
11743 .push_log(format!("Route: rest bed set to {name}"));
11744 }
11745 }
11746 S::FarmPlotPicker { action, .. } => {
11747 let plots = self.re_farm_plot_candidates();
11748 let Some(plot) = plots.get(row).cloned() else {
11749 return;
11750 };
11751 match action {
11752 crate::worker_route_editor::FarmPlotAction::Cultivate => {
11753 let label = plot_route_label(&plot);
11754 self.re_confirm_stop(
11755 WorkerRouteStop::CultivatePlot {
11756 plot_id: plot.plot_id,
11757 },
11758 format!("cultivate {label}"),
11759 );
11760 }
11761 crate::worker_route_editor::FarmPlotAction::Harvest => {
11762 let label = plot_route_label(&plot);
11763 self.re_confirm_stop(
11764 WorkerRouteStop::HarvestPlot {
11765 plot_id: plot.plot_id,
11766 },
11767 format!("harvest {label}"),
11768 );
11769 }
11770 crate::worker_route_editor::FarmPlotAction::Plant => {
11771 let seeds = self.re_farm_seed_candidates();
11772 if seeds.is_empty() {
11773 self.state.push_log(
11774 "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
11775 );
11776 return;
11777 }
11778 self.re_open_sheet(S::FarmPlantSeed {
11779 plot_id: plot.plot_id,
11780 seeds,
11781 index: 0,
11782 });
11783 }
11784 }
11785 }
11786 S::FarmPlantSeed { plot_id, seeds, .. } => {
11787 if let Some(seed) = seeds.get(row).cloned() {
11788 self.re_confirm_stop(
11789 WorkerRouteStop::PlantPlot {
11790 plot_id,
11791 seed_template: seed.clone(),
11792 },
11793 format!("plant {seed}"),
11794 );
11795 }
11796 }
11797 S::WaypointMapPick => {}
11798 }
11799 }
11800
11801 fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
11802 use crate::worker_route_editor::RouteEditorSheet as S;
11803 if self.re_farm_plot_candidates().is_empty() {
11804 self.state.push_log(
11805 "Route: no farmable plots visible — claim land or get farm access first",
11806 );
11807 return;
11808 }
11809 self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
11810 }
11811
11812 fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
11813 self.state
11814 .property_plots
11815 .iter()
11816 .filter(|p| p.is_mine || p.may_farm)
11817 .cloned()
11818 .collect()
11819 }
11820
11821 fn re_farm_seed_candidates(&self) -> Vec<String> {
11825 let mut set = std::collections::BTreeSet::new();
11826 let looks_like_seed = |id: &str| {
11827 id.ends_with("_seed") || id == "potato_seed" || id == "carrot_seed"
11828 };
11829 for (id, _, _) in self.state.farm_seed_entries() {
11830 set.insert(id);
11831 }
11832 for c in &self.state.placed_containers {
11833 let mine = match (self.state.character_id, c.owner_character_id) {
11834 (Some(a), Some(b)) => a == b,
11835 _ => false,
11836 };
11837 if !mine {
11838 continue;
11839 }
11840 for s in &c.contents {
11841 if s.quantity > 0
11842 && (s.props.contains_key("seed_for") || looks_like_seed(&s.template_id))
11843 {
11844 set.insert(s.template_id.clone());
11845 }
11846 }
11847 }
11848 if let Some(ed) = self.state.worker_route_editor.as_ref() {
11849 for stop in &ed.stops {
11850 if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } = stop
11851 {
11852 for it in items {
11853 if looks_like_seed(&it.template) {
11854 set.insert(it.template.clone());
11855 }
11856 }
11857 }
11858 if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
11859 seed_template, ..
11860 } = stop
11861 {
11862 if !seed_template.is_empty() {
11863 set.insert(seed_template.clone());
11864 }
11865 }
11866 }
11867 }
11868 for id in self.state.inventory_hints.keys() {
11869 if looks_like_seed(id) {
11870 set.insert(id.clone());
11871 }
11872 }
11873 for id in ["potato_seed", "carrot_seed"] {
11875 set.insert(id.to_string());
11876 }
11877 set.into_iter().collect()
11878 }
11879
11880 pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
11887 use crate::worker_route_editor as wre;
11888 use wre::RouteEditorSheet as S;
11889 if self.state.worker_route_editor.is_none() {
11890 return;
11891 }
11892 let sheet = self
11893 .state
11894 .worker_route_editor
11895 .as_ref()
11896 .map(|ed| ed.sheet.clone())
11897 .unwrap_or(S::Stops);
11898 match sheet {
11899 S::WaypointMapPick => {
11900 let (_, _, z) = self.state.player_position_with_z();
11901 let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
11902 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
11903 let editing = self
11905 .state
11906 .worker_route_editor
11907 .as_ref()
11908 .is_some_and(|ed| ed.editing_index.is_some());
11909 if !editing {
11910 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11911 ed.sheet = S::WaypointMapPick;
11912 }
11913 }
11914 }
11915 S::HarvestPicker { .. } => {
11916 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
11917 let mut log: Option<String> = None;
11918 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11919 let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
11920 return;
11921 };
11922 let selected = if picked.contains(&node.id) {
11923 picked.remove(&node.id);
11924 false
11925 } else {
11926 picked.insert(node.id.clone());
11927 true
11928 };
11929 log = Some(format!(
11930 "Route: {} {}",
11931 if selected { "selected" } else { "deselected" },
11932 resource_node_route_label(node)
11933 ));
11934 }
11935 if let Some(msg) = log {
11936 self.state.push_log(msg);
11937 }
11938 }
11939 }
11940 S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
11941 let inside = self.state.effective_inside_building();
11943 if let Some(cid) = wre::pick_storage_container_at(
11944 &self.state.placed_containers,
11945 self.state.character_id,
11946 x,
11947 y,
11948 inside.as_deref(),
11949 ) {
11950 self.re_open_withdraw_items(cid);
11951 }
11952 }
11953 S::DepositContainers { .. } | S::DepositFilter { .. } => {
11954 let inside = self.state.effective_inside_building();
11955 if let Some(cid) = wre::pick_storage_container_at(
11956 &self.state.placed_containers,
11957 self.state.character_id,
11958 x,
11959 y,
11960 inside.as_deref(),
11961 ) {
11962 self.re_open_deposit_filter(cid);
11963 }
11964 }
11965 S::SellNpcs { .. } => {
11966 if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11967 self.re_open_sell_item(Some(npc_id));
11968 }
11969 }
11970 S::SellItem { .. } => {
11971 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11972 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11973 if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
11974 *slot = Some(npc_id.clone());
11975 }
11976 }
11977 self.state
11978 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
11979 }
11980 }
11981 _ => self.worker_route_editor_quick_add_click(x, y),
11983 }
11984 }
11985
11986 fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
11990 use crate::worker_route_editor as wre;
11991 let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
11992 let dx = ax - bx;
11993 let dy = ay - by;
11994 (dx * dx + dy * dy).sqrt()
11995 };
11996
11997 let selected_stop_kind = self
12000 .state
12001 .worker_route_editor
12002 .as_ref()
12003 .and_then(|ed| ed.stops.get(ed.selected_stop_index))
12004 .map(|s| match s {
12005 wre::WorkerRouteStop::TradeWith { .. } => 1,
12006 wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
12007 _ => 0,
12008 })
12009 .unwrap_or(0);
12010 if selected_stop_kind == 1 {
12011 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
12012 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12013 ed.set_selected_trade_npc(npc_id.clone());
12014 }
12015 self.state
12016 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
12017 return;
12018 }
12019 }
12020 if selected_stop_kind == 2 {
12021 let inside = self.state.effective_inside_building();
12022 if let Some(cid) = wre::pick_storage_container_at(
12023 &self.state.placed_containers,
12024 self.state.character_id,
12025 x,
12026 y,
12027 inside.as_deref(),
12028 ) {
12029 let name = self
12030 .state
12031 .placed_containers
12032 .iter()
12033 .find(|c| c.id == cid)
12034 .map(|c| c.display_name.clone())
12035 .unwrap_or_else(|| "container".into());
12036 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12037 ed.set_selected_withdraw_container(cid.clone());
12038 }
12039 self.state
12040 .push_log(format!("Route: withdraw source → {name}"));
12041 return;
12042 }
12043 }
12044
12045 enum Target {
12048 Bed(String),
12049 Container(String),
12050 Npc(String, String),
12051 Node(String, String),
12052 }
12053 let mut best: Option<(f32, u8, Target)> = None;
12054 let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
12055 let better = match best {
12056 None => true,
12057 Some((bd, brank, _)) => d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank),
12058 };
12059 if better {
12060 *best = Some((d, rank, t));
12061 }
12062 };
12063 let inside = self.state.effective_inside_building();
12064 if let Some(bed_id) = wre::pick_lodging_container_at(
12065 &self.state.placed_containers,
12066 self.state.character_id,
12067 x,
12068 y,
12069 inside.as_deref(),
12070 ) {
12071 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
12072 let already_bed = self
12075 .state
12076 .worker_route_editor
12077 .as_ref()
12078 .is_some_and(|ed| ed.lodging_container_id.as_deref() == Some(bed_id.as_str()));
12079 if already_bed {
12080 consider(dist(x, y, c.x, c.y), 1, Target::Container(bed_id), &mut best);
12081 } else {
12082 consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
12083 }
12084 }
12085 }
12086 if let Some(cid) = wre::pick_storage_container_at(
12087 &self.state.placed_containers,
12088 self.state.character_id,
12089 x,
12090 y,
12091 inside.as_deref(),
12092 ) {
12093 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
12094 consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
12095 }
12096 }
12097 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
12098 if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
12099 consider(
12100 dist(x, y, n.x, n.y),
12101 2,
12102 Target::Npc(npc_id, label),
12103 &mut best,
12104 );
12105 }
12106 }
12107 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
12108 let d = dist(x, y, node.x, node.y);
12109 let label = resource_node_route_label(node);
12110 consider(
12111 d,
12112 3,
12113 Target::Node(node.id.clone(), label),
12114 &mut best,
12115 );
12116 }
12117
12118 match best.map(|(_, _, t)| t) {
12119 Some(Target::Bed(bed_id)) => {
12120 let name = self
12121 .state
12122 .placed_containers
12123 .iter()
12124 .find(|c| c.id == bed_id)
12125 .map(|c| c.display_name.clone())
12126 .unwrap_or_else(|| "camp bed".into());
12127 if let Some(ed) = self.state.worker_route_editor.as_mut() {
12128 ed.lodging_container_id = Some(bed_id.clone());
12129 }
12130 self.state
12131 .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
12132 }
12133 Some(Target::Container(cid)) => {
12134 let name = self
12135 .state
12136 .placed_containers
12137 .iter()
12138 .find(|c| c.id == cid)
12139 .map(|c| c.display_name.clone())
12140 .unwrap_or_else(|| "container".into());
12141 let added = self
12142 .state
12143 .worker_route_editor
12144 .as_mut()
12145 .is_some_and(|ed| ed.append_deposit_at(&cid));
12146 if added {
12147 self.state
12148 .push_log(format!("Route: + deposit at {name} ({cid})"));
12149 } else {
12150 self.state.push_log(format!(
12151 "Route: {name} already in route — selected it (d to remove)"
12152 ));
12153 }
12154 }
12155 Some(Target::Npc(npc_id, label)) => {
12156 let template = self.re_template_candidates().into_iter().next();
12159 let Some(template) = template else {
12160 self.state.push_log("Route: no items in your storage to sell — stock a chest first".to_string());
12161 return;
12162 };
12163 let added = self
12164 .state
12165 .worker_route_editor
12166 .as_mut()
12167 .is_some_and(|ed| ed.append_trade_with(template.clone(), Some(npc_id.clone()), true));
12168 if added {
12169 self.state
12170 .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
12171 } else {
12172 self.state.push_log(format!(
12173 "Route: {label} already sells {template} — selected it (d to remove)"
12174 ));
12175 }
12176 }
12177 Some(Target::Node(id, label)) => {
12178 let added = self
12179 .state
12180 .worker_route_editor
12181 .as_mut()
12182 .is_some_and(|ed| ed.append_harvest_node(&id));
12183 if added {
12184 self.state
12185 .push_log(format!("Route: + harvest node {label}"));
12186 } else {
12187 self.state.push_log(format!(
12188 "Route: {label} already in route — selected it (d to remove)"
12189 ));
12190 }
12191 }
12192 None => {}
12193 }
12194 }
12195
12196 pub fn worker_route_editor_select(&mut self, delta: i32) {
12197 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12198 return;
12199 };
12200 if ed.stops.is_empty() {
12201 return;
12202 }
12203 let n = ed.stops.len() as i32;
12204 let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
12205 ed.selected_stop_index = next;
12206 }
12207
12208 pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
12209 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12210 return;
12211 };
12212 if delta < 0 {
12213 ed.move_selected_up();
12214 } else if delta > 0 {
12215 ed.move_selected_down();
12216 }
12217 }
12218
12219 pub fn worker_route_editor_delete_selected(&mut self) {
12220 let removed = self
12221 .state
12222 .worker_route_editor
12223 .as_mut()
12224 .is_some_and(|ed| {
12225 let before = ed.stop_count();
12226 ed.remove_selected_stop();
12227 ed.stop_count() < before
12228 });
12229 if removed {
12230 self.state.push_log("Route: removed selected stop");
12231 }
12232 }
12233
12234 pub fn worker_route_editor_clear_stops(&mut self) {
12237 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12238 return;
12239 };
12240 if ed.stops.is_empty() {
12241 self.state.push_log("Route: already empty — s saves an idle worker".to_string());
12242 return;
12243 }
12244 ed.stops.clear();
12245 ed.selected_stop_index = 0;
12246 self.state
12247 .push_log("Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string());
12248 }
12249
12250 pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
12251 if self.state.pending_worker_job_ack.is_some() {
12252 anyhow::bail!("route save still pending — wait for server ack");
12253 }
12254 let Some(ed) = self.state.worker_route_editor.clone() else {
12255 anyhow::bail!("route editor not open");
12256 };
12257 let (job_yaml, idle) = if ed.stops.is_empty() {
12260 (ed.build_idle_job_yaml(), true)
12261 } else {
12262 (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
12263 };
12264 let worker_id = ed.worker_instance_id.clone();
12265 let route_view = if idle {
12266 None
12267 } else {
12268 Some(ed.to_route_view())
12269 };
12270 let mode = if idle {
12271 flatland_protocol::WorkerModeView::Idle
12272 } else {
12273 flatland_protocol::WorkerModeView::JobLoop
12274 };
12275 let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
12276 .state
12277 .hired_workers
12278 .iter()
12279 .find(|w| w.instance_id == worker_id)
12280 .map(|w| {
12281 (
12282 w.route.clone(),
12283 w.mode,
12284 w.step_label.clone(),
12285 w.last_error.clone(),
12286 )
12287 })
12288 .unwrap_or((
12289 None,
12290 flatland_protocol::WorkerModeView::Idle,
12291 String::new(),
12292 None,
12293 ));
12294 self.seq += 1;
12295 let seq = self.seq;
12296 self.session
12297 .submit_intent(Intent::SetWorkerJob {
12298 entity_id: self.state.entity_id,
12299 worker_instance_id: worker_id.clone(),
12300 job_yaml,
12301 seq,
12302 })
12303 .await?;
12304 self.state.intents_sent += 1;
12305 if let Some(w) = self
12306 .state
12307 .hired_workers
12308 .iter_mut()
12309 .find(|w| w.instance_id == worker_id)
12310 {
12311 w.route = route_view;
12312 w.mode = mode;
12313 w.last_error = None;
12314 if idle {
12315 w.step_label.clear();
12316 w.route_stop_index = None;
12317 }
12318 }
12319 self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
12320 seq,
12321 worker_instance_id: worker_id,
12322 worker_label: ed.worker_label.clone(),
12323 idle,
12324 stop_count: ed.stops.len(),
12325 prev_route,
12326 prev_mode,
12327 prev_step_label,
12328 prev_last_error,
12329 });
12330 self.state.push_log(format!(
12331 "Route: saving for {}… (waiting for server)",
12332 ed.worker_label
12333 ));
12334 Ok(())
12336 }
12337 pub fn quest_menu_move(&mut self, delta: i32) {
12338 let n = self.state.active_quest_entries().len();
12339 if n == 0 {
12340 return;
12341 }
12342 let idx = self.state.quest_menu_index as i32;
12343 self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
12344 }
12345
12346 pub fn quest_menu_page(&mut self, pages: i32) {
12347 let n = self.state.active_quest_entries().len();
12348 self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
12349 }
12350
12351 pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
12352 let Some(offer) = self.state.pending_quest_offer.clone() else {
12353 anyhow::bail!("no quest offer");
12354 };
12355 self.seq += 1;
12356 let seq = self.seq;
12357 self.session
12358 .submit_intent(Intent::AcceptQuest {
12359 entity_id: self.state.entity_id,
12360 quest_id: offer.quest_id,
12361 seq,
12362 })
12363 .await?;
12364 self.state.intents_sent += 1;
12365 Ok(())
12366 }
12367
12368 pub fn quest_offer_decline(&mut self) {
12369 self.state.show_quest_offer = false;
12370 self.state.pending_quest_offer = None;
12371 if !self.state.show_npc_chat
12372 && !self.state.show_shop_menu
12373 && self.state.npc_verb_target.is_some()
12374 {
12375 self.state.show_npc_verb_menu = true;
12376 }
12377 }
12378
12379 pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
12380 if !self.state.show_quest_menu {
12381 return Ok(());
12382 }
12383 let active: Vec<_> = self
12384 .state
12385 .active_quest_entries()
12386 .into_iter()
12387 .cloned()
12388 .collect();
12389 let Some(entry) = active.get(self.state.quest_menu_index) else {
12390 return Ok(());
12391 };
12392 if self.state.quest_withdraw_confirm {
12393 if !entry.can_withdraw {
12394 anyhow::bail!("quest cannot be withdrawn");
12395 }
12396 self.seq += 1;
12397 let seq = self.seq;
12398 self.session
12399 .submit_intent(Intent::WithdrawQuest {
12400 entity_id: self.state.entity_id,
12401 quest_id: entry.quest_id.clone(),
12402 seq,
12403 })
12404 .await?;
12405 self.state.intents_sent += 1;
12406 self.state.quest_withdraw_confirm = false;
12407 return Ok(());
12408 }
12409 self.seq += 1;
12410 let seq = self.seq;
12411 self.session
12412 .submit_intent(Intent::TrackQuest {
12413 entity_id: self.state.entity_id,
12414 quest_id: entry.quest_id.clone(),
12415 seq,
12416 })
12417 .await?;
12418 self.state.intents_sent += 1;
12419 Ok(())
12420 }
12421
12422 pub fn quest_request_withdraw(&mut self) {
12423 if self.state.show_quest_menu {
12424 self.state.quest_withdraw_confirm = true;
12425 }
12426 }
12427
12428 pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
12429 if !self.state.is_alive() {
12430 anyhow::bail!("you are dead");
12431 }
12432 let Some(catalog) = self.state.shop_catalog.clone() else {
12433 anyhow::bail!("no shop open");
12434 };
12435 self.seq += 1;
12436 let seq = self.seq;
12437 match self.state.shop_tab {
12438 ShopTab::Buy => {
12439 let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
12440 anyhow::bail!("nothing selected");
12441 };
12442 if offer.already_owned {
12443 anyhow::bail!("already owned");
12444 }
12445 self.session
12446 .submit_intent(Intent::ShopBuy {
12447 entity_id: self.state.entity_id,
12448 npc_id: catalog.npc_id.clone(),
12449 offer_id: offer.offer_id.clone(),
12450 quantity: self.state.shop_quantity,
12451 seq,
12452 })
12453 .await?;
12454 }
12455 ShopTab::Sell => {
12456 let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
12457 anyhow::bail!("nothing to sell");
12458 };
12459 if line.quantity == 0 {
12460 anyhow::bail!("you have no {}", line.label);
12461 }
12462 let quantity = self.state.shop_quantity.min(line.quantity).max(1);
12463 self.session
12464 .submit_intent(Intent::ShopSell {
12465 entity_id: self.state.entity_id,
12466 npc_id: catalog.npc_id.clone(),
12467 template_id: line.template_id.clone(),
12468 quantity,
12469 seq,
12470 })
12471 .await?;
12472 }
12473 }
12474 self.state.intents_sent += 1;
12475 Ok(())
12476 }
12477
12478 pub fn craft_menu_move(&mut self, delta: i32) {
12479 let n = self.state.blueprints.len();
12480 if n == 0 {
12481 return;
12482 }
12483 let idx = self.state.craft_menu_index as i32;
12484 let next = (idx + delta).rem_euclid(n as i32);
12485 self.state.craft_menu_index = next as usize;
12486 self.state.clamp_craft_batch_quantity();
12487 }
12488
12489 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
12490 self.state.craft_batch_adjust_quantity(delta);
12491 }
12492
12493 pub fn craft_batch_set_max(&mut self) {
12494 self.state.craft_batch_set_max();
12495 }
12496
12497 pub fn craft_batch_set_min(&mut self) {
12498 self.state.craft_batch_set_min();
12499 }
12500
12501 pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
12502 let Some(blueprint) = self
12503 .state
12504 .blueprints
12505 .get(self.state.craft_menu_index)
12506 .cloned()
12507 else {
12508 anyhow::bail!("no blueprints known");
12509 };
12510 if !self.state.can_craft_blueprint(&blueprint) {
12511 let hint = self
12512 .state
12513 .craft_missing_hint(&blueprint)
12514 .unwrap_or_else(|| "missing materials".into());
12515 anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
12516 }
12517 let count = self.state.craft_batch_quantity;
12518 let max = self.state.max_craft_batches(&blueprint);
12519 if max == 0 {
12520 anyhow::bail!("cannot craft {}", blueprint.label);
12521 }
12522 let batches = count.min(max);
12523 self.craft(&blueprint.id, Some(batches)).await?;
12524 self.state.show_craft_menu = false;
12525 Ok(())
12526 }
12527
12528 pub async fn move_by(
12529 &mut self,
12530 forward: f32,
12531 strafe: f32,
12532 vertical: f32,
12533 sprint: bool,
12534 ) -> anyhow::Result<()> {
12535 if !self.state.is_alive() {
12536 anyhow::bail!("you are dead");
12537 }
12538 if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
12539 self.last_move_forward = forward;
12540 self.last_move_strafe = strafe;
12541 }
12542 self.seq += 1;
12543 self.session
12544 .submit_intent(Intent::Move {
12545 entity_id: self.state.entity_id,
12546 forward,
12547 strafe,
12548 vertical,
12549 sprint,
12550 seq: self.seq,
12551 })
12552 .await?;
12553 self.state.intents_sent += 1;
12554 Ok(())
12555 }
12556
12557 pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
12558 if !self.state.connected {
12559 crate::harvest_trace!("harvest_nearest rejected: not connected");
12560 anyhow::bail!("not connected");
12561 }
12562 if !self.state.is_alive() {
12563 crate::harvest_trace!("harvest_nearest rejected: player dead");
12564 anyhow::bail!("you are dead");
12565 }
12566 if self.state.harvest_in_progress {
12567 if self.state.harvest_state_stale() {
12568 self.state.clear_harvest_state();
12569 } else {
12570 anyhow::bail!("already harvesting");
12571 }
12572 }
12573 let (px, py) = self
12574 .state
12575 .player
12576 .as_ref()
12577 .map(|p| (p.transform.position.x, p.transform.position.y))
12578 .unwrap_or((0.0, 0.0));
12579
12580 let available = self
12581 .state
12582 .resource_nodes
12583 .iter()
12584 .filter(|n| !n.harvest_off)
12585 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
12586 .count();
12587 let node_id = self
12588 .state
12589 .resource_nodes
12590 .iter()
12591 .filter(|n| !n.harvest_off)
12592 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
12593 .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
12594 .min_by(|a, b| {
12595 let da = distance(px, py, a.x, a.y);
12596 let db = distance(px, py, b.x, b.y);
12597 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
12598 })
12599 .map(|n| n.id.clone());
12600
12601 let Some(node_id) = node_id else {
12602 let has_loot = self
12603 .state
12604 .ground_drops
12605 .iter()
12606 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
12607 if has_loot {
12608 return self.pickup_nearest().await;
12609 }
12610 anyhow::bail!(
12611 "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
12612 );
12613 };
12614
12615 self.seq += 1;
12616 let seq = self.seq;
12617 crate::harvest_trace!(
12618 entity_id = self.state.entity_id,
12619 node_id = %node_id,
12620 seq,
12621 px,
12622 py,
12623 available_nodes = available,
12624 "submitting harvest intent"
12625 );
12626 self.session
12627 .submit_intent(Intent::Harvest {
12628 entity_id: self.state.entity_id,
12629 node_id,
12630 seq,
12631 })
12632 .await?;
12633 self.state.intents_sent += 1;
12634 self.state.harvest_in_progress = true;
12635 self.state.harvest_started_at = Some(Instant::now());
12636 self.state.push_log("Harvesting…");
12637 crate::harvest_trace!(
12638 entity_id = self.state.entity_id,
12639 seq,
12640 "harvest intent queued to session"
12641 );
12642 Ok(())
12643 }
12644
12645 pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
12646 if !self.state.is_alive() {
12647 anyhow::bail!("you are dead");
12648 }
12649 let blueprint_id = self
12650 .state
12651 .blueprints
12652 .iter()
12653 .find(|bp| self.state.can_craft_blueprint(bp))
12654 .map(|bp| bp.id.clone())
12655 .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
12656 self.craft(&blueprint_id, None).await
12657 }
12658
12659 pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
12660 if !self.state.is_alive() {
12661 anyhow::bail!("you are dead");
12662 }
12663 self.seq += 1;
12664 self.session
12665 .submit_intent(Intent::Craft {
12666 entity_id: self.state.entity_id,
12667 blueprint_id: blueprint_id.to_string(),
12668 count,
12669 seq: self.seq,
12670 })
12671 .await?;
12672 self.state.intents_sent += 1;
12673 let (label, batches) = self
12674 .state
12675 .blueprints
12676 .iter()
12677 .find(|b| b.id == blueprint_id)
12678 .map(|b| {
12679 let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
12680 (b.label.as_str(), n)
12681 })
12682 .unwrap_or((blueprint_id, count.unwrap_or(1)));
12683 self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
12684 Ok(())
12685 }
12686
12687 pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
12688 if !self.state.is_alive() {
12689 anyhow::bail!("you are dead");
12690 }
12691 let target_id = match self.state.nearest_interact_target() {
12692 Some(id) => id,
12693 None => {
12694 anyhow::bail!("nothing to interact with nearby");
12695 }
12696 };
12697 if self.state.npcs.iter().any(|n| n.id == target_id) {
12698 self.state.show_npc_verb_menu = true;
12699 self.state.npc_verb_target = Some(target_id);
12700 self.state.npc_verb_index = 0;
12701 return Ok(());
12702 }
12703 if self
12704 .state
12705 .hired_workers
12706 .iter()
12707 .any(|w| w.instance_id == target_id)
12708 {
12709 return self.open_workers_menu_for(&target_id).await;
12710 }
12711 if let Ok(peer_id) = target_id.parse::<EntityId>() {
12712 if self
12713 .state
12714 .hired_workers
12715 .iter()
12716 .any(|w| w.entity_id == peer_id)
12717 {
12718 if let Some(w) = self
12719 .state
12720 .hired_workers
12721 .iter()
12722 .find(|w| w.entity_id == peer_id)
12723 {
12724 let id = w.instance_id.clone();
12725 return self.open_workers_menu_for(&id).await;
12726 }
12727 }
12728 if let Some(entity) = self
12729 .state
12730 .entities
12731 .iter()
12732 .find(|e| e.id == peer_id && e.id != self.state.entity_id)
12733 {
12734 self.state
12735 .player_verbs
12736 .open_for(peer_id, &entity.label);
12737 return Ok(());
12738 }
12739 }
12740 self.seq += 1;
12741 self.session
12742 .submit_intent(Intent::Interact {
12743 entity_id: self.state.entity_id,
12744 target_id: target_id.clone(),
12745 seq: self.seq,
12746 })
12747 .await?;
12748 self.state.intents_sent += 1;
12749 Ok(())
12750 }
12751
12752 pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
12754 if !self.state.is_alive() {
12755 anyhow::bail!("you are dead");
12756 }
12757 let (px, py) = self.state.player_position();
12758 let has_loot = self
12759 .state
12760 .ground_drops
12761 .iter()
12762 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
12763 if has_loot {
12764 return self.pickup_nearest().await;
12765 }
12766 if self
12767 .state
12768 .placed_containers
12769 .iter()
12770 .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
12771 {
12772 return self.pickup_nearest_container().await;
12773 }
12774
12775 if let Some(plot) = self.state.my_plot_under_player().cloned() {
12776 const SELL_WINDOW: Duration = Duration::from_millis(1200);
12778 let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
12779 && self
12780 .state
12781 .sell_plot_armed_at
12782 .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
12783 if sell_armed {
12784 return self.confirm_sell_plot_to_crown(plot.plot_id).await;
12785 }
12786 self.state.sell_plot_confirm = None;
12787 self.state.sell_plot_armed_at = None;
12788
12789 let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
12792 self.state.npcs.iter().any(|n| n.id == id)
12793 || self.state.hired_workers.iter().any(|w| w.instance_id == id)
12794 || self.state.doors.iter().any(|d| d.id == id)
12795 || self.state.interactables.iter().any(|i| {
12796 i.id == id
12797 && matches!(
12798 i.kind.as_str(),
12799 "quest_board" | "well" | "exit" | "enter"
12800 )
12801 })
12802 || id.parse::<EntityId>().is_ok_and(|eid| {
12803 self.state
12804 .entities
12805 .iter()
12806 .any(|e| e.id == eid && e.id != self.state.entity_id)
12807 })
12808 });
12809 if !blocking_interact {
12810 match self.harvest_nearest().await {
12812 Ok(()) => return Ok(()),
12813 Err(err) => {
12814 let msg = err.to_string();
12815 if !(msg.contains("no harvestable")
12816 || msg.contains("press p")
12817 || msg.contains("press f")
12818 || msg.contains("nothing"))
12819 {
12820 return Err(err);
12821 }
12822 }
12823 }
12824 return Ok(());
12825 }
12826 }
12827 if self.state.nearest_interact_target().is_some() {
12828 return self.interact_nearest().await;
12829 }
12830 if let Some((label, dist)) = self.state.nearest_quest_board() {
12833 if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
12834 anyhow::bail!(
12835 "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
12836 );
12837 }
12838 }
12839
12840 match self.harvest_nearest().await {
12841 Ok(()) => Ok(()),
12842 Err(err) => {
12843 let msg = err.to_string();
12844 if msg.contains("no harvestable")
12845 || msg.contains("press p")
12846 || msg.contains("press f")
12847 {
12848 anyhow::bail!(
12849 "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
12850 );
12851 }
12852 Err(err)
12853 }
12854 }
12855 }
12856
12857 pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
12859 if !self.state.is_alive() {
12860 anyhow::bail!("you are dead");
12861 }
12862 if self.state.claim_mode.is_some() {
12863 anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
12864 }
12865 let zone = self
12866 .state
12867 .free_property_zone_under_player()
12868 .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
12869 let zone_id = zone.id.clone();
12870 let label = zone
12871 .label
12872 .as_deref()
12873 .filter(|s| !s.trim().is_empty())
12874 .unwrap_or(zone.id.as_str())
12875 .to_string();
12876 self.enter_claim_mode(&zone_id);
12877 self.state
12878 .push_log(format!(
12879 "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
12880 ));
12881 Ok(())
12882 }
12883
12884 pub fn enter_claim_mode(&mut self, zone_id: &str) {
12886 let Some(zone) = self
12887 .state
12888 .property_zones
12889 .iter()
12890 .find(|z| z.id == zone_id)
12891 .cloned()
12892 else {
12893 self.state.push_log("unknown property zone");
12894 return;
12895 };
12896 self.state.sell_plot_confirm = None;
12897 self.state.sell_plot_armed_at = None;
12898 let min_area = self
12899 .state
12900 .property_plot_settings
12901 .as_ref()
12902 .map(|s| s.min_plot_area_m2)
12903 .unwrap_or(4.0)
12904 .max(1.0);
12905 let min_side = min_area.sqrt().ceil().max(1.0) as u32;
12906 let side = 4u32.max(min_side);
12907 let (px, py) = self.state.player_position();
12908 let anchor_x = px.floor();
12909 let anchor_y = py.floor();
12910 self.state.claim_mode = Some(ClaimModeState {
12911 zone_id: zone.id.clone(),
12912 width_m: side,
12913 height_m: side,
12914 anchor_x,
12915 anchor_y,
12916 });
12917 let label = zone
12918 .label
12919 .as_deref()
12920 .filter(|s| !s.trim().is_empty())
12921 .unwrap_or(zone.id.as_str());
12922 self.state.push_log(format!(
12923 "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
12924 ));
12925 }
12926
12927 pub fn cancel_claim_mode(&mut self) {
12928 if self.state.claim_mode.take().is_some() {
12929 self.state.push_log("Claim cancelled");
12930 }
12931 }
12932
12933 pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
12935 if !self.state.is_alive() {
12936 anyhow::bail!("you are dead");
12937 }
12938 if self.state.relocate_mode.is_some() {
12939 anyhow::bail!("already relocating — Enter confirm, Esc cancel");
12940 }
12941 if self.state.claim_mode.is_some() {
12942 anyhow::bail!("finish or cancel claim mode first");
12943 }
12944 let chest = self
12945 .state
12946 .placed_containers
12947 .iter()
12948 .find(|c| c.id == container_id)
12949 .cloned()
12950 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
12951 let (px, py) = self.state.player_position();
12952 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
12953 anyhow::bail!("too far from {}", chest.display_name);
12954 }
12955 if chest.locked && !chest.accessible {
12956 anyhow::bail!(
12957 "need the matching key for {} before moving it",
12958 chest.display_name
12959 );
12960 }
12961 let label = if chest.display_name.trim().is_empty() {
12962 chest.template_id.clone()
12963 } else {
12964 chest.display_name.clone()
12965 };
12966 self.state.relocate_mode = Some(RelocateModeState {
12967 container_id: chest.id.clone(),
12968 label: label.clone(),
12969 cursor_x: chest.x.floor() + 0.5,
12970 cursor_y: chest.y.floor() + 0.5,
12971 });
12972 self.state.push_log(format!(
12973 "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
12974 ));
12975 Ok(())
12976 }
12977
12978 pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
12980 let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
12981 anyhow::bail!("no chest nearby to relocate");
12982 };
12983 if chest.locked && !chest.accessible {
12984 anyhow::bail!(
12985 "need the matching key for {} before moving it",
12986 chest.display_name
12987 );
12988 }
12989 self.begin_relocate_container(&chest.id)
12992 }
12993
12994 pub fn cancel_relocate_mode(&mut self) {
12995 if self.state.relocate_mode.take().is_some() {
12996 self.state.push_log("Relocate cancelled");
12997 }
12998 }
12999
13000 pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
13001 let Some(mode) = self.state.relocate_mode.as_mut() else {
13002 return;
13003 };
13004 let max_x = self.state.world_width_m.max(1.0);
13005 let max_y = self.state.world_height_m.max(1.0);
13006 let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
13007 let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
13008 mode.cursor_x = nx.floor() + 0.5;
13009 mode.cursor_y = ny.floor() + 0.5;
13010 }
13011
13012 pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
13013 let Some(mode) = self.state.relocate_mode.as_mut() else {
13014 return;
13015 };
13016 let max_x = self.state.world_width_m.max(1.0);
13017 let max_y = self.state.world_height_m.max(1.0);
13018 mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
13019 mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
13020 }
13021
13022 pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
13023 if !self.state.is_alive() {
13024 anyhow::bail!("you are dead");
13025 }
13026 let Some(mode) = self.state.relocate_mode.clone() else {
13027 anyhow::bail!("not relocating");
13028 };
13029 let (px, py) = self.state.player_position();
13030 let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
13031 if dist > 8.0 {
13032 anyhow::bail!("destination too far (max 8 m)");
13033 }
13034 self.seq += 1;
13035 self.session
13036 .submit_intent(Intent::MovePlacedContainer {
13037 entity_id: self.state.entity_id,
13038 container_id: mode.container_id.clone(),
13039 x: mode.cursor_x,
13040 y: mode.cursor_y,
13041 seq: self.seq,
13042 })
13043 .await?;
13044 self.state.intents_sent += 1;
13045 self.state.relocate_mode = None;
13046 self.state
13047 .push_log(format!("Moving {}…", mode.label));
13048 Ok(())
13049 }
13050
13051 pub fn claim_set_preset(&mut self, w: u32, h: u32) {
13052 let Some(mode) = self.state.claim_mode.as_mut() else {
13053 return;
13054 };
13055 mode.width_m = w.max(1);
13056 mode.height_m = h.max(1);
13057 }
13058
13059 pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
13060 let Some(mode) = self.state.claim_mode.as_mut() else {
13061 return;
13062 };
13063 let w = (mode.width_m as i32 + dw).max(1) as u32;
13064 let h = (mode.height_m as i32 + dh).max(1) as u32;
13065 mode.width_m = w;
13066 mode.height_m = h;
13067 }
13068
13069 pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
13071 let Some(mode) = self.state.claim_mode.as_mut() else {
13072 return;
13073 };
13074 let max_x = self.state.world_width_m.max(1.0);
13075 let max_y = self.state.world_height_m.max(1.0);
13076 let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
13077 let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
13078 mode.anchor_x = nx.floor();
13079 mode.anchor_y = ny.floor();
13080 }
13081
13082 pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
13083 if !self.state.is_alive() {
13084 anyhow::bail!("you are dead");
13085 }
13086 let Some(mode) = self.state.claim_mode.clone() else {
13087 anyhow::bail!("not in claim mode");
13088 };
13089 let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
13090 self.state.claim_quote()
13091 else {
13092 anyhow::bail!("cannot quote claim");
13093 };
13094 if !valid {
13095 anyhow::bail!(reason);
13096 }
13097 if !can_afford {
13098 anyhow::bail!(
13099 "not enough copper (need {})",
13100 crate::currency::format_copper(purchase)
13101 );
13102 }
13103 let (x0, y0, x1, y1) = self
13104 .state
13105 .claim_footprint_rect()
13106 .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
13107 let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
13108 self.seq += 1;
13109 self.session
13110 .submit_intent(Intent::BuyPlot {
13111 entity_id: self.state.entity_id,
13112 zone_id: mode.zone_id,
13113 x0,
13114 y0,
13115 x1,
13116 y1,
13117 seq: self.seq,
13118 })
13119 .await?;
13120 self.state.intents_sent += 1;
13121 self.state.claim_mode = None;
13122 self.state
13123 .push_log(format!("Buying plot for {}", crate::currency::format_copper(purchase)));
13124 Ok(())
13125 }
13126
13127 pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
13128 if !self.state.is_alive() {
13129 anyhow::bail!("you are dead");
13130 }
13131 let zone_id = self
13132 .state
13133 .claim_mode
13134 .as_ref()
13135 .map(|m| m.zone_id.clone())
13136 .or_else(|| {
13137 self.state
13138 .free_property_zone_under_player()
13139 .map(|z| z.id.clone())
13140 })
13141 .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
13142 self.seq += 1;
13143 self.session
13144 .submit_intent(Intent::BuyPlotAllFree {
13145 entity_id: self.state.entity_id,
13146 zone_id,
13147 seq: self.seq,
13148 })
13149 .await?;
13150 self.state.intents_sent += 1;
13151 self.state.claim_mode = None;
13152 self.state.push_log("Claiming largest free plot…");
13153 Ok(())
13154 }
13155
13156 pub async fn confirm_sell_plot_to_crown(
13157 &mut self,
13158 plot_id: uuid::Uuid,
13159 ) -> anyhow::Result<()> {
13160 if !self.state.is_alive() {
13161 anyhow::bail!("you are dead");
13162 }
13163 self.seq += 1;
13164 self.session
13165 .submit_intent(Intent::SellPlotToCrown {
13166 entity_id: self.state.entity_id,
13167 plot_id,
13168 seq: self.seq,
13169 })
13170 .await?;
13171 self.state.intents_sent += 1;
13172 self.state.sell_plot_confirm = None;
13173 self.state.sell_plot_armed_at = None;
13174 self.state.push_log("Selling plot to the crown…");
13175 Ok(())
13176 }
13177
13178 pub async fn set_plot_farm_public(
13179 &mut self,
13180 plot_id: uuid::Uuid,
13181 public: bool,
13182 public_tax_discount_bps: u32,
13183 ) -> anyhow::Result<()> {
13184 self.seq += 1;
13185 self.session
13186 .submit_intent(Intent::SetPlotFarmPublic {
13187 entity_id: self.state.entity_id,
13188 plot_id,
13189 public,
13190 public_tax_discount_bps,
13191 seq: self.seq,
13192 })
13193 .await?;
13194 self.state.intents_sent += 1;
13195 Ok(())
13196 }
13197
13198 pub async fn plot_farm_allow_upsert(
13199 &mut self,
13200 plot_id: uuid::Uuid,
13201 character_id: Option<uuid::Uuid>,
13202 character_name: String,
13203 tax_discount_bps: u32,
13204 ) -> anyhow::Result<()> {
13205 self.seq += 1;
13206 self.session
13207 .submit_intent(Intent::PlotFarmAllowUpsert {
13208 entity_id: self.state.entity_id,
13209 plot_id,
13210 character_id,
13211 character_name,
13212 tax_discount_bps,
13213 seq: self.seq,
13214 })
13215 .await?;
13216 self.state.intents_sent += 1;
13217 Ok(())
13218 }
13219
13220 pub async fn plot_farm_allow_remove(
13221 &mut self,
13222 plot_id: uuid::Uuid,
13223 character_id: uuid::Uuid,
13224 ) -> anyhow::Result<()> {
13225 self.seq += 1;
13226 self.session
13227 .submit_intent(Intent::PlotFarmAllowRemove {
13228 entity_id: self.state.entity_id,
13229 plot_id,
13230 character_id,
13231 seq: self.seq,
13232 })
13233 .await?;
13234 self.state.intents_sent += 1;
13235 Ok(())
13236 }
13237
13238 pub fn open_farm_access_panel(&mut self) {
13239 let Some(plot) = self.state.my_plot_under_player() else {
13240 self.state
13241 .push_log("Stand on your deed plot to manage farm access");
13242 return;
13243 };
13244 self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
13245 self.state.farm_access_index = 0;
13246 self.state.show_farm_access = true;
13247 }
13248
13249 pub fn close_farm_access_panel(&mut self) {
13250 self.state.show_farm_access = false;
13251 self.state.farm_access_name_draft.clear();
13252 self.state.farm_access_index = 0;
13253 }
13254
13255 pub fn farm_access_move(&mut self, delta: i32) {
13256 let n = self.farm_access_row_count().max(1);
13257 let idx = self.state.farm_access_index as i32 + delta;
13258 self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
13259 }
13260
13261 pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
13262 let Some(plot) = self.state.my_plot_under_player() else {
13263 return vec![FarmAccessRow::PublicToggle];
13264 };
13265 let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
13266 for g in &plot.farm_allow {
13267 rows.push(FarmAccessRow::AllowRemove {
13268 character_id: g.character_id,
13269 label: if g.character_label.trim().is_empty() {
13270 g.character_id.to_string()[..8].to_string()
13271 } else {
13272 g.character_label.clone()
13273 },
13274 tax_discount_bps: g.tax_discount_bps,
13275 });
13276 }
13277 for e in &self.state.entities {
13278 if e.id == self.state.entity_id || e.label.trim().is_empty() {
13279 continue;
13280 }
13281 if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
13282 continue;
13283 }
13284 if self
13285 .state
13286 .npcs
13287 .iter()
13288 .any(|n| n.id == e.label || n.label == e.label)
13289 {
13290 continue;
13291 }
13292 if plot
13293 .farm_allow
13294 .iter()
13295 .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
13296 {
13297 continue;
13298 }
13299 rows.push(FarmAccessRow::NearbyAdd {
13300 name: e.label.clone(),
13301 });
13302 }
13303 rows
13304 }
13305
13306 pub fn farm_access_row_count(&self) -> usize {
13307 self.farm_access_rows().len().max(1)
13308 }
13309
13310 pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
13311 let Some(plot) = self.state.my_plot_under_player().cloned() else {
13312 self.close_farm_access_panel();
13313 return Ok(());
13314 };
13315 let rows = self.farm_access_rows();
13316 let Some(row) = rows.get(self.state.farm_access_index) else {
13317 return Ok(());
13318 };
13319 match row {
13320 FarmAccessRow::PublicToggle => {
13321 self.set_plot_farm_public(
13322 plot.plot_id,
13323 !plot.farm_public,
13324 plot.public_tax_discount_bps,
13325 )
13326 .await
13327 }
13328 FarmAccessRow::PublicDiscount => Ok(()),
13329 FarmAccessRow::AllowRemove { character_id, .. } => {
13330 self.plot_farm_allow_remove(plot.plot_id, *character_id)
13331 .await
13332 }
13333 FarmAccessRow::NearbyAdd { name } => {
13334 let disc = self
13335 .state
13336 .farm_access_discount_bps
13337 .max(plot.public_tax_discount_bps);
13338 self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
13339 .await
13340 }
13341 }
13342 }
13343
13344 pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
13345 let Some(plot) = self.state.my_plot_under_player().cloned() else {
13346 return Ok(());
13347 };
13348 let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
13349 self.state.farm_access_discount_bps = next;
13350 self.state.farm_access_index = 1;
13351 self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
13352 .await
13353 }
13354
13355 pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
13357 if self.state.farmable_plot_under_player().is_none() {
13358 anyhow::bail!("stand on a farmable plot to cultivate");
13359 }
13360 let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
13361 let (px, py) = self.state.player_position();
13362 if self
13363 .state
13364 .terrain_at(px, py)
13365 .is_some_and(|k| k == TerrainKindView::Tilled)
13366 {
13367 anyhow::bail!("already tilled — stand on bare soil and press c");
13368 }
13369 anyhow::bail!("cannot till this cell — move onto soil on your plot");
13370 };
13371 self.cultivate_at(tx, ty).await
13372 }
13373
13374 pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
13376 if self.state.farmable_plot_under_player().is_none() {
13377 anyhow::bail!("stand on a farmable plot to plant");
13378 }
13379 if !self.state.underfoot_free_tilled_plant_slot() {
13380 anyhow::bail!("stand on empty tilled soil and press p");
13381 }
13382 let seeds = self.state.farm_seed_entries();
13383 if seeds.is_empty() {
13384 anyhow::bail!("no seeds in inventory — buy seeds from Eli");
13385 }
13386 if seeds.len() == 1 {
13387 return self.plant_seeds(seeds[0].0.clone(), 1).await;
13388 }
13389 self.open_plant_menu();
13390 Ok(())
13391 }
13392
13393 pub fn open_plot_build_menu(&mut self) -> anyhow::Result<()> {
13395 let Some(plot) = self.state.my_plot_under_player() else {
13396 anyhow::bail!("stand on your plot to build");
13397 };
13398 if plot.building_id.is_some() {
13399 anyhow::bail!("this plot already has a building");
13400 }
13401 let building_now = self
13402 .state
13403 .timed_channel
13404 .as_ref()
13405 .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
13406 if !building_now && self.state.building_materials.is_empty() {
13407 anyhow::bail!("no building materials loaded — wait a moment and try again");
13408 }
13409 self.state.show_plot_build_menu = true;
13410 self.state.show_craft_menu = false;
13411 self.state.show_shop_menu = false;
13412 self.state.shop_catalog = None;
13413 self.state.show_stats = false;
13414 self.state.show_inventory_menu = false;
13415 self.state.plot_build_focus_wall = true;
13416 let walls = self.state.plot_build_wall_options().len();
13417 let roofs = self.state.plot_build_roof_options().len();
13418 if walls > 0 {
13419 self.state.plot_build_wall_index = self.state.plot_build_wall_index.min(walls - 1);
13420 } else {
13421 self.state.plot_build_wall_index = 0;
13422 }
13423 if roofs > 0 {
13424 self.state.plot_build_roof_index = self.state.plot_build_roof_index.min(roofs - 1);
13425 } else {
13426 self.state.plot_build_roof_index = 0;
13427 }
13428 Ok(())
13429 }
13430
13431 pub fn close_plot_build_menu(&mut self) {
13432 self.state.show_plot_build_menu = false;
13433 }
13434
13435 pub fn plot_build_menu_move(&mut self, delta: i32) {
13436 let walls = self.state.plot_build_wall_options();
13437 let roofs = self.state.plot_build_roof_options();
13438 if self.state.plot_build_focus_wall {
13439 if walls.is_empty() {
13440 return;
13441 }
13442 let n = walls.len() as i32;
13443 let cur = self.state.plot_build_wall_index as i32;
13444 self.state.plot_build_wall_index = ((cur + delta).rem_euclid(n)) as usize;
13445 } else {
13446 if roofs.is_empty() {
13447 return;
13448 }
13449 let n = roofs.len() as i32;
13450 let cur = self.state.plot_build_roof_index as i32;
13451 self.state.plot_build_roof_index = ((cur + delta).rem_euclid(n)) as usize;
13452 }
13453 }
13454
13455 pub fn plot_build_menu_toggle_focus(&mut self) {
13456 self.state.plot_build_focus_wall = !self.state.plot_build_focus_wall;
13457 }
13458
13459 pub async fn plot_build_menu_confirm(&mut self) -> anyhow::Result<()> {
13461 let wall = self
13462 .state
13463 .plot_build_selected_wall()
13464 .ok_or_else(|| anyhow::anyhow!("pick a wall material"))?
13465 .id
13466 .clone();
13467 let roof = self
13468 .state
13469 .plot_build_selected_roof()
13470 .ok_or_else(|| anyhow::anyhow!("pick a roof material"))?
13471 .id
13472 .clone();
13473 self.start_plot_build(&wall, &roof).await
13475 }
13476
13477 pub async fn plot_build_menu_cancel_build(&mut self) -> anyhow::Result<()> {
13479 self.seq += 1;
13480 self.session
13481 .submit_intent(Intent::CancelPlotBuild {
13482 entity_id: self.state.entity_id,
13483 seq: self.seq,
13484 })
13485 .await?;
13486 self.state.intents_sent += 1;
13487 Ok(())
13488 }
13489
13490 pub async fn start_plot_build(
13492 &mut self,
13493 wall_material_id: &str,
13494 roof_material_id: &str,
13495 ) -> anyhow::Result<()> {
13496 let Some(plot) = self.state.my_plot_under_player() else {
13497 anyhow::bail!("stand on your plot to build");
13498 };
13499 if plot.building_id.is_some() {
13500 anyhow::bail!("this plot already has a building");
13501 }
13502 let plot_id = plot.plot_id;
13503 self.seq += 1;
13504 self.session
13505 .submit_intent(Intent::StartPlotBuild {
13506 entity_id: self.state.entity_id,
13507 plot_id,
13508 wall_material_id: wall_material_id.to_string(),
13509 roof_material_id: roof_material_id.to_string(),
13510 seq: self.seq,
13511 })
13512 .await?;
13513 self.state.intents_sent += 1;
13514 Ok(())
13515 }
13516
13517 pub async fn toggle_nearby_door_lock(&mut self) -> anyhow::Result<()> {
13519 let (px, py) = self.state.player_position();
13520 let mut best: Option<(f32, String, bool)> = None;
13521 for d in &self.state.doors {
13522 if d.lock_id.is_none() {
13523 continue;
13524 }
13525 let dist = (d.x - px).hypot(d.y - py);
13526 if dist > 3.5 {
13527 continue;
13528 }
13529 if best.as_ref().is_none_or(|(bd, _, _)| dist < *bd) {
13530 best = Some((dist, d.id.clone(), d.locked));
13531 }
13532 }
13533 let Some((_, door_id, locked_now)) = best else {
13534 anyhow::bail!("no lockable door nearby");
13535 };
13536 let locked = !locked_now;
13537 self.seq += 1;
13538 self.session
13539 .submit_intent(Intent::SetDoorLocked {
13540 entity_id: self.state.entity_id,
13541 door_id,
13542 locked,
13543 seq: self.seq,
13544 })
13545 .await?;
13546 self.state.intents_sent += 1;
13547 Ok(())
13548 }
13549
13550 pub async fn enter_nearby_open_door(&mut self) -> anyhow::Result<()> {
13552 if !self.state.is_alive() {
13553 anyhow::bail!("you are dead");
13554 }
13555 if self.state.effective_inside_building().is_some() {
13556 anyhow::bail!("already inside");
13557 }
13558 let (px, py) = self.state.player_position();
13559 let mut best: Option<(f32, String)> = None;
13560 for d in &self.state.doors {
13561 if !d.open || d.locked {
13562 continue;
13563 }
13564 let player_house = self
13565 .state
13566 .buildings
13567 .iter()
13568 .find(|b| b.id == d.building_id)
13569 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
13570 if !player_house {
13571 continue;
13572 }
13573 let dist = (d.x - px).hypot(d.y - py);
13574 if dist > 3.5 {
13575 continue;
13576 }
13577 if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
13578 best = Some((dist, d.id.clone()));
13579 }
13580 }
13581 let Some((_, door_id)) = best else {
13582 anyhow::bail!("no open house door nearby — open with f first");
13583 };
13584 self.seq += 1;
13585 self.session
13586 .submit_intent(Intent::EnterBuildingDoor {
13587 entity_id: self.state.entity_id,
13588 door_id,
13589 seq: self.seq,
13590 })
13591 .await?;
13592 self.state.intents_sent += 1;
13593 Ok(())
13594 }
13595
13596 pub async fn exit_nearby_building_door(&mut self) -> anyhow::Result<()> {
13599 if !self.state.is_alive() {
13600 anyhow::bail!("you are dead");
13601 }
13602 let Some(bid) = self.state.effective_inside_building() else {
13603 anyhow::bail!("not inside a building");
13604 };
13605 let (px, py) = self.state.player_position();
13606 let mut best: Option<(f32, String)> = None;
13607 for d in &self.state.doors {
13608 if d.building_id != bid || d.portal.is_none() {
13609 continue;
13610 }
13611 let player_house = self
13612 .state
13613 .buildings
13614 .iter()
13615 .find(|b| b.id == d.building_id)
13616 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
13617 if !player_house {
13618 continue;
13619 }
13620 let dist = (d.x - px).hypot(d.y - py);
13621 if dist > 1.5 {
13622 continue;
13623 }
13624 if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
13625 best = Some((dist, d.id.clone()));
13626 }
13627 }
13628 let Some((_, door_id)) = best else {
13629 anyhow::bail!("stand by the door to exit");
13630 };
13631 self.seq += 1;
13632 self.session
13633 .submit_intent(Intent::ExitBuildingDoor {
13634 entity_id: self.state.entity_id,
13635 door_id,
13636 seq: self.seq,
13637 })
13638 .await?;
13639 self.state.intents_sent += 1;
13640 Ok(())
13641 }
13642
13643 pub async fn confirm_interior_edit(
13645 &mut self,
13646 building_id: String,
13647 rooms: Vec<flatland_protocol::InteriorRoomEdit>,
13648 room_doors: Vec<flatland_protocol::InteriorRoomDoorEdit>,
13649 ) -> anyhow::Result<()> {
13650 self.seq += 1;
13651 self.session
13652 .submit_intent(Intent::ConfirmInteriorEdit {
13653 entity_id: self.state.entity_id,
13654 building_id,
13655 rooms,
13656 room_doors,
13657 seq: self.seq,
13658 })
13659 .await?;
13660 self.state.intents_sent += 1;
13661 Ok(())
13662 }
13663
13664 pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
13665 if !self.state.is_alive() {
13666 anyhow::bail!("you are dead");
13667 }
13668 self.seq += 1;
13669 self.session
13670 .submit_intent(Intent::Cultivate {
13671 entity_id: self.state.entity_id,
13672 x,
13673 y,
13674 seq: self.seq,
13675 })
13676 .await?;
13677 self.state.intents_sent += 1;
13678 Ok(())
13679 }
13680
13681 pub async fn plant_seeds(
13682 &mut self,
13683 seed_template_id: String,
13684 quantity: u32,
13685 ) -> anyhow::Result<()> {
13686 if !self.state.is_alive() {
13687 anyhow::bail!("you are dead");
13688 }
13689 self.seq += 1;
13690 self.session
13691 .submit_intent(Intent::PlantSeeds {
13692 entity_id: self.state.entity_id,
13693 seed_template_id: seed_template_id.clone(),
13694 quantity,
13695 seq: self.seq,
13696 })
13697 .await?;
13698 self.state.intents_sent += 1;
13699 self.state
13700 .push_log(format!("Planting {quantity}× {seed_template_id}…"));
13701 Ok(())
13702 }
13703
13704 pub fn open_plant_menu(&mut self) {
13705 if self.state.farm_seed_entries().is_empty() {
13706 self.state.push_log("No seeds in inventory to plant");
13707 return;
13708 }
13709 self.state.show_plant_menu = true;
13710 self.state.plant_menu_index = 0;
13711 self.state.plant_quantity = 1;
13712 self.state.clamp_plant_menu();
13713 }
13714
13715 pub fn close_plant_menu(&mut self) {
13716 self.state.show_plant_menu = false;
13717 }
13718
13719 pub fn plant_menu_move(&mut self, delta: i32) {
13720 let n = self.state.farm_seed_entries().len();
13721 if n == 0 {
13722 return;
13723 }
13724 let idx = self.state.plant_menu_index as i32 + delta;
13725 self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
13726 self.state.clamp_plant_menu();
13727 }
13728
13729 pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
13730 let next = self.state.plant_quantity as i32 + delta;
13731 self.state.plant_quantity = next.max(1) as u32;
13732 self.state.clamp_plant_menu();
13733 }
13734
13735 pub fn plant_menu_set_quantity_max(&mut self) {
13736 if let Some((_, max, _)) = self.state.plant_menu_selection() {
13737 self.state.plant_quantity = max;
13738 }
13739 self.state.clamp_plant_menu();
13740 }
13741
13742 pub fn plant_menu_set_quantity_min(&mut self) {
13743 self.state.plant_quantity = 1;
13744 self.state.clamp_plant_menu();
13745 }
13746
13747 pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
13748 let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
13749 self.close_plant_menu();
13750 anyhow::bail!("no seeds to plant");
13751 };
13752 self.close_plant_menu();
13753 self.plant_seeds(seed, qty).await?;
13754 self.state.push_log(format!("Planted {qty}× {label}"));
13755 Ok(())
13756 }
13757
13758 pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
13761 if !self.state.is_alive() {
13762 anyhow::bail!("you are dead");
13763 }
13764 let binding = self
13765 .state
13766 .hotbar_ability(slot)
13767 .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
13768 .to_string();
13769 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
13770 let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
13771 if qty == 0 {
13772 anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
13773 }
13774 return self.use_item(template_id).await;
13775 }
13776 let ability_id = binding;
13777 if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
13778 return self
13779 .cast_ability(&ability_id, Some(self.state.entity_id))
13780 .await;
13781 }
13782 let is_heal = ability_id == "heal_touch"
13783 || self
13784 .state
13785 .ability_meta
13786 .get(&ability_id)
13787 .map(|meta| meta.is_heal)
13788 .unwrap_or(false);
13789 let target = if is_heal {
13790 Some(
13791 self.state
13792 .target_for_slot(2)
13793 .unwrap_or(self.state.entity_id),
13794 )
13795 } else {
13796 self.state
13797 .target_for_slot(1)
13798 .or_else(|| self.state.target_for_slot(2))
13799 };
13800 let Some(target_id) = target else {
13801 anyhow::bail!("no target — Tab to select, then press the hotbar key");
13802 };
13803 self.cast_ability(&ability_id, Some(target_id)).await
13804 }
13805
13806 pub async fn set_hotbar_slot(
13809 &mut self,
13810 slot: u8,
13811 ability_id: Option<&str>,
13812 ) -> anyhow::Result<()> {
13813 if !self.state.is_alive() {
13814 anyhow::bail!("you are dead");
13815 }
13816 if !(1..=9).contains(&slot) {
13817 anyhow::bail!("hotbar slot must be 1–9");
13818 }
13819 let ability_id = ability_id
13820 .map(str::trim)
13821 .filter(|id| !id.is_empty())
13822 .map(str::to_string);
13823 self.seq += 1;
13824 self.session
13825 .submit_intent(Intent::SetHotbarSlot {
13826 entity_id: self.state.entity_id,
13827 slot,
13828 ability_id: ability_id.clone(),
13829 seq: self.seq,
13830 })
13831 .await?;
13832 self.state.intents_sent += 1;
13833 let idx = (slot - 1) as usize;
13834 if self.state.hotbar.len() < 9 {
13835 self.state.hotbar.resize(9, None);
13836 }
13837 if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
13838 *slot_mut = ability_id.clone();
13839 }
13840 match ability_id {
13841 Some(id) => {
13842 let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
13843 format!("use {tid}")
13844 } else {
13845 id
13846 };
13847 self.state.push_log(format!("Hotbar {slot} → {label}"))
13848 }
13849 None => self.state.push_log(format!("Hotbar {slot} cleared")),
13850 }
13851 Ok(())
13852 }
13853
13854 pub fn npc_verb_options(&self) -> Vec<&'static str> {
13855 self.state.npc_verb_options()
13856 }
13857
13858 pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
13859 let Some(npc_id) = self.state.npc_verb_target.clone() else {
13860 return Ok(());
13861 };
13862 let options = self.npc_verb_options();
13863 let choice = options
13864 .get(self.state.npc_verb_index)
13865 .copied()
13866 .unwrap_or("Talk");
13867 self.seq += 1;
13868 match choice {
13869 "Trade" | "Bank" | "Storage" | "Market" => {
13870 self.session
13871 .submit_intent(Intent::Interact {
13872 entity_id: self.state.entity_id,
13873 target_id: npc_id,
13874 seq: self.seq,
13875 })
13876 .await?;
13877 }
13878 _ => {
13879 self.session
13880 .submit_intent(Intent::NpcTalkOpen {
13881 entity_id: self.state.entity_id,
13882 npc_id,
13883 seq: self.seq,
13884 })
13885 .await?;
13886 }
13887 }
13888 self.state.intents_sent += 1;
13889 Ok(())
13890 }
13891
13892 pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
13893 let Some(chat) = self.state.npc_chat.clone() else {
13894 return Ok(());
13895 };
13896 let message = chat.input.trim().to_string();
13897 if message.is_empty() || chat.pending {
13898 return Ok(());
13899 }
13900 if let Some(c) = self.state.npc_chat.as_mut() {
13901 c.lines.push(format!("You: {message}"));
13902 c.input.clear();
13903 c.pending = true;
13904 }
13905 self.seq += 1;
13906 self.session
13907 .submit_intent(Intent::NpcTalkSay {
13908 entity_id: self.state.entity_id,
13909 npc_id: chat.npc_id,
13910 message,
13911 seq: self.seq,
13912 })
13913 .await?;
13914 self.state.intents_sent += 1;
13915 Ok(())
13916 }
13917
13918 pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
13919 let return_to_verbs = self.state.npc_verb_target.is_some();
13920 let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
13921 self.state.show_npc_chat = false;
13922 if return_to_verbs {
13923 self.state.show_npc_verb_menu = true;
13924 }
13925 return Ok(());
13926 };
13927 self.seq += 1;
13928 self.session
13929 .submit_intent(Intent::NpcTalkClose {
13930 entity_id: self.state.entity_id,
13931 npc_id,
13932 seq: self.seq,
13933 })
13934 .await?;
13935 self.state.intents_sent += 1;
13936 self.state.show_npc_chat = false;
13937 self.state.npc_chat = None;
13938 if return_to_verbs {
13939 self.state.show_npc_verb_menu = true;
13940 }
13941 Ok(())
13942 }
13943
13944 pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
13946 if self.state.show_quest_offer
13947 && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
13948 {
13949 self.quest_offer_decline();
13950 return Ok(());
13951 }
13952 if self.state.show_npc_chat {
13953 return self.npc_talk_close().await;
13954 }
13955 if self.state.show_shop_menu {
13956 return self.back_from_shop_menu().await;
13957 }
13958 if self.state.bank_panel.is_some() {
13959 if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
13960 self.bank_transfer_back();
13961 return Ok(());
13962 }
13963 return self.close_bank_panel().await;
13964 }
13965 if self.state.storage_panel.is_some() {
13966 if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
13967 self.storage_ui_back();
13968 return Ok(());
13969 }
13970 return self.close_storage_panel().await;
13971 }
13972 if self.state.market_panel.is_some() {
13973 if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
13974 self.market_ui_back();
13975 return Ok(());
13976 }
13977 if self.state.market_buy_confirm.is_some() {
13978 self.state.market_buy_confirm = None;
13979 return Ok(());
13980 }
13981 return self.close_market_panel().await;
13982 }
13983 if self.state.show_npc_verb_menu {
13984 self.state.show_npc_verb_menu = false;
13985 self.state.npc_verb_target = None;
13986 }
13987 Ok(())
13988 }
13989
13990 pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
13991 self.seq += 1;
13992 self.session
13993 .submit_intent(Intent::TestDamage {
13994 entity_id: self.state.entity_id,
13995 amount,
13996 seq: self.seq,
13997 })
13998 .await?;
13999 self.state.intents_sent += 1;
14000 Ok(())
14001 }
14002
14003 pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
14004 self.cycle_combat_target_slot(1, reverse).await
14005 }
14006
14007 pub async fn cycle_combat_target_slot(
14008 &mut self,
14009 slot_index: u8,
14010 reverse: bool,
14011 ) -> anyhow::Result<()> {
14012 if !self.state.is_alive() {
14013 anyhow::bail!("you are dead");
14014 }
14015 let candidates = self.state.candidates_for_slot(slot_index);
14016 if candidates.is_empty() {
14017 anyhow::bail!("no targets nearby");
14018 }
14019 let current = self.state.target_for_slot(slot_index);
14020 let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
14021 let next_idx = match idx {
14022 None => 0,
14023 Some(i) if reverse => {
14024 if i == 0 {
14025 candidates.len() - 1
14026 } else {
14027 i - 1
14028 }
14029 }
14030 Some(i) => (i + 1) % candidates.len(),
14031 };
14032 if idx == Some(next_idx) && candidates.len() == 1 {
14033 self.clear_combat_target_slot(slot_index).await?;
14034 return Ok(());
14035 }
14036 let (target_id, label) = candidates[next_idx].clone();
14037 self.set_combat_target_slot(slot_index, target_id, &label)
14038 .await
14039 }
14040
14041 pub async fn set_combat_target_slot(
14042 &mut self,
14043 slot_index: u8,
14044 target_id: EntityId,
14045 label: &str,
14046 ) -> anyhow::Result<()> {
14047 if !self.state.is_alive() {
14048 anyhow::bail!("you are dead");
14049 }
14050 self.seq += 1;
14051 self.session
14052 .submit_intent(Intent::SetTargetSlot {
14053 entity_id: self.state.entity_id,
14054 slot_index,
14055 target_id,
14056 seq: self.seq,
14057 })
14058 .await?;
14059 self.state.intents_sent += 1;
14060 if slot_index == 1 {
14061 self.state.combat_target = Some(target_id);
14062 self.state.combat_target_label = Some(label.to_string());
14063 }
14064 self.state
14065 .push_log(format!("Slot {slot_index} target: {label}"));
14066 Ok(())
14067 }
14068
14069 pub async fn set_combat_target(
14070 &mut self,
14071 target_id: EntityId,
14072 label: &str,
14073 ) -> anyhow::Result<()> {
14074 self.set_combat_target_slot(1, target_id, label).await
14075 }
14076
14077 pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
14078 if slot_index == 1 && self.state.combat_target.is_none() {
14079 return Ok(());
14080 }
14081 self.seq += 1;
14082 self.session
14083 .submit_intent(Intent::ClearTargetSlot {
14084 entity_id: self.state.entity_id,
14085 slot_index,
14086 seq: self.seq,
14087 })
14088 .await?;
14089 if slot_index == 1 {
14090 self.state.combat_target = None;
14091 self.state.combat_target_label = None;
14092 }
14093 self.state.intents_sent += 1;
14094 self.state
14095 .push_log(format!("Slot {slot_index} target cleared"));
14096 Ok(())
14097 }
14098
14099 pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
14100 self.clear_combat_target_slot(1).await
14101 }
14102
14103 pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
14104 if !self.state.is_alive() {
14105 anyhow::bail!("you are dead");
14106 }
14107 self.seq += 1;
14108 self.session
14109 .submit_intent(Intent::AdvanceRotation {
14110 entity_id: self.state.entity_id,
14111 slot_index,
14112 seq: self.seq,
14113 })
14114 .await?;
14115 self.state.intents_sent += 1;
14116 Ok(())
14117 }
14118
14119 pub async fn assign_slot_preset(
14120 &mut self,
14121 slot_index: u8,
14122 preset_id: &str,
14123 ) -> anyhow::Result<()> {
14124 if !self.state.is_alive() {
14125 anyhow::bail!("you are dead");
14126 }
14127 self.seq += 1;
14128 self.session
14129 .submit_intent(Intent::AssignSlotPreset {
14130 entity_id: self.state.entity_id,
14131 slot_index,
14132 preset_id: preset_id.to_string(),
14133 seq: self.seq,
14134 })
14135 .await?;
14136 self.state.intents_sent += 1;
14137 if let Some(slot) = self
14138 .state
14139 .combat_slots
14140 .iter_mut()
14141 .find(|s| s.slot_index == slot_index)
14142 {
14143 slot.preset_id = Some(preset_id.to_string());
14144 if let Some(preset) = self
14145 .state
14146 .rotation_presets
14147 .iter()
14148 .find(|p| p.id == preset_id)
14149 {
14150 slot.preset_label = Some(preset.label.clone());
14151 slot.rotation = preset.abilities.clone();
14152 slot.rotation_index = 0;
14153 }
14154 }
14155 self.state
14156 .push_log(format!("T{slot_index} loadout → {preset_id}"));
14157 Ok(())
14158 }
14159
14160 pub async fn cast_ability(
14161 &mut self,
14162 ability_id: &str,
14163 target_id: Option<EntityId>,
14164 ) -> anyhow::Result<()> {
14165 if !self.state.is_alive() {
14166 anyhow::bail!("you are dead");
14167 }
14168 let allows_ground = self.state.ability_allows_ground(ability_id);
14169 let requires_ground = self.state.ability_requires_ground(ability_id);
14170 if requires_ground && self.state.ground_target.is_none() {
14171 anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
14172 }
14173 let (resolved_target_id, target_point) = if allows_ground {
14174 if let Some((x, y, z)) = self.state.ground_target {
14175 (
14176 target_id.unwrap_or(self.state.entity_id),
14177 Some(flatland_protocol::AimPoint { x, y, z }),
14178 )
14179 } else {
14180 (
14181 target_id
14182 .or_else(|| self.state.target_for_slot(2))
14183 .or_else(|| self.state.target_for_slot(1))
14184 .unwrap_or(self.state.entity_id),
14185 None,
14186 )
14187 }
14188 } else {
14189 (
14190 target_id
14191 .or_else(|| self.state.target_for_slot(2))
14192 .or_else(|| self.state.target_for_slot(1))
14193 .unwrap_or(self.state.entity_id),
14194 None,
14195 )
14196 };
14197 self.seq += 1;
14198 self.session
14199 .submit_intent(Intent::Cast {
14200 entity_id: self.state.entity_id,
14201 ability_id: ability_id.to_string(),
14202 target_id: resolved_target_id,
14203 target_point,
14204 seq: self.seq,
14205 })
14206 .await?;
14207 self.state.intents_sent += 1;
14208 match target_point {
14209 Some(point) => self.state.push_log(format!(
14210 "Cast {ability_id} → ({:.1}, {:.1})",
14211 point.x, point.y
14212 )),
14213 None => self
14214 .state
14215 .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
14216 }
14217 Ok(())
14218 }
14219
14220 pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
14221 self.seq += 1;
14222 self.session
14223 .submit_intent(Intent::UpsertRotationPreset {
14224 entity_id: self.state.entity_id,
14225 preset: preset.clone(),
14226 seq: self.seq,
14227 })
14228 .await?;
14229 self.state.intents_sent += 1;
14230 if let Some(existing) = self
14231 .state
14232 .rotation_presets
14233 .iter_mut()
14234 .find(|p| p.id == preset.id)
14235 {
14236 *existing = preset.clone();
14237 } else {
14238 self.state.rotation_presets.push(preset.clone());
14239 }
14240 for slot in &mut self.state.combat_slots {
14241 if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
14242 slot.preset_label = Some(preset.label.clone());
14243 slot.rotation = preset.abilities.clone();
14244 }
14245 }
14246 self.state
14247 .push_log(format!("Saved rotation: {}", preset.label));
14248 Ok(())
14249 }
14250
14251 pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
14252 self.seq += 1;
14253 self.session
14254 .submit_intent(Intent::DeleteRotationPreset {
14255 entity_id: self.state.entity_id,
14256 preset_id: preset_id.to_string(),
14257 seq: self.seq,
14258 })
14259 .await?;
14260 self.state.intents_sent += 1;
14261 self.state.rotation_presets.retain(|p| p.id != preset_id);
14262 for slot in &mut self.state.combat_slots {
14263 if slot.preset_id.as_deref() == Some(preset_id) {
14264 slot.preset_id = None;
14265 slot.preset_label = None;
14266 slot.rotation.clear();
14267 slot.rotation_index = 0;
14268 }
14269 }
14270 self.state
14271 .push_log(format!("Deleted rotation: {preset_id}"));
14272 Ok(())
14273 }
14274
14275 pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
14276 if !self.state.is_alive() {
14277 anyhow::bail!("you are dead");
14278 }
14279 let enabled = !self
14280 .state
14281 .combat_slots
14282 .iter()
14283 .find(|s| s.slot_index == slot_index)
14284 .map(|s| s.auto_enabled)
14285 .unwrap_or(false);
14286 self.seq += 1;
14287 self.session
14288 .submit_intent(Intent::SetAutoAttack {
14289 entity_id: self.state.entity_id,
14290 slot_index,
14291 enabled,
14292 seq: self.seq,
14293 })
14294 .await?;
14295 if slot_index == 1 {
14296 self.state.auto_attack = enabled;
14297 }
14298 self.state.intents_sent += 1;
14299 self.state.push_log(format!(
14300 "T{slot_index} auto {}",
14301 if enabled { "ON" } else { "OFF" }
14302 ));
14303 Ok(())
14304 }
14305
14306 pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
14307 if !self.state.connected {
14308 anyhow::bail!("not connected");
14309 }
14310 if !self.state.is_alive() {
14311 anyhow::bail!("you are dead");
14312 }
14313 let (px, py) = self.state.player_position();
14314 if self
14315 .state
14316 .ground_drops
14317 .iter()
14318 .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
14319 {
14320 anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
14321 }
14322 self.seq += 1;
14323 self.session
14324 .submit_intent(Intent::Pickup {
14325 entity_id: self.state.entity_id,
14326 drop_id: None,
14327 seq: self.seq,
14328 })
14329 .await?;
14330 self.state.intents_sent += 1;
14331 Ok(())
14332 }
14333
14334 pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
14335 self.toggle_auto_attack_slot(1).await
14336 }
14337
14338 pub async fn dodge(&mut self) -> anyhow::Result<()> {
14339 if !self.state.is_alive() {
14340 anyhow::bail!("you are dead");
14341 }
14342 let (forward, strafe) = self.last_move_axes();
14343 self.seq += 1;
14344 self.session
14345 .submit_intent(Intent::Dodge {
14346 entity_id: self.state.entity_id,
14347 forward,
14348 strafe,
14349 seq: self.seq,
14350 })
14351 .await?;
14352 self.state.intents_sent += 1;
14353 self.state.push_log("Dodge!");
14354 Ok(())
14355 }
14356
14357 pub async fn lunge(&mut self) -> anyhow::Result<()> {
14358 if !self.state.is_alive() {
14359 anyhow::bail!("you are dead");
14360 }
14361 let (forward, strafe) = self.last_move_axes();
14362 self.seq += 1;
14363 self.session
14364 .submit_intent(Intent::Lunge {
14365 entity_id: self.state.entity_id,
14366 forward,
14367 strafe,
14368 seq: self.seq,
14369 })
14370 .await?;
14371 self.state.intents_sent += 1;
14372 self.state.push_log("Lunge!");
14373 Ok(())
14374 }
14375
14376 pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
14377 if !self.state.is_alive() {
14378 anyhow::bail!("you are dead");
14379 }
14380 self.seq += 1;
14381 self.session
14382 .submit_intent(Intent::DirectionalJump {
14383 entity_id: self.state.entity_id,
14384 forward,
14385 strafe,
14386 seq: self.seq,
14387 })
14388 .await?;
14389 self.state.intents_sent += 1;
14390 self.state.push_log("Jump!");
14391 Ok(())
14392 }
14393
14394 pub fn last_move_axes(&self) -> (f32, f32) {
14396 (self.last_move_forward, self.last_move_strafe)
14397 }
14398
14399 pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
14400 if !self.state.is_alive() {
14401 anyhow::bail!("you are dead");
14402 }
14403 self.seq += 1;
14404 self.session
14405 .submit_intent(Intent::Block {
14406 entity_id: self.state.entity_id,
14407 enabled,
14408 seq: self.seq,
14409 })
14410 .await?;
14411 self.state.intents_sent += 1;
14412 if enabled {
14413 self.state.push_log("Blocking");
14414 }
14415 Ok(())
14416 }
14417
14418 pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
14419 if !self.state.is_alive() {
14420 anyhow::bail!("you are dead");
14421 }
14422 self.seq += 1;
14423 self.session
14424 .submit_intent(Intent::EquipMainhand {
14425 entity_id: self.state.entity_id,
14426 template_id,
14427 instance_id: None,
14428 seq: self.seq,
14429 })
14430 .await?;
14431 self.state.intents_sent += 1;
14432 Ok(())
14433 }
14434
14435 pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
14437 let idx = self.state.equip_menu_index;
14438 let slots = equip_paperdoll_rows(&self.state);
14439 let Some(row) = slots.get(idx) else {
14440 return Ok(());
14441 };
14442 match row {
14443 EquipPaperdollRow::Body { slot, filled } => {
14444 if *filled {
14445 self.equip_worn(*slot, None).await
14446 } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
14447 self.equip_worn(*slot, Some(inst)).await
14448 } else {
14449 self.state.push_log(format!("No item for {}", body_slot_label(*slot)));
14450 Ok(())
14451 }
14452 }
14453 EquipPaperdollRow::Mainhand { filled } => {
14454 if *filled {
14455 self.unequip_mainhand().await
14456 } else if let Some(tid) = first_inventory_weapon(&self.state) {
14457 self.equip_mainhand(Some(tid)).await
14458 } else {
14459 self.state.push_log("No weapon in inventory".to_string());
14460 Ok(())
14461 }
14462 }
14463 EquipPaperdollRow::Offhand { filled, locked } => {
14464 if *locked {
14465 self.state
14466 .push_log("Offhand locked — two-handed weapon equipped".to_string());
14467 Ok(())
14468 } else if *filled {
14469 self.unequip_offhand().await
14470 } else if let Some(tid) = first_inventory_offhand(&self.state) {
14471 self.equip_offhand(Some(tid)).await
14472 } else {
14473 self.state
14474 .push_log("No offhand item in inventory".to_string());
14475 Ok(())
14476 }
14477 }
14478 }
14479 }
14480
14481 pub async fn say(
14482 &mut self,
14483 channel: flatland_protocol::ChatChannel,
14484 text: &str,
14485 ) -> anyhow::Result<()> {
14486 self.say_to(channel, text, None).await
14487 }
14488
14489 pub async fn say_to(
14490 &mut self,
14491 channel: flatland_protocol::ChatChannel,
14492 text: &str,
14493 to_entity: Option<EntityId>,
14494 ) -> anyhow::Result<()> {
14495 self.seq += 1;
14496 self.session
14497 .submit_intent(Intent::Say {
14498 entity_id: self.state.entity_id,
14499 channel,
14500 text: text.to_string(),
14501 to_entity,
14502 seq: self.seq,
14503 })
14504 .await?;
14505 self.state.intents_sent += 1;
14506 Ok(())
14507 }
14508
14509 pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
14510 let Some(peer) = self.state.player_verbs.target_entity else {
14511 return Ok(());
14512 };
14513 let label = self.state.player_verbs.target_label.clone();
14514 let choice = crate::social::PlayerVerbState::options()
14515 .get(self.state.player_verbs.index)
14516 .copied()
14517 .unwrap_or("Whisper");
14518 self.state.player_verbs.close();
14519 match choice {
14520 "Trade" => {
14521 self.seq += 1;
14524 self.session
14525 .submit_intent(Intent::TradeRequest {
14526 entity_id: self.state.entity_id,
14527 peer_entity_id: peer,
14528 seq: self.seq,
14529 })
14530 .await?;
14531 self.state.intents_sent += 1;
14532 self.state
14533 .social_chat
14534 .push_system(format!("Trade request sent to {label} — waiting for accept"));
14535 }
14536 "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
14537 _ => self.state.social_chat.focus_nearby(),
14538 }
14539 Ok(())
14540 }
14541
14542 pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
14543 let Some(pending) = self.state.social_chat.pending_trade.take() else {
14544 return Ok(());
14545 };
14546 self.seq += 1;
14547 self.session
14548 .submit_intent(Intent::TradeRespond {
14549 entity_id: self.state.entity_id,
14550 peer_entity_id: pending.from_entity,
14551 accept,
14552 seq: self.seq,
14553 })
14554 .await?;
14555 self.state.intents_sent += 1;
14556 if accept {
14557 self.state
14558 .social_chat
14559 .push_system(format!("Accepted trade with {}", pending.from_name));
14560 } else {
14561 self.state
14562 .social_chat
14563 .push_system(format!("Declined trade with {}", pending.from_name));
14564 }
14565 Ok(())
14566 }
14567
14568 pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
14569 let text = self.state.social_chat.buffer.trim().to_string();
14570 if text.is_empty() {
14571 return Ok(());
14572 }
14573 self.state.social_chat.buffer.clear();
14574 if crate::social::is_chat_slash_line(&text) {
14575 match crate::social::parse_chat_slash(&text) {
14576 Some(cmd) => return self.apply_chat_slash(cmd).await,
14577 None => {
14578 self.state.social_chat.push_system(format!(
14579 "Unknown command — {}",
14580 crate::social::chat_slash_help_text()
14581 ));
14582 return Ok(());
14583 }
14584 }
14585 }
14586 let thread = self.state.social_chat.thread;
14587 let channel = thread.channel();
14588 let to = thread.to_entity();
14589 if let Some(peer) = to {
14590 let label = self.state.social_chat.peer_label.clone();
14591 self.state
14592 .social_chat
14593 .remember_whisper_peer(peer, &label, channel);
14594 }
14595 self.say_to(channel, &text, to).await
14596 }
14597
14598 async fn apply_chat_slash(
14599 &mut self,
14600 cmd: crate::social::ChatSlashCommand,
14601 ) -> anyhow::Result<()> {
14602 use crate::social::{chat_slash_help_text, ChatSlashCommand};
14603 match cmd {
14604 ChatSlashCommand::Help => {
14605 self.state
14606 .social_chat
14607 .push_system(chat_slash_help_text().to_string());
14608 Ok(())
14609 }
14610 ChatSlashCommand::Nearby { message } => {
14611 self.state.social_chat.focus_nearby();
14612 self.state
14613 .social_chat
14614 .push_system("Nearby speech — everyone close can hear");
14615 if let Some(msg) = message {
14616 self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
14617 .await
14618 } else {
14619 Ok(())
14620 }
14621 }
14622 ChatSlashCommand::Reply { message } => {
14623 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
14624 self.state.social_chat.push_system(
14625 "No one to reply to — wait for a whisper, or /whisper Name",
14626 );
14627 return Ok(());
14628 };
14629 let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
14630 self.state
14631 .social_chat
14632 .set_whisper_thread(peer.entity_id, &peer.label, stone);
14633 self.state.social_chat.push_system(format!(
14634 "Replying to {} — type and Enter · /nearby",
14635 peer.label
14636 ));
14637 if let Some(msg) = message {
14638 self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
14639 } else {
14640 Ok(())
14641 }
14642 }
14643 ChatSlashCommand::Whisper { name, message } => {
14644 let (peer_id, label, stone) = if let Some(name) = name {
14645 match self.resolve_whisper_target(&name) {
14646 Ok(t) => t,
14647 Err(err) => {
14648 self.state.social_chat.push_system(err);
14649 return Ok(());
14650 }
14651 }
14652 } else {
14653 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
14654 self.state.social_chat.push_system(
14655 "Usage: /whisper Name [message] · or /reply after someone whispers you",
14656 );
14657 return Ok(());
14658 };
14659 (
14660 peer.entity_id,
14661 peer.label,
14662 peer.channel == flatland_protocol::ChatChannel::WhisperStone,
14663 )
14664 };
14665 self.state
14666 .social_chat
14667 .set_whisper_thread(peer_id, &label, stone);
14668 let channel = if stone {
14669 flatland_protocol::ChatChannel::WhisperStone
14670 } else {
14671 flatland_protocol::ChatChannel::Whisper
14672 };
14673 if let Some(msg) = message {
14674 self.state.social_chat.push_system(format!(
14675 "Whisper → {label}"
14676 ));
14677 self.say_to(channel, &msg, Some(peer_id)).await
14678 } else {
14679 self.state.social_chat.push_system(format!(
14680 "Whispering {label} — type and Enter · Esc / /nearby cancels"
14681 ));
14682 Ok(())
14683 }
14684 }
14685 }
14686 }
14687
14688 fn resolve_whisper_target(
14690 &self,
14691 name: &str,
14692 ) -> Result<(EntityId, String, bool), String> {
14693 let needle = name.trim().to_ascii_lowercase();
14694 if needle.is_empty() {
14695 return Err("Usage: /whisper Name [message]".into());
14696 }
14697 let mut candidates: Vec<(EntityId, String)> = self
14698 .state
14699 .entities
14700 .iter()
14701 .filter(|e| e.id != self.state.entity_id)
14702 .filter(|e| !e.label.trim().is_empty())
14703 .filter(|e| e.vitals.is_some())
14704 .filter(|e| {
14705 !self
14706 .state
14707 .npcs
14708 .iter()
14709 .any(|n| n.id == e.id.to_string())
14710 })
14711 .filter(|e| {
14712 !self
14713 .state
14714 .hired_workers
14715 .iter()
14716 .any(|w| w.entity_id == e.id)
14717 })
14718 .map(|e| (e.id, e.label.clone()))
14719 .collect();
14720
14721 if let Some(last) = &self.state.social_chat.last_whisper_peer {
14723 if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
14724 candidates.push((last.entity_id, last.label.clone()));
14725 }
14726 }
14727
14728 let exact: Vec<_> = candidates
14729 .iter()
14730 .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
14731 .cloned()
14732 .collect();
14733 let pool = if exact.len() == 1 {
14734 exact
14735 } else if exact.len() > 1 {
14736 return Err(format!(
14737 "Several players named '{name}' nearby — move closer and try again"
14738 ));
14739 } else {
14740 let starts: Vec<_> = candidates
14741 .iter()
14742 .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
14743 .cloned()
14744 .collect();
14745 if starts.len() == 1 {
14746 starts
14747 } else if starts.len() > 1 {
14748 let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
14749 return Err(format!(
14750 "Ambiguous name '{name}' — matches: {}",
14751 names.join(", ")
14752 ));
14753 } else {
14754 let contains: Vec<_> = candidates
14755 .iter()
14756 .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
14757 .cloned()
14758 .collect();
14759 if contains.len() == 1 {
14760 contains
14761 } else if contains.is_empty() {
14762 return Err(format!(
14763 "No player matching '{name}' in range — get closer or check the spelling"
14764 ));
14765 } else {
14766 let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
14767 return Err(format!(
14768 "Ambiguous name '{name}' — matches: {}",
14769 names.join(", ")
14770 ));
14771 }
14772 }
14773 };
14774
14775 let (id, label) = pool.into_iter().next().unwrap();
14776 let stone = self
14777 .state
14778 .social_chat
14779 .last_whisper_peer
14780 .as_ref()
14781 .is_some_and(|p| p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone);
14782 Ok((id, label, stone))
14783 }
14784
14785 pub async fn trade_present_selected(
14786 &mut self,
14787 item_instance_id: uuid::Uuid,
14788 ) -> anyhow::Result<()> {
14789 self.trade_present_quantity(item_instance_id, None).await
14790 }
14791
14792 pub async fn trade_present_quantity(
14793 &mut self,
14794 item_instance_id: uuid::Uuid,
14795 quantity: Option<u32>,
14796 ) -> anyhow::Result<()> {
14797 self.seq += 1;
14798 self.session
14799 .submit_intent(Intent::TradePresent {
14800 entity_id: self.state.entity_id,
14801 item_instance_id,
14802 quantity,
14803 seq: self.seq,
14804 })
14805 .await?;
14806 self.state.intents_sent += 1;
14807 self.state.trade_ui.qty_entry = None;
14808 self.state.trade_ui.picking_inventory = false;
14809 Ok(())
14810 }
14811
14812 pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
14814 if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
14815 let qty = self.state.trade_ui.present_quantity();
14816 return self
14817 .trade_present_quantity(entry.item_instance_id, qty)
14818 .await;
14819 }
14820 if !self.state.trade_ui.picking_inventory {
14821 return Ok(());
14822 }
14823 let stacks = self.state.trade_presentable_stacks();
14824 let Some(stack) = stacks.get(self.state.trade_ui.inventory_index).copied() else {
14825 return Ok(());
14826 };
14827 let Some(id) = stack.item_instance_id else {
14828 return Ok(());
14829 };
14830 let label = stack
14831 .display_name
14832 .clone()
14833 .unwrap_or_else(|| stack.template_id.clone());
14834 if stack.quantity <= 1 {
14835 self.trade_present_quantity(id, Some(1)).await
14836 } else {
14837 self.state
14838 .trade_ui
14839 .begin_qty_entry(id, label, stack.quantity);
14840 Ok(())
14841 }
14842 }
14843
14844 pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
14845 self.seq += 1;
14846 self.session
14847 .submit_intent(Intent::TradeSetReady {
14848 entity_id: self.state.entity_id,
14849 ready,
14850 seq: self.seq,
14851 })
14852 .await?;
14853 self.state.intents_sent += 1;
14854 Ok(())
14855 }
14856
14857 pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
14858 self.seq += 1;
14859 self.session
14860 .submit_intent(Intent::TradeCancel {
14861 entity_id: self.state.entity_id,
14862 seq: self.seq,
14863 })
14864 .await?;
14865 self.state.intents_sent += 1;
14866 self.state.trade_ui.close();
14867 Ok(())
14868 }
14869
14870 pub async fn destroy_whisper_stone(
14871 &mut self,
14872 item_instance_id: uuid::Uuid,
14873 ) -> anyhow::Result<()> {
14874 self.seq += 1;
14875 self.session
14876 .submit_intent(Intent::DestroyWhisperStone {
14877 entity_id: self.state.entity_id,
14878 item_instance_id,
14879 seq: self.seq,
14880 })
14881 .await?;
14882 self.state.intents_sent += 1;
14883 Ok(())
14884 }
14885
14886 pub async fn stop(&mut self) -> anyhow::Result<()> {
14887 self.seq += 1;
14888 self.session
14889 .submit_intent(Intent::Stop {
14890 entity_id: self.state.entity_id,
14891 seq: self.seq,
14892 })
14893 .await?;
14894 self.state.intents_sent += 1;
14895 Ok(())
14896 }
14897
14898 pub fn disconnect(&self) {
14899 self.session.disconnect();
14900 }
14901}
14902
14903fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
14904 let dx = ax - bx;
14905 let dy = ay - by;
14906 (dx * dx + dy * dy).sqrt()
14907}
14908
14909#[cfg(test)]
14910mod tests {
14911 use std::collections::BTreeMap;
14912
14913 use super::*;
14914 use flatland_protocol::{
14915 BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
14916 };
14917
14918 fn sample_state() -> GameState {
14919 let mut state = GameState {
14920 session_id: 1,
14921 entity_id: 1,
14922 character_id: None,
14923 tick: 0,
14924 chunk_rev: 0,
14925 content_rev: 0,
14926 publish_rev: 0,
14927 entities: vec![EntityState {
14928 id: 1,
14929 label: "You".into(),
14930 transform: Transform {
14931 position: WorldCoord::surface(128.0, 128.0),
14932 yaw: 0.0,
14933 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
14934 },
14935 vitals: None,
14936 attributes: None,
14937 skills: None,
14938 inside_building: None,
14939 tile_id: None,
14940 paperdoll_ref: None,
14941 draw_scale: 1.0,
14942 presentation_state: None,
14943 sprite_mode: None,
14944 progression_xp: None,
14945 combat_cues: vec![],
14946 statuses: vec![],
14947 }],
14948 player: None,
14949 resource_nodes: vec![ResourceNodeView {
14950 id: "oak-1".into(),
14951 label: "Oak".into(),
14952 x: 130.0,
14953 y: 128.0,
14954 z: 0.0,
14955 item_template: "oak_log".into(),
14956 state: ResourceNodeState::Available,
14957 blocking: true,
14958 blocking_radius_m: 0.8,
14959 harvest_off: false,
14960 tile_id: None,
14961 yaw: 0.0,
14962 pitch: 0.0,
14963 roll: 0.0,
14964 draw_scale: 1.0,
14965 sprite_mode: None,
14966 growth_progress: None,
14967 presentation_state: None,
14968 channel_start_tick: None,
14969 channel_end_tick: None,
14970 harvest_drop_templates: vec![],
14971 }],
14972 ground_drops: vec![],
14973 placed_containers: vec![],
14974 buildings: vec![BuildingView {
14975 id: "broker-hut".into(),
14976 label: "Broker".into(),
14977 x: 148.0,
14978 y: 118.0,
14979 width_m: 8.0,
14980 depth_m: 6.0,
14981 interior_blueprint: Some("broker_hut".into()),
14982 tags: vec![],
14983 market_boundary_zone_ids: vec![],
14984 market_max_volume: None,
14985 wall_set: None,
14986 roof_set: None,
14987 }],
14988 doors: vec![flatland_protocol::DoorView {
14989 id: "door-1".into(),
14990 building_id: "broker-hut".into(),
14991 x: 148.0,
14992 y: 118.0,
14993 open: false,
14994 portal: Some("front".into()),
14995 locked: false,
14996 accessible: true,
14997 lock_id: None,
14998 }],
14999 interior_map: None,
15000 npcs: vec![],
15001 blueprints: vec![],
15002 building_materials: vec![],
15003 world_x0: 0.0,
15004 world_y0: 0.0,
15005 world_width_m: 256.0,
15006 world_height_m: 256.0,
15007 terrain_zones: Vec::new(),
15008 z_platforms: Vec::new(),
15009 z_transitions: Vec::new(),
15010 z_bands_outdoor_backup: None,
15011 world_clock: flatland_protocol::WorldClock::default(),
15012 inventory: std::collections::HashMap::new(),
15013 inventory_hints: std::collections::HashMap::new(),
15014 logs: VecDeque::new(),
15015 intents_sent: 0,
15016 ticks_received: 0,
15017 connected: true,
15018 disconnect_reason: None,
15019 show_stats: false,
15020 hud_log_hidden: false,
15021 show_equip_menu: false,
15022 equip_menu_index: 0,
15023 show_craft_menu: false,
15024 show_plot_build_menu: false,
15025 plot_build_focus_wall: true,
15026 plot_build_wall_index: 0,
15027 plot_build_roof_index: 0,
15028 craft_menu_index: 0,
15029 craft_batch_quantity: 1,
15030 show_shop_menu: false,
15031 shop_catalog: None,
15032 bank_panel: None,
15033 bank_menu_index: 0,
15034 bank_ui_mode: BankUiMode::Menu,
15035 storage_panel: None,
15036 market_panel: None,
15037 market_menu_index: 0,
15038 market_filter: String::new(),
15039 market_filter_focused: false,
15040 market_category_filter: None,
15041 market_buy_confirm: None,
15042 market_ui_mode: MarketUiMode::Browse,
15043 storage_menu_index: 0,
15044 storage_ui_mode: StorageUiMode::Menu,
15045 shop_tab: ShopTab::default(),
15046 shop_menu_index: 0,
15047 shop_quantity: 1,
15048 shop_trade_log: VecDeque::new(),
15049 show_npc_verb_menu: false,
15050 npc_verb_target: None,
15051 npc_verb_index: 0,
15052 player_verbs: crate::social::PlayerVerbState::default(),
15053 social_chat: crate::social::SocialChatState::default(),
15054 trade_ui: crate::social::TradeUiState::default(),
15055 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
15056 show_npc_chat: false,
15057 npc_chat: None,
15058 show_inventory_menu: false,
15059 inventory_menu_index: 0,
15060 inventory_tab: InventoryTab::OnPerson,
15061 inventory_filter: String::new(),
15062 inventory_filter_focused: false,
15063 show_move_picker: false,
15064 show_rename_prompt: false,
15065 rename_plot_id: None,
15066 highlighted_plot_id: None,
15067 show_worker_rename: false,
15068 rename_buffer: String::new(),
15069 move_picker_index: 0,
15070 move_picker: None,
15071 show_grant_picker: false,
15072 grant_picker_index: 0,
15073 grant_picker: None,
15074 show_destroy_picker: false,
15075 destroy_confirm_pending: false,
15076 destroy_picker: None,
15077 combat_target: None,
15078 combat_target_label: None,
15079 ground_target: None,
15080 combat_fx: Vec::new(),
15081 property_zones: Vec::new(),
15082 tax_zones: Vec::new(),
15083 growth_zones: Vec::new(),
15084 biome_zones: Vec::new(),
15085 terrain_kind_nav: Vec::new(),
15086 property_plots: Vec::new(),
15087 property_plot_settings: None,
15088 claim_mode: None,
15089 relocate_mode: None,
15090 sell_plot_confirm: None,
15091 sell_plot_armed_at: None,
15092 show_plant_menu: false,
15093 plant_menu_index: 0,
15094 show_farm_access: false,
15095 farm_access_name_draft: String::new(),
15096 farm_access_discount_bps: 0,
15097 farm_access_index: 0,
15098 plant_quantity: 1,
15099 in_combat: false,
15100 auto_attack: true,
15101 combat_has_los: false,
15102 attack_cd_ticks: 0,
15103 gcd_ticks: 0,
15104 weapon_ability_id: "unarmed".into(),
15105 mainhand_template_id: None,
15106 mainhand_label: None,
15107 mainhand_instance_id: None,
15108 offhand_template_id: None,
15109 offhand_label: None,
15110 offhand_instance_id: None,
15111 mainhand_hand_slots: 1,
15112 defense: None,
15113 worn: BTreeMap::new(),
15114 carry_mass: 0.0,
15115 carry_mass_max: 0.0,
15116 encumbrance: flatland_protocol::EncumbranceState::Light,
15117 inventory_stacks: Vec::new(),
15118 keychain_stacks: Vec::new(),
15119 whisper_pouch_stacks: Vec::new(),
15120 combat_target_detail: None,
15121 statuses: Vec::new(),
15122 cast_progress: None,
15123 timed_channel: None,
15124 plot_build_offer: None,
15125 ability_cooldowns: Vec::new(),
15126 blocking_active: false,
15127 max_target_slots: 1,
15128 combat_slots: Vec::new(),
15129 rotation_presets: Vec::new(),
15130 known_abilities: Vec::new(),
15131 ability_meta: std::collections::HashMap::new(),
15132 ability_mastery: std::collections::HashMap::new(),
15133 hotbar: vec![None; 9],
15134 max_abilities_per_rotation: 0,
15135 show_loadout_menu: false,
15136 show_keychain_menu: false,
15137 keychain_menu_index: 0,
15138 show_rotation_editor: false,
15139 loadout_menu_index: 0,
15140 loadout_hotbar_slot: 1,
15141 loadout_ability_index: 0,
15142 loadout_focus_presets: false,
15143 rotation_editor: RotationEditorState::default(),
15144 harvest_in_progress: false,
15145 harvest_started_at: None,
15146 pending_craft_ack: None,
15147 pending_worker_job_ack: None,
15148 attending_worker_instance_id: None,
15149 quest_log: Vec::new(),
15150 interactables: Vec::new(),
15151 ledger: None,
15152 career: None,
15153 character_sheet_tab: CharacterSheetTab::Character,
15154 ledger_period: LedgerPeriod::Day,
15155 show_quest_offer: false,
15156 pending_quest_offer: None,
15157 show_quest_menu: false,
15158 quest_menu_index: 0,
15159 quest_withdraw_confirm: false,
15160 hired_workers: Vec::new(),
15161 show_workers_menu: false,
15162 workers_menu_index: 0,
15163 workers_menu_compact: false,
15164 worker_step_display: BTreeMap::new(),
15165 worker_error_display: BTreeMap::new(),
15166 show_worker_give_picker: false,
15167 worker_give_picker_index: 0,
15168 worker_give_picker: None,
15169 show_worker_give_target_picker: false,
15170 worker_give_target_picker_index: 0,
15171 worker_give_target_picker: None,
15172 show_worker_take_picker: false,
15173 worker_take_picker_index: 0,
15174 worker_take_picker: None,
15175 show_worker_teach_picker: false,
15176 worker_teach_picker_index: 0,
15177 worker_teach_picker: None,
15178 worker_route_editor: None,
15179 progression_curve: None,
15180 };
15181 state.player = state.entities.first().cloned();
15182 state
15183 }
15184
15185 #[test]
15186 fn whisper_cancels_when_peer_walks_out_of_range() {
15187 let mut state = sample_state();
15188 state.player = state.entities.first().cloned();
15189 let mut peer = state.entities[0].clone();
15190 peer.id = 2;
15191 peer.label = "Ada".into();
15192 peer.transform.position = WorldCoord::surface(129.0, 128.0); state.entities.push(peer.clone());
15194 state.social_chat.focus_whisper(2, "Ada");
15195 state.refresh_whisper_range();
15196 assert!(matches!(
15197 state.social_chat.thread,
15198 crate::social::ChatThreadKind::Whisper { peer: 2 }
15199 ));
15200
15201 peer.transform.position = WorldCoord::surface(132.0, 128.0); state.entities[1] = peer;
15203 state.refresh_whisper_range();
15204 assert_eq!(
15205 state.social_chat.thread,
15206 crate::social::ChatThreadKind::Nearby
15207 );
15208 assert!(!state.social_chat.input_focused);
15209 }
15210
15211 #[test]
15212 fn probe_use_world_hired_worker_manage() {
15213 let mut state = sample_state();
15214 state.hired_workers.push(flatland_protocol::HiredWorkerView {
15215 instance_id: "worker-1".into(),
15216 entity_id: 42,
15217 def_id: "worker_laborer".into(),
15218 label: "Sam".into(),
15219 x: 129.0,
15220 y: 128.0,
15221 z: 0.0,
15222 mode: flatland_protocol::WorkerModeView::JobLoop,
15223 state: flatland_protocol::WorkerStateView::Working,
15224 step_label: "cultivate".into(),
15225 vitals: flatland_protocol::WorkerVitalsSummary {
15226 health_pct: 100.0,
15227 stamina_pct: 100.0,
15228 },
15229 carry_pct: 0.0,
15230 last_error: None,
15231 wage_copper_per_interval: 1,
15232 effective_wage_copper: 1,
15233 wage_meters_walked: 0.0,
15234 lodging_container_id: None,
15235 route: None,
15236 route_stop_index: None,
15237 known_blueprint_ids: Vec::new(),
15238 level: 1,
15239 worker_xp: 0.0,
15240 inventory: Vec::new(),
15241 issue_hint: None,
15242 });
15243 let probe = state.probe_use_world();
15244 let primary = probe.primary.expect("primary");
15245 assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
15246 assert_eq!(primary.id, "worker-1");
15247 assert!(primary.hint_line().contains("Manage"));
15248 assert!(primary.hint_line().contains("Sam"));
15249 assert_eq!(
15250 state.nearest_interact_target().as_deref(),
15251 Some("worker-1")
15252 );
15253 }
15254
15255 #[test]
15256 fn market_clerk_verb_options_include_market() {
15257 let mut state = sample_state();
15258 state.npcs.push(flatland_protocol::NpcView {
15259 id: "mira_market".into(),
15260 label: "Mira".into(),
15261 role: "market_clerk".into(),
15262 x: 129.0,
15263 y: 128.0,
15264 building_id: Some("town_market".into()),
15265 entity_id: None,
15266 life_state: None,
15267 hp_pct: None,
15268 can_trade: false,
15269 tile_id: None,
15270 behavior_state: None,
15271 presentation_state: None,
15272 sprite_mode: None,
15273 paperdoll_ref: None,
15274 draw_scale: 1.0,
15275 });
15276 state.npc_verb_target = Some("mira_market".into());
15277 assert_eq!(state.npc_verb_options(), vec!["Market", "Talk"]);
15278 }
15279
15280 #[test]
15281 fn market_list_excludes_currency_stacks() {
15282 let mut state = sample_state();
15283 state.inventory_stacks = vec![
15284 flatland_protocol::ItemStack {
15285 template_id: "copper_coin".into(),
15286 quantity: 50,
15287 item_instance_id: Some(uuid::Uuid::from_u128(10)),
15288 display_name: Some("Copper Coin".into()),
15289 ..Default::default()
15290 },
15291 flatland_protocol::ItemStack {
15292 template_id: "oak_log".into(),
15293 quantity: 2,
15294 item_instance_id: Some(uuid::Uuid::from_u128(11)),
15295 display_name: Some("Oak Log".into()),
15296 ..Default::default()
15297 },
15298 flatland_protocol::ItemStack {
15299 template_id: "whisper_stone".into(),
15300 quantity: 1,
15301 item_instance_id: Some(uuid::Uuid::from_u128(12)),
15302 display_name: Some("Whisper Stone".into()),
15303 category: Some("quest".into()),
15304 listable: Some(false),
15305 ..Default::default()
15306 },
15307 ];
15308 let opts = state.market_list_item_options(&MarketListSourceKind::Person);
15309 assert_eq!(opts.len(), 1);
15310 assert!(opts[0].label.contains("Oak"));
15311 }
15312
15313 #[test]
15314 fn market_browse_filters_by_category_and_search() {
15315 let mut state = sample_state();
15316 state.market_panel = Some(flatland_protocol::MarketPanel {
15317 npc_id: "mira_market".into(),
15318 npc_label: "Mira".into(),
15319 building_id: "town_market".into(),
15320 building_label: "Town Market".into(),
15321 used_volume: 0.0,
15322 max_volume: 100.0,
15323 listings: vec![
15324 flatland_protocol::MarketListingView {
15325 listing_id: uuid::Uuid::from_u128(1),
15326 seller_character_id: uuid::Uuid::from_u128(2),
15327 seller_label: "Ada".into(),
15328 hall_building_id: "town_market".into(),
15329 hall_label: "Town Market".into(),
15330 template_id: "oak_log".into(),
15331 display_name: "Oak Log".into(),
15332 category: "resource".into(),
15333 quantity: 3,
15334 unit_price_copper: 10,
15335 line_total_copper: 30,
15336 npc_price: false,
15337 mine: false,
15338 },
15339 flatland_protocol::MarketListingView {
15340 listing_id: uuid::Uuid::from_u128(3),
15341 seller_character_id: uuid::Uuid::from_u128(2),
15342 seller_label: "Ada".into(),
15343 hall_building_id: "town_market".into(),
15344 hall_label: "Town Market".into(),
15345 template_id: "short_sword".into(),
15346 display_name: "Short Sword".into(),
15347 category: "weapon".into(),
15348 quantity: 1,
15349 unit_price_copper: 100,
15350 line_total_copper: 100,
15351 npc_price: false,
15352 mine: false,
15353 },
15354 ],
15355 tax_bps: 0,
15356 tax_flat_copper: 0,
15357 list_vaults: vec![],
15358 });
15359 assert_eq!(state.market_filtered_listing_indices().len(), 2);
15360 state.market_category_filter = Some("Weapons");
15361 let weapons = state.market_filtered_listing_indices();
15362 assert_eq!(weapons.len(), 1);
15363 assert_eq!(
15364 state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
15365 "Short Sword"
15366 );
15367 state.market_category_filter = None;
15368 state.market_filter = "oak".into();
15369 let oak = state.market_filtered_listing_indices();
15370 assert_eq!(oak.len(), 1);
15371 assert_eq!(
15372 state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
15373 "Oak Log"
15374 );
15375 }
15376
15377 #[test]
15378 fn market_list_source_includes_person_and_vaults() {
15379 let mut state = sample_state();
15380 let item_id = uuid::Uuid::from_u128(1);
15381 state.inventory_stacks = vec![flatland_protocol::ItemStack {
15382 template_id: "oak_log".into(),
15383 quantity: 2,
15384 item_instance_id: Some(item_id),
15385 display_name: Some("Oak Log".into()),
15386 ..Default::default()
15387 }];
15388 state.market_panel = Some(flatland_protocol::MarketPanel {
15389 npc_id: "mira_market".into(),
15390 npc_label: "Mira".into(),
15391 building_id: "town_market".into(),
15392 building_label: "Town Market".into(),
15393 used_volume: 0.0,
15394 max_volume: 100.0,
15395 listings: vec![],
15396 tax_bps: 0,
15397 tax_flat_copper: 0,
15398 list_vaults: vec![flatland_protocol::MarketListVault {
15399 building_id: "town_storage".into(),
15400 building_label: "Town Storage".into(),
15401 contents: vec![flatland_protocol::ItemStack {
15402 template_id: "lumber".into(),
15403 quantity: 1,
15404 item_instance_id: Some(uuid::Uuid::from_u128(2)),
15405 display_name: Some("Lumber".into()),
15406 ..Default::default()
15407 }],
15408 }],
15409 });
15410 let sources = state.market_list_source_options();
15411 assert_eq!(sources.len(), 2);
15412 assert!(matches!(sources[0].0, MarketListSourceKind::Person));
15413 assert!(matches!(
15414 sources[1].0,
15415 MarketListSourceKind::TownStorage { .. }
15416 ));
15417 assert!(sources[1].1.contains("Town Storage"));
15418 }
15419
15420 #[test]
15421 fn probe_use_world_npc_beats_nearby_loot() {
15422 let mut state = sample_state();
15423 state.npcs.push(flatland_protocol::NpcView {
15424 id: "ada".into(),
15425 label: "Ada".into(),
15426 role: "broker".into(),
15427 x: 129.0,
15428 y: 128.0,
15429 building_id: None,
15430 entity_id: None,
15431 life_state: None,
15432 hp_pct: None,
15433 can_trade: true,
15434 tile_id: None,
15435 behavior_state: None,
15436 presentation_state: None,
15437 sprite_mode: None,
15438 paperdoll_ref: None,
15439 draw_scale: 1.0,
15440 });
15441 state.ground_drops.push(flatland_protocol::GroundDropView {
15442 id: "d1".into(),
15443 template_id: "lumber".into(),
15444 quantity: 1,
15445 x: 128.5,
15446 y: 128.0,
15447 z: 0.0,
15448 tile_id: None,
15449 display_name: None,
15450 yaw: 0.0,
15451 pitch: 0.0,
15452 roll: 0.0,
15453 draw_scale: 1.0,
15454 });
15455 let probe = state.probe_use_world();
15456 let primary = probe.primary.expect("primary");
15457 assert_eq!(primary.kind, crate::UseWorldKind::Npc);
15458 assert_eq!(primary.id, "ada");
15459 }
15460
15461 #[test]
15462 fn probe_use_world_harvest_when_in_range() {
15463 let state = sample_state(); let probe = state.probe_use_world();
15465 assert!(
15466 probe.primary.is_none(),
15467 "oak is 2m away, out of harvest range"
15468 );
15469 assert!(probe
15470 .candidates
15471 .iter()
15472 .any(|c| c.kind == crate::UseWorldKind::Harvest));
15473
15474 let mut state = sample_state();
15475 state.resource_nodes[0].x = 129.0;
15476 let probe = state.probe_use_world();
15477 let primary = probe.primary.expect("primary");
15478 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
15479 }
15480
15481 #[test]
15482 fn probe_use_world_door_uses_building_label() {
15483 let mut state = sample_state();
15484 state.doors[0].x = 129.0;
15485 state.doors[0].y = 128.0;
15486 let probe = state.probe_use_world();
15487 let primary = probe.primary.expect("primary");
15488 assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
15489 assert_eq!(primary.label, "Broker");
15490 assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
15491 }
15492
15493 #[test]
15494 fn empty_entity_tick_preserves_welcome_snapshot() {
15495 let mut state = sample_state();
15496 state.inventory.insert("carrot".into(), 3);
15497 let delta = TickDelta {
15498 tick: 1,
15499 entities: vec![],
15500 resource_nodes: vec![],
15501 ground_drops: vec![],
15502 placed_containers: vec![],
15503 buildings: vec![],
15504 doors: vec![],
15505 interior_map: None,
15506 npcs: vec![],
15507 inventory: vec![],
15508 blueprints: vec![],
15509 building_materials: vec![],
15510 world_clock: flatland_protocol::WorldClock::default(),
15511 combat: None,
15512 quest_log: vec![],
15513 hired_workers: Vec::new(),
15514 interactables: vec![],
15515 ledger: None,
15516 career: None,
15517 combat_fx: Vec::new(),
15518 property_plots: Vec::new(),
15519 terrain_overlays: Vec::new(),
15520 };
15521
15522 state.apply_tick_fields(&delta, 1);
15523
15524 assert_eq!(state.entities.len(), 1);
15525 assert!(state.player.is_some());
15526 assert_eq!(state.inventory.get("carrot"), Some(&3));
15527 assert_eq!(state.resource_nodes.len(), 1);
15528 }
15529
15530 #[test]
15531 fn tick_preserves_world_layers_when_delta_omits_them() {
15532 let mut state = sample_state();
15533 let delta = TickDelta {
15534 tick: 1,
15535 entities: state.entities.clone(),
15536 resource_nodes: vec![],
15537 ground_drops: vec![],
15538 placed_containers: vec![],
15539 buildings: vec![],
15540 doors: vec![],
15541 interior_map: None,
15542 npcs: vec![],
15543 inventory: vec![],
15544 blueprints: vec![],
15545 building_materials: vec![],
15546 world_clock: flatland_protocol::WorldClock::default(),
15547 combat: None,
15548 quest_log: vec![],
15549 hired_workers: Vec::new(),
15550 interactables: vec![],
15551 ledger: None,
15552 career: None,
15553 combat_fx: Vec::new(),
15554 property_plots: Vec::new(),
15555 terrain_overlays: Vec::new(),
15556 };
15557
15558 state.apply_tick_fields(&delta, 1);
15559
15560 assert_eq!(state.resource_nodes.len(), 1);
15561 assert_eq!(state.buildings.len(), 1);
15562 assert_eq!(state.doors.len(), 1);
15563 }
15564
15565 #[test]
15566 fn tick_updates_resource_nodes_when_server_sends_them() {
15567 let mut state = sample_state();
15568 let delta = TickDelta {
15569 tick: 1,
15570 entities: state.entities.clone(),
15571 resource_nodes: vec![ResourceNodeView {
15572 id: "oak-1".into(),
15573 label: "Oak".into(),
15574 x: 130.0,
15575 y: 128.0,
15576 z: 0.0,
15577 item_template: "oak_log".into(),
15578 state: ResourceNodeState::Cooldown,
15579 blocking: true,
15580 blocking_radius_m: 0.8,
15581 harvest_off: false,
15582 tile_id: None,
15583 yaw: 0.0,
15584 pitch: 0.0,
15585 roll: 0.0,
15586 draw_scale: 1.0,
15587 sprite_mode: None,
15588 growth_progress: None,
15589 presentation_state: None,
15590 channel_start_tick: None,
15591 channel_end_tick: None,
15592 harvest_drop_templates: vec![],
15593 }],
15594 buildings: vec![],
15595 doors: vec![],
15596 interior_map: None,
15597 npcs: vec![],
15598 inventory: vec![],
15599 blueprints: vec![],
15600 building_materials: vec![],
15601 world_clock: flatland_protocol::WorldClock::default(),
15602 ground_drops: vec![],
15603 placed_containers: vec![],
15604 combat: None,
15605 quest_log: vec![],
15606 hired_workers: Vec::new(),
15607 interactables: vec![],
15608 ledger: None,
15609 career: None,
15610 combat_fx: Vec::new(),
15611 property_plots: Vec::new(),
15612 terrain_overlays: Vec::new(),
15613 };
15614
15615 state.apply_tick_fields(&delta, 1);
15616
15617 assert!(matches!(
15618 state.resource_nodes[0].state,
15619 ResourceNodeState::Cooldown
15620 ));
15621 }
15622
15623 #[test]
15624 fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
15625 let mut state = GameState {
15626 session_id: 1,
15627 entity_id: 1,
15628 character_id: None,
15629 tick: 0,
15630 chunk_rev: 0,
15631 content_rev: 0,
15632 publish_rev: 0,
15633 entities: vec![EntityState {
15634 id: 1,
15635 label: "You".into(),
15636 transform: Transform {
15637 position: WorldCoord::surface(4.5, 2.0),
15638 yaw: 0.0,
15639 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
15640 },
15641 vitals: None,
15642 attributes: None,
15643 skills: None,
15644 inside_building: Some("broker_hut".into()),
15645 tile_id: None,
15646 paperdoll_ref: None,
15647 draw_scale: 1.0,
15648 presentation_state: None,
15649 sprite_mode: None,
15650 progression_xp: None,
15651 combat_cues: vec![],
15652 statuses: vec![],
15653 }],
15654 player: None,
15655 resource_nodes: vec![],
15656 ground_drops: vec![],
15657 placed_containers: vec![],
15658 buildings: vec![BuildingView {
15659 id: "broker_hut".into(),
15660 label: "Broker".into(),
15661 x: 158.0,
15662 y: 124.0,
15663 width_m: 8.0,
15664 depth_m: 6.0,
15665 interior_blueprint: Some("broker_hut".into()),
15666 tags: vec![],
15667 market_boundary_zone_ids: vec![],
15668 market_max_volume: None,
15669 wall_set: None,
15670 roof_set: None,
15671 }],
15672 doors: vec![flatland_protocol::DoorView {
15673 id: "broker_hut_exit".into(),
15674 building_id: "broker_hut".into(),
15675 x: 4.3,
15676 y: 0.9,
15677 open: true,
15678 portal: Some("front".into()),
15679 locked: false,
15680 accessible: true,
15681 lock_id: None,
15682 }],
15683 interior_map: None,
15684 npcs: vec![flatland_protocol::NpcView {
15685 id: "ada_broker".into(),
15686 label: "Ada".into(),
15687 x: 4.5,
15688 y: 2.0,
15689 building_id: Some("broker_hut".into()),
15690 role: "broker".into(),
15691 entity_id: None,
15692 life_state: None,
15693 hp_pct: None,
15694 can_trade: true,
15695 tile_id: None,
15696 behavior_state: None,
15697 presentation_state: None,
15698 sprite_mode: None,
15699 paperdoll_ref: None,
15700 draw_scale: 1.0,
15701 }],
15702 blueprints: vec![],
15703 building_materials: vec![],
15704 world_x0: 0.0,
15705 world_y0: 0.0,
15706 world_width_m: 256.0,
15707 world_height_m: 256.0,
15708 terrain_zones: Vec::new(),
15709 z_platforms: Vec::new(),
15710 z_transitions: Vec::new(),
15711 z_bands_outdoor_backup: None,
15712 world_clock: flatland_protocol::WorldClock::default(),
15713 inventory: std::collections::HashMap::new(),
15714 inventory_hints: std::collections::HashMap::new(),
15715 logs: VecDeque::new(),
15716 intents_sent: 0,
15717 ticks_received: 0,
15718 connected: true,
15719 disconnect_reason: None,
15720 show_stats: false,
15721 hud_log_hidden: false,
15722 show_equip_menu: false,
15723 equip_menu_index: 0,
15724 show_craft_menu: false,
15725 show_plot_build_menu: false,
15726 plot_build_focus_wall: true,
15727 plot_build_wall_index: 0,
15728 plot_build_roof_index: 0,
15729 craft_menu_index: 0,
15730 craft_batch_quantity: 1,
15731 show_shop_menu: false,
15732 shop_catalog: None,
15733 bank_panel: None,
15734 bank_menu_index: 0,
15735 bank_ui_mode: BankUiMode::Menu,
15736 storage_panel: None,
15737 market_panel: None,
15738 market_menu_index: 0,
15739 market_filter: String::new(),
15740 market_filter_focused: false,
15741 market_category_filter: None,
15742 market_buy_confirm: None,
15743 market_ui_mode: MarketUiMode::Browse,
15744 storage_menu_index: 0,
15745 storage_ui_mode: StorageUiMode::Menu,
15746 shop_tab: ShopTab::default(),
15747 shop_menu_index: 0,
15748 shop_quantity: 1,
15749 shop_trade_log: VecDeque::new(),
15750 show_npc_verb_menu: false,
15751 npc_verb_target: None,
15752 npc_verb_index: 0,
15753 player_verbs: crate::social::PlayerVerbState::default(),
15754 social_chat: crate::social::SocialChatState::default(),
15755 trade_ui: crate::social::TradeUiState::default(),
15756 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
15757 show_npc_chat: false,
15758 npc_chat: None,
15759 show_inventory_menu: false,
15760 inventory_menu_index: 0,
15761 inventory_tab: InventoryTab::OnPerson,
15762 inventory_filter: String::new(),
15763 inventory_filter_focused: false,
15764 show_move_picker: false,
15765 show_rename_prompt: false,
15766 rename_plot_id: None,
15767 highlighted_plot_id: None,
15768 show_worker_rename: false,
15769 rename_buffer: String::new(),
15770 move_picker_index: 0,
15771 move_picker: None,
15772 show_grant_picker: false,
15773 grant_picker_index: 0,
15774 grant_picker: None,
15775 show_destroy_picker: false,
15776 destroy_confirm_pending: false,
15777 destroy_picker: None,
15778 combat_target: None,
15779 combat_target_label: None,
15780 ground_target: None,
15781 combat_fx: Vec::new(),
15782 property_zones: Vec::new(),
15783 tax_zones: Vec::new(),
15784 growth_zones: Vec::new(),
15785 biome_zones: Vec::new(),
15786 terrain_kind_nav: Vec::new(),
15787 property_plots: Vec::new(),
15788 property_plot_settings: None,
15789 claim_mode: None,
15790 relocate_mode: None,
15791 sell_plot_confirm: None,
15792 sell_plot_armed_at: None,
15793 show_plant_menu: false,
15794 plant_menu_index: 0,
15795 show_farm_access: false,
15796 farm_access_name_draft: String::new(),
15797 farm_access_discount_bps: 0,
15798 farm_access_index: 0,
15799 plant_quantity: 1,
15800 in_combat: false,
15801 auto_attack: true,
15802 combat_has_los: false,
15803 attack_cd_ticks: 0,
15804 gcd_ticks: 0,
15805 weapon_ability_id: "unarmed".into(),
15806 mainhand_template_id: None,
15807 mainhand_label: None,
15808 mainhand_instance_id: None,
15809 offhand_template_id: None,
15810 offhand_label: None,
15811 offhand_instance_id: None,
15812 mainhand_hand_slots: 1,
15813 defense: None,
15814 worn: BTreeMap::new(),
15815 carry_mass: 0.0,
15816 carry_mass_max: 0.0,
15817 encumbrance: flatland_protocol::EncumbranceState::Light,
15818 inventory_stacks: Vec::new(),
15819 keychain_stacks: Vec::new(),
15820 whisper_pouch_stacks: Vec::new(),
15821 combat_target_detail: None,
15822 statuses: Vec::new(),
15823 cast_progress: None,
15824 timed_channel: None,
15825 plot_build_offer: None,
15826 ability_cooldowns: Vec::new(),
15827 blocking_active: false,
15828 max_target_slots: 1,
15829 combat_slots: Vec::new(),
15830 rotation_presets: Vec::new(),
15831 known_abilities: Vec::new(),
15832 ability_meta: std::collections::HashMap::new(),
15833 ability_mastery: std::collections::HashMap::new(),
15834 hotbar: vec![None; 9],
15835 max_abilities_per_rotation: 0,
15836 show_loadout_menu: false,
15837 show_keychain_menu: false,
15838 keychain_menu_index: 0,
15839 show_rotation_editor: false,
15840 loadout_menu_index: 0,
15841 loadout_hotbar_slot: 1,
15842 loadout_ability_index: 0,
15843 loadout_focus_presets: false,
15844 rotation_editor: RotationEditorState::default(),
15845 harvest_in_progress: false,
15846 harvest_started_at: None,
15847 pending_craft_ack: None,
15848 pending_worker_job_ack: None,
15849 attending_worker_instance_id: None,
15850 quest_log: Vec::new(),
15851 interactables: Vec::new(),
15852 ledger: None,
15853 career: None,
15854 character_sheet_tab: CharacterSheetTab::Character,
15855 ledger_period: LedgerPeriod::Day,
15856 show_quest_offer: false,
15857 pending_quest_offer: None,
15858 show_quest_menu: false,
15859 quest_menu_index: 0,
15860 quest_withdraw_confirm: false,
15861 hired_workers: Vec::new(),
15862 show_workers_menu: false,
15863 workers_menu_index: 0,
15864 workers_menu_compact: false,
15865 worker_step_display: BTreeMap::new(),
15866 worker_error_display: BTreeMap::new(),
15867 show_worker_give_picker: false,
15868 worker_give_picker_index: 0,
15869 worker_give_picker: None,
15870 show_worker_give_target_picker: false,
15871 worker_give_target_picker_index: 0,
15872 worker_give_target_picker: None,
15873 show_worker_take_picker: false,
15874 worker_take_picker_index: 0,
15875 worker_take_picker: None,
15876 show_worker_teach_picker: false,
15877 worker_teach_picker_index: 0,
15878 worker_teach_picker: None,
15879 worker_route_editor: None,
15880 progression_curve: None,
15881 };
15882 state.player = state.entities.first().cloned();
15883 assert_eq!(
15884 state.nearest_interact_target().as_deref(),
15885 Some("ada_broker")
15886 );
15887 }
15888
15889 #[test]
15890 fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
15891 let mut state = sample_state();
15892 state.placed_containers = vec![
15895 flatland_protocol::PlacedContainerView {
15896 id: "near".into(),
15897 template_id: "wooden_chest_small".into(),
15898 display_name: "Wooden Chest".into(),
15899 x: 130.0,
15900 y: 128.0,
15901 z: 0.0,
15902 locked: true,
15903 accessible: true,
15904 owner_character_id: None,
15905 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
15906 lock_id: None,
15907 capacity_volume: None,
15908 item_instance_id: Some(uuid::Uuid::from_u128(1)),
15909 tile_id: None,
15910 worker_lodging_capacity: None,
15911 blocking: false,
15912 blocking_radius_m: 0.0,
15913 building_id: None,
15914 },
15915 flatland_protocol::PlacedContainerView {
15916 id: "far".into(),
15917 template_id: "wooden_chest_small".into(),
15918 display_name: "Distant Chest".into(),
15919 x: 128.0 + CONTAINER_RANGE_M + 5.0,
15920 y: 128.0,
15921 z: 0.0,
15922 locked: false,
15923 accessible: true,
15924 owner_character_id: None,
15925 contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
15926 lock_id: None,
15927 capacity_volume: None,
15928 item_instance_id: Some(uuid::Uuid::from_u128(2)),
15929 tile_id: None,
15930 worker_lodging_capacity: None,
15931 blocking: false,
15932 blocking_radius_m: 0.0,
15933 building_id: None,
15934 },
15935 ];
15936
15937 let nearby = state.nearby_containers();
15938 assert_eq!(
15939 nearby.len(),
15940 1,
15941 "far chest must not appear once out of range"
15942 );
15943 assert_eq!(nearby[0].view.id, "near");
15944 assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
15945 assert!(nearby[0].rows[0].is_chest_shell);
15946
15947 state.placed_containers[0].accessible = false;
15950 let nearby = state.nearby_containers();
15951 assert_eq!(nearby.len(), 1);
15952 assert_eq!(nearby[0].rows.len(), 1);
15953 assert!(nearby[0].rows[0].is_chest_shell);
15954 }
15955
15956 #[test]
15957 fn chest_pickup_destinations_offer_person_and_worn_bag() {
15958 let mut state = sample_state();
15959 let back_id = uuid::Uuid::from_u128(42);
15960 state.worn.insert(
15961 BodySlot::Back,
15962 flatland_protocol::ItemStack {
15963 template_id: "travel_backpack".into(),
15964 quantity: 1,
15965 item_instance_id: Some(back_id),
15966 props: Default::default(),
15967 status_bindings: Vec::new(),
15968 contents: Vec::new(),
15969 display_name: Some("Travel Backpack".into()),
15970 category: Some("container".into()),
15971 base_mass: Some(2.5),
15972 base_volume: Some(12.0),
15973 capacity_volume: Some(80.0),
15974 stackable: Some(false),
15975 world_placeable: Some(false),
15976 worker_lodging_capacity: None,
15977 equip_slot: None,
15978 armor_physical: None,
15979 resists: vec![],
15980 hand_slots: None,
15981 listable: None,
15982 },
15983 );
15984 let opts = state.chest_pickup_destinations("chest-1");
15985 assert!(matches!(
15986 opts.first().map(|o| &o.kind),
15987 Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
15988 ));
15989 assert!(opts.iter().any(|o| matches!(
15990 &o.kind,
15991 MoveOptionKind::PickupPlaced {
15992 nest_parent_instance_id: None,
15993 ..
15994 }
15995 )));
15996 assert!(opts.iter().any(|o| matches!(
15997 &o.kind,
15998 MoveOptionKind::PickupPlaced {
15999 nest_parent_instance_id: Some(id),
16000 ..
16001 } if *id == back_id
16002 )));
16003 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
16004 }
16005
16006 #[test]
16007 fn placed_container_public_label_hides_owner_custom_name() {
16008 let owner = uuid::Uuid::from_u128(99);
16009 let mut state = sample_state();
16010 state.character_id = Some(uuid::Uuid::from_u128(1));
16011 state.inventory_hints.insert(
16012 "wooden_chest_medium".into(),
16013 InventoryHint {
16014 display_name: "Medium Wooden Chest".into(),
16015 category: "container".into(),
16016 base_mass: None,
16017 base_volume: None,
16018 capacity_volume: None,
16019 stackable: false,
16020 listable: true,
16021 },
16022 );
16023 let chest = flatland_protocol::PlacedContainerView {
16024 id: "c1".into(),
16025 template_id: "wooden_chest_medium".into(),
16026 display_name: "Barry's Loot #a3f2".into(),
16027 x: 128.0,
16028 y: 128.0,
16029 z: 0.0,
16030 locked: false,
16031 accessible: true,
16032 owner_character_id: Some(owner),
16033 contents: vec![],
16034 lock_id: None,
16035 capacity_volume: None,
16036 item_instance_id: None,
16037 tile_id: None,
16038 worker_lodging_capacity: None,
16039 blocking: false,
16040 blocking_radius_m: 0.0,
16041 building_id: None,
16042 };
16043 assert_eq!(
16044 state.placed_container_public_label(&chest),
16045 "Medium Wooden Chest"
16046 );
16047 state.character_id = Some(owner);
16048 assert_eq!(
16049 state.placed_container_public_label(&chest),
16050 "Barry's Loot #a3f2"
16051 );
16052 }
16053
16054 #[test]
16055 fn location_context_shows_crop_growth_percent_not_depleted() {
16056 let mut state = sample_state();
16057 state.player = state.entities.first().cloned();
16058 state.resource_nodes[0].label = "Carrot (growing)".into();
16059 state.resource_nodes[0].x = 128.2;
16060 state.resource_nodes[0].y = 128.0;
16061 state.resource_nodes[0].state = ResourceNodeState::Cooldown;
16062 state.resource_nodes[0].growth_progress = Some(0.47);
16063 let lines = state.location_context_lines();
16064 let line = lines
16065 .iter()
16066 .find(|l| l.text.contains("Carrot"))
16067 .map(|l| l.text.as_str())
16068 .unwrap_or("");
16069 assert!(
16070 line.contains("(growing, 47%)"),
16071 "expected growth percent, got: {line}"
16072 );
16073 assert!(
16074 !line.contains("depleted"),
16075 "growing crop should not show depleted: {line}"
16076 );
16077 }
16078
16079 #[test]
16080 fn resource_node_near_action_suffix_prefers_growth() {
16081 let node = ResourceNodeView {
16082 id: "crop".into(),
16083 label: "Wheat".into(),
16084 x: 0.0,
16085 y: 0.0,
16086 z: 0.0,
16087 item_template: "wheat".into(),
16088 state: ResourceNodeState::Cooldown,
16089 blocking: false,
16090 blocking_radius_m: 0.0,
16091 harvest_off: false,
16092 tile_id: None,
16093 yaw: 0.0,
16094 pitch: 0.0,
16095 roll: 0.0,
16096 draw_scale: 1.0,
16097 sprite_mode: None,
16098 growth_progress: Some(0.12),
16099 presentation_state: None,
16100 channel_start_tick: None,
16101 channel_end_tick: None,
16102 harvest_drop_templates: vec![],
16103 };
16104 assert_eq!(
16105 resource_node_near_action_suffix(&node),
16106 " (growing, 12%)"
16107 );
16108 }
16109
16110 #[test]
16111 fn location_context_lists_nearby_resource_node() {
16112 let mut state = sample_state();
16113 state.player = state.entities.first().cloned();
16114 state.resource_nodes[0].x = 128.2;
16115 state.resource_nodes[0].y = 128.0;
16116 let lines = state.location_context_lines();
16117 assert!(
16118 lines
16119 .iter()
16120 .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
16121 "expected resource node in context: {:?}",
16122 lines
16123 );
16124 }
16125
16126 #[test]
16127 fn quest_board_usable_within_board_radius() {
16128 let mut state = sample_state();
16129 state.player = state.entities.first().cloned();
16130 state.interactables = vec![flatland_protocol::InteractableView {
16131 id: "board-1".into(),
16132 kind: "quest_board".into(),
16133 label: "Town Quest Board".into(),
16134 x: 130.5,
16135 y: 128.0,
16136 z: 0.0,
16137 board_id: Some("starter_town_board".into()),
16138 }];
16139 assert_eq!(
16141 state.nearest_interact_target().as_deref(),
16142 Some("board-1"),
16143 "quest board should be selectable at ~2.5m"
16144 );
16145 let lines = state.location_context_lines();
16146 assert!(
16147 lines
16148 .iter()
16149 .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
16150 "HUD should advertise f when board is in range: {:?}",
16151 lines
16152 );
16153 }
16154
16155 #[test]
16156 fn inventory_selectable_rows_orders_worn_before_person_on_person_tab() {
16157 let mut state = sample_state();
16158 state.worn.insert(
16159 BodySlot::Back,
16160 flatland_protocol::ItemStack {
16161 template_id: "travel_backpack".into(),
16162 quantity: 1,
16163 item_instance_id: Some(uuid::Uuid::from_u128(3)),
16164 props: Default::default(),
16165 status_bindings: Vec::new(),
16166 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
16167 display_name: None,
16168 category: None,
16169 base_mass: None,
16170 base_volume: None,
16171 capacity_volume: None,
16172 stackable: None,
16173 world_placeable: None,
16174 worker_lodging_capacity: None,
16175 equip_slot: None,
16176 armor_physical: None,
16177 resists: vec![],
16178 hand_slots: None,
16179 listable: None,
16180 },
16181 );
16182 state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
16183 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16184 id: "chest-1".into(),
16185 template_id: "wooden_chest_small".into(),
16186 display_name: "Wooden Chest".into(),
16187 x: 129.0,
16188 y: 128.0,
16189 z: 0.0,
16190 locked: false,
16191 accessible: true,
16192 owner_character_id: None,
16193 contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
16194 lock_id: None,
16195 capacity_volume: None,
16196 item_instance_id: Some(uuid::Uuid::from_u128(4)),
16197 tile_id: None,
16198 worker_lodging_capacity: None,
16199 blocking: false,
16200 blocking_radius_m: 0.0,
16201 building_id: None,
16202 }];
16203
16204 state.inventory_tab = InventoryTab::OnPerson;
16205 let rows = state.inventory_selectable_rows();
16206 let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
16207 assert_eq!(
16208 sections,
16209 vec![
16210 InventorySection::Worn, InventorySection::Worn, InventorySection::Person, ]
16214 );
16215 assert_eq!(rows[0].stack.template_id, "travel_backpack");
16216 assert!(rows[0].is_equip_shell);
16217 assert_eq!(rows[1].stack.template_id, "iron_ore");
16218 assert_eq!(rows[1].depth, 1);
16219 assert_eq!(rows[2].stack.template_id, "lumber");
16220
16221 let lines = state.inventory_browser_lines();
16222 assert!(lines.iter().any(|l| matches!(
16223 l,
16224 InventoryBrowserLine::Section(s) if s.contains("Worn")
16225 )));
16226 assert!(lines.iter().any(|l| matches!(
16227 l,
16228 InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
16229 || text.contains("backpack")
16230 )));
16231 assert!(!lines.iter().any(|l| matches!(
16232 l,
16233 InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
16234 )));
16235
16236 state.inventory_tab = InventoryTab::Nearby;
16237 let nearby_rows = state.inventory_selectable_rows();
16238 assert_eq!(nearby_rows.len(), 2);
16239 assert!(nearby_rows[0].is_chest_shell);
16240 assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
16241 let nearby_lines = state.inventory_browser_lines();
16242 assert!(nearby_lines.iter().any(|l| matches!(
16243 l,
16244 InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
16245 )));
16246 }
16247
16248 #[test]
16249 fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
16250 let mut state = sample_state();
16251 let back_id = uuid::Uuid::from_u128(5);
16252 state.worn.insert(
16253 BodySlot::Back,
16254 flatland_protocol::ItemStack {
16255 template_id: "travel_backpack".into(),
16256 quantity: 1,
16257 item_instance_id: Some(back_id),
16258 props: Default::default(),
16259 status_bindings: Vec::new(),
16260 contents: Vec::new(),
16261 display_name: None,
16262 category: Some("container".into()),
16263 base_mass: None,
16264 base_volume: None,
16265 capacity_volume: Some(80.0),
16266 stackable: None,
16267 world_placeable: None,
16268 worker_lodging_capacity: None,
16269 equip_slot: None,
16270 armor_physical: None,
16271 resists: vec![],
16272 hand_slots: None,
16273 listable: None,
16274 },
16275 );
16276 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16277 id: "chest-1".into(),
16278 template_id: "wooden_chest_small".into(),
16279 display_name: "Wooden Chest".into(),
16280 x: 129.0,
16281 y: 128.0,
16282 z: 0.0,
16283 locked: false,
16284 accessible: true,
16285 owner_character_id: None,
16286 contents: Vec::new(),
16287 lock_id: None,
16288 capacity_volume: None,
16289 item_instance_id: Some(uuid::Uuid::from_u128(6)),
16290 tile_id: None,
16291 worker_lodging_capacity: None,
16292 blocking: false,
16293 blocking_radius_m: 0.0,
16294 building_id: None,
16295 }];
16296
16297 let opts = state.move_destinations_for(
16300 &flatland_protocol::InventoryLocation::Root,
16301 None,
16302 None,
16303 "lumber",
16304 );
16305 assert!(!opts.iter().any(|o| matches!(
16306 &o.kind,
16307 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
16308 )));
16309 assert!(opts.iter().any(|o| matches!(
16310 &o.kind,
16311 MoveOptionKind::Move { location, parent_instance_id, .. }
16312 if *location == flatland_protocol::InventoryLocation::Worn {
16313 slot: BodySlot::Back,
16314 } && *parent_instance_id == Some(back_id)
16315 )));
16316 assert!(opts.iter().any(|o| matches!(
16317 &o.kind,
16318 MoveOptionKind::Move { location, .. }
16319 if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
16320 )));
16321 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
16322 assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
16323
16324 let from_backpack = flatland_protocol::InventoryLocation::Worn {
16328 slot: BodySlot::Back,
16329 };
16330 let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
16331 assert!(!opts.iter().any(|o| matches!(
16332 &o.kind,
16333 MoveOptionKind::Move { location, parent_instance_id, .. }
16334 if *location == from_backpack && *parent_instance_id == Some(back_id)
16335 )));
16336 assert!(opts.iter().any(|o| matches!(
16337 &o.kind,
16338 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
16339 )));
16340 }
16341
16342 #[test]
16343 fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
16344 let mut state = sample_state();
16345 state.worn.insert(
16348 BodySlot::Waist,
16349 flatland_protocol::ItemStack {
16350 template_id: "simple_belt".into(),
16351 quantity: 1,
16352 item_instance_id: Some(uuid::Uuid::from_u128(10)),
16353 props: Default::default(),
16354 status_bindings: Vec::new(),
16355 contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
16356 display_name: None,
16357 category: Some("container".into()),
16358 base_mass: None,
16359 base_volume: None,
16360 capacity_volume: None,
16361 stackable: None,
16362 world_placeable: None,
16363 worker_lodging_capacity: None,
16364 equip_slot: None,
16365 armor_physical: None,
16366 resists: vec![],
16367 hand_slots: None,
16368 listable: None,
16369 },
16370 );
16371 state.worn.insert(
16372 BodySlot::Head,
16373 flatland_protocol::ItemStack {
16374 template_id: "cloth_cap".into(),
16375 quantity: 1,
16376 item_instance_id: Some(uuid::Uuid::from_u128(11)),
16377 props: Default::default(),
16378 status_bindings: Vec::new(),
16379 contents: Vec::new(),
16380 display_name: None,
16381 category: Some("armor".into()),
16382 base_mass: None,
16383 base_volume: None,
16384 capacity_volume: None,
16385 stackable: None,
16386 world_placeable: None,
16387 worker_lodging_capacity: None,
16388 equip_slot: None,
16389 armor_physical: None,
16390 resists: vec![],
16391 hand_slots: None,
16392 listable: None,
16393 },
16394 );
16395 state.worn.insert(
16396 BodySlot::Back,
16397 flatland_protocol::ItemStack {
16398 template_id: "travel_backpack".into(),
16399 quantity: 1,
16400 item_instance_id: Some(uuid::Uuid::from_u128(12)),
16401 props: Default::default(),
16402 status_bindings: Vec::new(),
16403 contents: Vec::new(),
16404 display_name: None,
16405 category: Some("container".into()),
16406 base_mass: None,
16407 base_volume: None,
16408 capacity_volume: None,
16409 stackable: None,
16410 world_placeable: None,
16411 worker_lodging_capacity: None,
16412 equip_slot: None,
16413 armor_physical: None,
16414 resists: vec![],
16415 hand_slots: None,
16416 listable: None,
16417 },
16418 );
16419
16420 let rows = state.worn_rows();
16421 assert_eq!(rows.len(), 4);
16423 assert_eq!(rows[0].stack.template_id, "cloth_cap");
16424 assert!(rows[0].is_equip_shell);
16425 assert_eq!(rows[1].stack.template_id, "travel_backpack");
16426 assert!(rows[1].is_equip_shell);
16427 assert_eq!(rows[2].stack.template_id, "simple_belt");
16428 assert!(rows[2].is_equip_shell);
16429 assert_eq!(rows[3].stack.template_id, "leather_pouch");
16430 assert_eq!(rows[3].depth, 1);
16431 assert!(!rows[3].is_equip_shell);
16432 }
16433
16434 #[test]
16435 fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
16436 let mut state = sample_state();
16437 state.worn.insert(
16438 BodySlot::Waist,
16439 flatland_protocol::ItemStack {
16440 template_id: "simple_belt".into(),
16441 quantity: 1,
16442 item_instance_id: Some(uuid::Uuid::from_u128(20)),
16443 props: Default::default(),
16444 status_bindings: Vec::new(),
16445 contents: Vec::new(),
16446 display_name: Some("Simple Belt".into()),
16447 category: Some("container".into()),
16448 base_mass: None,
16449 base_volume: None,
16450 capacity_volume: None,
16451 stackable: None,
16452 world_placeable: None,
16453 worker_lodging_capacity: None,
16454 equip_slot: None,
16455 armor_physical: None,
16456 resists: vec![],
16457 hand_slots: None,
16458 listable: None,
16459 },
16460 );
16461 state.worn.insert(
16462 BodySlot::Head,
16463 flatland_protocol::ItemStack {
16464 template_id: "cloth_cap".into(),
16465 quantity: 1,
16466 item_instance_id: Some(uuid::Uuid::from_u128(21)),
16467 props: Default::default(),
16468 status_bindings: Vec::new(),
16469 contents: Vec::new(),
16470 display_name: Some("Cloth Cap".into()),
16471 category: Some("armor".into()),
16472 base_mass: None,
16473 base_volume: None,
16474 capacity_volume: None,
16475 stackable: None,
16476 world_placeable: None,
16477 worker_lodging_capacity: None,
16478 equip_slot: None,
16479 armor_physical: None,
16480 resists: vec![],
16481 hand_slots: None,
16482 listable: None,
16483 },
16484 );
16485
16486 let opts = state.move_destinations_for(
16487 &flatland_protocol::InventoryLocation::Root,
16488 None,
16489 None,
16490 "leather_pouch",
16491 );
16492 assert!(
16493 opts.iter().any(|o| matches!(
16494 &o.kind,
16495 MoveOptionKind::Move { location, .. }
16496 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
16497 )),
16498 "belt loop must be offered when moving a pouch"
16499 );
16500 assert!(
16501 !opts.iter().any(|o| matches!(
16502 &o.kind,
16503 MoveOptionKind::Move { location, .. }
16504 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
16505 )),
16506 "armor slots can't hold other items and must not appear as move destinations"
16507 );
16508 let belt_opt = opts
16509 .iter()
16510 .find(|o| matches!(
16511 &o.kind,
16512 MoveOptionKind::Move { location, .. }
16513 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
16514 ))
16515 .unwrap();
16516 assert!(belt_opt.label.contains("belt loop"));
16517
16518 let opts = state.move_destinations_for(
16519 &flatland_protocol::InventoryLocation::Root,
16520 None,
16521 None,
16522 "lumber",
16523 );
16524 assert!(
16525 !opts.iter().any(|o| o.label.contains("belt loop")),
16526 "loose materials must not target the belt shell — only nested pouches"
16527 );
16528 }
16529
16530 #[test]
16531 fn move_destinations_for_offers_dimensional_pouch_on_belt() {
16532 let mut state = sample_state();
16533 let belt_id = uuid::Uuid::from_u128(30);
16534 let pouch_id = uuid::Uuid::from_u128(31);
16535 state.worn.insert(
16536 BodySlot::Waist,
16537 flatland_protocol::ItemStack {
16538 template_id: "simple_belt".into(),
16539 quantity: 1,
16540 item_instance_id: Some(belt_id),
16541 props: Default::default(),
16542 status_bindings: Vec::new(),
16543 world_placeable: None,
16544 worker_lodging_capacity: None,
16545 equip_slot: None,
16546 armor_physical: None,
16547 resists: vec![],
16548 hand_slots: None,
16549 contents: vec![flatland_protocol::ItemStack {
16550 template_id: "dimensional_pouch".into(),
16551 quantity: 1,
16552 item_instance_id: Some(pouch_id),
16553 props: Default::default(),
16554 status_bindings: Vec::new(),
16555 contents: Vec::new(),
16556 display_name: Some("Dimensional Pouch".into()),
16557 category: Some("container".into()),
16558 base_mass: None,
16559 base_volume: None,
16560 capacity_volume: Some(200.0),
16561 stackable: None,
16562 world_placeable: None,
16563 worker_lodging_capacity: None,
16564 equip_slot: None,
16565 armor_physical: None,
16566 resists: vec![],
16567 hand_slots: None,
16568 listable: None,
16569 }],
16570 display_name: Some("Simple Belt".into()),
16571 category: Some("container".into()),
16572 base_mass: None,
16573 base_volume: None,
16574 capacity_volume: None,
16575 stackable: None,
16576 listable: None,
16577 },
16578 );
16579
16580 let opts = state.move_destinations_for(
16581 &flatland_protocol::InventoryLocation::Root,
16582 None,
16583 None,
16584 "iron_ore",
16585 );
16586 assert!(
16587 opts.iter().any(|o| matches!(
16588 &o.kind,
16589 MoveOptionKind::Move {
16590 location,
16591 parent_instance_id,
16592 ..
16593 } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
16594 && *parent_instance_id == Some(pouch_id)
16595 )),
16596 "dimensional pouch clipped on belt must accept loose items"
16597 );
16598 assert!(
16599 opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
16600 "destination label should name the pouch"
16601 );
16602 }
16603
16604 #[test]
16605 fn container_volume_label_on_placed_chest_shell() {
16606 let mut state = sample_state();
16607 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16608 id: "chest-1".into(),
16609 template_id: "wooden_chest_small".into(),
16610 display_name: "Camp Chest".into(),
16611 x: 129.0,
16612 y: 128.0,
16613 z: 0.0,
16614 locked: false,
16615 accessible: true,
16616 owner_character_id: None,
16617 contents: vec![flatland_protocol::ItemStack {
16618 template_id: "iron_ore".into(),
16619 quantity: 2,
16620 item_instance_id: None,
16621 props: Default::default(),
16622 status_bindings: Vec::new(),
16623 contents: Vec::new(),
16624 display_name: None,
16625 category: None,
16626 base_mass: None,
16627 base_volume: Some(2.0),
16628 capacity_volume: None,
16629 stackable: None,
16630 world_placeable: None,
16631 worker_lodging_capacity: None,
16632 equip_slot: None,
16633 armor_physical: None,
16634 resists: vec![],
16635 hand_slots: None,
16636 listable: None,
16637 }],
16638 lock_id: None,
16639 capacity_volume: Some(60.0),
16640 item_instance_id: Some(uuid::Uuid::from_u128(4)),
16641 tile_id: None,
16642 worker_lodging_capacity: None,
16643 blocking: false,
16644 blocking_radius_m: 0.0,
16645 building_id: None,
16646 }];
16647 let nearby = state.nearby_containers();
16648 let label = state.container_volume_label(&nearby[0].rows[0]);
16649 assert!(
16650 label.contains("vol 4/60"),
16651 "expected used/cap in label, got {label}"
16652 );
16653 assert!(
16654 label.contains("56 free"),
16655 "expected free space, got {label}"
16656 );
16657 }
16658
16659 #[test]
16660 fn key_pair_chest_label_from_placed_lock_id() {
16661 let mut state = sample_state();
16662 let owner = uuid::Uuid::from_u128(77);
16663 state.character_id = Some(owner);
16664 let lock = uuid::Uuid::from_u128(99).to_string();
16665 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16666 id: "chest-1".into(),
16667 template_id: "wooden_chest_small".into(),
16668 display_name: "Barry's Loot #a3f2".into(),
16669 x: 129.0,
16670 y: 128.0,
16671 z: 0.0,
16672 locked: true,
16673 accessible: true,
16674 owner_character_id: Some(owner),
16675 contents: Vec::new(),
16676 lock_id: Some(lock.clone()),
16677 capacity_volume: None,
16678 item_instance_id: Some(uuid::Uuid::from_u128(4)),
16679 tile_id: None,
16680 worker_lodging_capacity: None,
16681 blocking: false,
16682 blocking_radius_m: 0.0,
16683 building_id: None,
16684 }];
16685 let key_id = uuid::Uuid::from_u128(5);
16686 let key = flatland_protocol::ItemStack {
16687 template_id: KEY_TEMPLATE.into(),
16688 quantity: 1,
16689 item_instance_id: Some(key_id),
16690 props: BTreeMap::from([
16691 (PROP_OPENS_LOCK_ID.into(), lock),
16692 (
16693 PROP_OPENS_CONTAINER_NAME.into(),
16694 "Barry's Loot #a3f2".into(),
16695 ),
16696 ]),
16697 status_bindings: Vec::new(),
16698 contents: Vec::new(),
16699 display_name: Some("Container Key".into()),
16700 category: Some("key".into()),
16701 base_mass: None,
16702 base_volume: None,
16703 capacity_volume: None,
16704 stackable: None,
16705 world_placeable: None,
16706 worker_lodging_capacity: None,
16707 equip_slot: None,
16708 armor_physical: None,
16709 resists: vec![],
16710 hand_slots: None,
16711 listable: None,
16712 };
16713 state.inventory_stacks = vec![key.clone()];
16714 assert_eq!(
16715 state.key_pair_chest_label(&key).as_deref(),
16716 Some("Barry's Loot #a3f2")
16717 );
16718 assert!(state.key_drop_blocked(&key));
16719 }
16720
16721 #[test]
16722 fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
16723 let mut state = sample_state();
16724 let lock = uuid::Uuid::from_u128(101).to_string();
16725 let key = flatland_protocol::ItemStack {
16726 template_id: KEY_TEMPLATE.into(),
16727 quantity: 1,
16728 item_instance_id: Some(uuid::Uuid::from_u128(7)),
16729 props: BTreeMap::from([
16730 (PROP_OPENS_LOCK_ID.into(), lock),
16731 (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
16732 ]),
16733 status_bindings: Vec::new(),
16734 contents: Vec::new(),
16735 display_name: None,
16736 category: Some("key".into()),
16737 base_mass: None,
16738 base_volume: None,
16739 capacity_volume: None,
16740 stackable: None,
16741 world_placeable: None,
16742 worker_lodging_capacity: None,
16743 equip_slot: None,
16744 armor_physical: None,
16745 resists: vec![],
16746 hand_slots: None,
16747 listable: None,
16748 };
16749 state.placed_containers.clear();
16750 assert_eq!(
16751 state.key_pair_chest_label(&key).as_deref(),
16752 Some("Camp Stash")
16753 );
16754 }
16755
16756 #[test]
16757 fn key_drop_allowed_when_paired_chest_unlocked() {
16758 let mut state = sample_state();
16759 let lock = uuid::Uuid::from_u128(100).to_string();
16760 let key_id = uuid::Uuid::from_u128(6);
16761 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16762 id: "chest-1".into(),
16763 template_id: "wooden_chest_small".into(),
16764 display_name: "Camp Chest".into(),
16765 x: 129.0,
16766 y: 128.0,
16767 z: 0.0,
16768 locked: false,
16769 accessible: true,
16770 owner_character_id: None,
16771 contents: Vec::new(),
16772 lock_id: Some(lock.clone()),
16773 capacity_volume: None,
16774 item_instance_id: None,
16775 tile_id: None,
16776 worker_lodging_capacity: None,
16777 blocking: false,
16778 blocking_radius_m: 0.0,
16779 building_id: None,
16780 }];
16781 let key = flatland_protocol::ItemStack {
16782 template_id: KEY_TEMPLATE.into(),
16783 quantity: 1,
16784 item_instance_id: Some(key_id),
16785 props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
16786 status_bindings: Vec::new(),
16787 contents: Vec::new(),
16788 display_name: None,
16789 category: Some("key".into()),
16790 base_mass: None,
16791 base_volume: None,
16792 capacity_volume: None,
16793 stackable: None,
16794 world_placeable: None,
16795 worker_lodging_capacity: None,
16796 equip_slot: None,
16797 armor_physical: None,
16798 resists: vec![],
16799 hand_slots: None,
16800 listable: None,
16801 };
16802 state.inventory_stacks = vec![key.clone()];
16803 assert!(!state.key_drop_blocked(&key));
16804 let opts = state.move_destinations_for(
16805 &flatland_protocol::InventoryLocation::Root,
16806 None,
16807 Some(key_id),
16808 KEY_TEMPLATE,
16809 );
16810 assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
16811 }
16812
16813 #[test]
16814 fn combat_hud_refreshes_progression_xp_when_entity_stale() {
16815 use flatland_protocol::{CombatHud, ProgressionXp, ProgressionCurve};
16816
16817 let mut state = sample_state();
16818 let curve = ProgressionCurve::default();
16819 let bootstrap = ProgressionXp::bootstrap_new(
16820 curve.baseline_display,
16821 curve.xp_base,
16822 curve.xp_growth,
16823 );
16824 let mut fresh = bootstrap.clone();
16825 fresh.strength += 0.08;
16826 if let Some(player) = state.player.as_mut() {
16827 player.progression_xp = Some(bootstrap);
16828 }
16829
16830 let combat = CombatHud {
16831 progression_xp: Some(fresh.clone()),
16832 progression_baseline: curve.baseline_display,
16833 progression_xp_base: curve.xp_base,
16834 progression_xp_growth: curve.xp_growth,
16835 attributes: state.player.as_ref().and_then(|p| p.attributes),
16836 skills: state.player.as_ref().and_then(|p| p.skills.clone()),
16837 ..CombatHud::default()
16838 };
16839 state.apply_combat_hud(&combat);
16840
16841 let xp = state
16842 .player
16843 .as_ref()
16844 .and_then(|p| p.progression_xp.as_ref())
16845 .expect("xp");
16846 assert!((xp.strength - fresh.strength).abs() < 0.001);
16847 assert!(state.progression_curve.is_some());
16848 }
16849
16850 #[test]
16851 fn combat_hud_syncs_known_abilities_and_hotbar() {
16852 use flatland_protocol::CombatHud;
16853
16854 let mut state = sample_state();
16855 let combat = CombatHud {
16856 known_abilities: vec!["unarmed".into(), "fireball".into()],
16857 hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
16858 max_abilities_per_rotation: 4,
16859 ability_id: "short_sword_slash".into(),
16860 ..CombatHud::default()
16861 };
16862 state.apply_combat_hud(&combat);
16863
16864 assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
16865 assert_eq!(state.hotbar_ability(1), Some("fireball"));
16866 assert_eq!(state.hotbar_ability(2), None);
16867 assert_eq!(state.hotbar_ability(3), Some("unarmed"));
16868 assert_eq!(state.max_abilities_per_rotation, 4);
16869 let choices = state.loadout_ability_choices();
16870 assert!(choices.iter().any(|a| a == "short_sword_slash"));
16871 assert!(choices.iter().any(|a| a == "fireball"));
16872 }
16873
16874 #[test]
16875 fn loadout_hotbar_choices_include_inventory_consumables() {
16876 let mut state = sample_state();
16877 state.known_abilities = vec!["unarmed".into()];
16878 state.weapon_ability_id = "unarmed".into();
16879 state.inventory_stacks = vec![flatland_protocol::ItemStack {
16880 template_id: "bottle_of_water".into(),
16881 quantity: 3,
16882 item_instance_id: Some(uuid::Uuid::from_u128(9)),
16883 display_name: Some("Bottle of Water".into()),
16884 category: Some("consumable".into()),
16885 ..Default::default()
16886 }];
16887 state.inventory.insert("bottle_of_water".into(), 3);
16888 state.inventory_hints.insert(
16889 "bottle_of_water".into(),
16890 InventoryHint {
16891 display_name: "Bottle of Water".into(),
16892 category: "consumable".into(),
16893 ..Default::default()
16894 },
16895 );
16896
16897 let choices = state.loadout_hotbar_choices();
16898 assert!(choices.iter().any(|c| c.binding == "unarmed"));
16899 let water = choices
16900 .iter()
16901 .find(|c| c.binding == "item:bottle_of_water")
16902 .expect("water binding");
16903 assert_eq!(water.meta.as_deref(), Some("use"));
16904 assert!(water.label.contains("Water"));
16905 assert_eq!(
16906 state.hotbar_slot_label(1),
16907 None,
16908 "unbound until set"
16909 );
16910 state.hotbar = vec![None, None, None, None, Some("item:bottle_of_water".into())];
16911 assert_eq!(
16912 state.hotbar_slot_label(5).as_deref(),
16913 Some("Bottle of Water×3")
16914 );
16915 }
16916
16917 #[test]
16918 fn storage_store_options_excludes_hand_equipped() {
16919 let mut state = sample_state();
16920 let sword_id = uuid::Uuid::from_u128(11);
16921 let ore_id = uuid::Uuid::from_u128(22);
16922 state.inventory_stacks = vec![
16923 flatland_protocol::ItemStack {
16924 template_id: "short_sword".into(),
16925 quantity: 1,
16926 item_instance_id: Some(sword_id),
16927 display_name: Some("Short Sword".into()),
16928 category: Some("weapon".into()),
16929 ..Default::default()
16930 },
16931 flatland_protocol::ItemStack {
16932 template_id: "iron_ore".into(),
16933 quantity: 5,
16934 item_instance_id: Some(ore_id),
16935 display_name: Some("Iron Ore".into()),
16936 category: Some("resource".into()),
16937 ..Default::default()
16938 },
16939 ];
16940 state.mainhand_template_id = Some("short_sword".into());
16941 state.mainhand_instance_id = Some(sword_id);
16942
16943 let opts = state.storage_store_options();
16944 assert_eq!(opts.len(), 1);
16945 assert_eq!(opts[0].item_instance_id, ore_id);
16946 assert!(state.hand_equipped_instance_ids().contains(&sword_id));
16947 }
16948
16949 #[test]
16950 fn loose_consumable_move_picker_offers_use_and_storage() {
16951 let mut state = sample_state();
16952 let inst = uuid::Uuid::from_u128(77);
16953 state.inventory_stacks = vec![flatland_protocol::ItemStack {
16954 template_id: "carrot".into(),
16955 quantity: 2,
16956 item_instance_id: Some(inst),
16957 props: Default::default(),
16958 status_bindings: Vec::new(),
16959 contents: Vec::new(),
16960 display_name: Some("Wild Carrot".into()),
16961 category: Some("consumable".into()),
16962 base_mass: None,
16963 base_volume: None,
16964 capacity_volume: None,
16965 stackable: Some(true),
16966 world_placeable: None,
16967 worker_lodging_capacity: None,
16968 equip_slot: None,
16969 armor_physical: None,
16970 resists: vec![],
16971 hand_slots: None,
16972 listable: None,
16973 }];
16974 state.inventory_hints.insert(
16975 "carrot".into(),
16976 InventoryHint {
16977 display_name: "Wild Carrot".into(),
16978 category: "consumable".into(),
16979 base_mass: Some(0.15),
16980 base_volume: Some(0.3),
16981 capacity_volume: None,
16982 stackable: true,
16983 listable: true,
16984 },
16985 );
16986 state.show_inventory_menu = true;
16987 state.inventory_menu_index = 0;
16988
16989 let row = state.inventory_selected_row().expect("carrot row");
16990 let mut options = state.move_destinations_for(
16991 &row.from,
16992 row.from_parent_instance_id,
16993 row.stack.item_instance_id,
16994 &row.stack.template_id,
16995 );
16996 if row.from == flatland_protocol::InventoryLocation::Root
16997 && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
16998 {
16999 options.insert(
17000 0,
17001 MoveOption {
17002 label: "Use (eat / drink)".into(),
17003 kind: MoveOptionKind::Use,
17004 },
17005 );
17006 }
17007
17008 assert_eq!(options.first().map(|o| &o.label), Some(&"Use (eat / drink)".into()));
17009 assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
17010 assert!(options.iter().any(|o| matches!(o.kind, MoveOptionKind::Drop)));
17011 }
17012
17013 #[test]
17014 fn inventory_category_group_order_is_stable() {
17015 assert_eq!(inventory_category_group("weapon").0, "Weapons");
17016 assert_eq!(inventory_category_group("armor").0, "Armor");
17017 assert_eq!(inventory_category_group("consumable").0, "Consumables");
17018 assert_eq!(inventory_category_group("resource").0, "Resources");
17019 assert_eq!(inventory_category_group("container").0, "Containers");
17020 assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
17021 assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
17022 }
17023
17024 #[test]
17025 fn page_list_index_clamps_without_wrap() {
17026 assert_eq!(page_list_index(0, -1, 25), 0);
17027 assert_eq!(page_list_index(0, 1, 25), 10);
17028 assert_eq!(page_list_index(12, 1, 25), 22);
17029 assert_eq!(page_list_index(22, 1, 25), 24);
17030 assert_eq!(page_list_index(5, 1, 0), 0);
17031 assert_eq!(page_list_index(3, -1, 8), 0);
17032 }
17033
17034 #[test]
17035 fn inventory_filter_hides_non_matching_person_items() {
17036 let mut state = sample_state();
17037 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
17038 sword.display_name = Some("Iron Sword".into());
17039 sword.category = Some("weapon".into());
17040 let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
17041 herb.display_name = Some("Wild Herb".into());
17042 herb.category = Some("consumable".into());
17043 state.inventory_stacks = vec![sword, herb];
17044 state.inventory_tab = InventoryTab::OnPerson;
17045 state.inventory_filter = "sword".into();
17046
17047 let rows = state.inventory_selectable_rows();
17048 assert_eq!(rows.len(), 1);
17049 assert_eq!(rows[0].stack.template_id, "iron_sword");
17050
17051 let lines = state.inventory_browser_lines();
17052 assert!(lines.iter().any(|l| matches!(
17053 l,
17054 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
17055 )));
17056 assert!(!lines.iter().any(|l| matches!(
17057 l,
17058 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
17059 )));
17060 }
17061
17062 #[test]
17063 fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
17064 let mut state = sample_state();
17065 let id_a = uuid::Uuid::from_u128(0xa1);
17066 let id_b = uuid::Uuid::from_u128(0xb2);
17067 let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
17068 sword_a.display_name = Some("Iron Sword".into());
17069 sword_a.category = Some("weapon".into());
17070 sword_a.item_instance_id = Some(id_a);
17071 let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
17072 sword_b.display_name = Some("Iron Sword".into());
17073 sword_b.category = Some("weapon".into());
17074 sword_b.item_instance_id = Some(id_b);
17075 state.inventory_stacks = vec![sword_a, sword_b];
17076 state.inventory_tab = InventoryTab::OnPerson;
17077
17078 let lines = state.inventory_browser_lines();
17079 let items: Vec<_> = lines
17080 .iter()
17081 .filter_map(|l| match l {
17082 InventoryBrowserLine::Item {
17083 title,
17084 instance_tooltip,
17085 ..
17086 } => Some((title.clone(), instance_tooltip.clone())),
17087 _ => None,
17088 })
17089 .collect();
17090 assert_eq!(items.len(), 2);
17091 for (title, tip) in &items {
17092 assert!(
17093 !title.contains('#'),
17094 "title should not show instance suffix: {title}"
17095 );
17096 assert!(
17097 tip.is_some(),
17098 "two identical rows should expose instance on hover"
17099 );
17100 }
17101
17102 state.inventory_stacks.pop();
17103 let lines = state.inventory_browser_lines();
17104 let one = lines.iter().find_map(|l| match l {
17105 InventoryBrowserLine::Item {
17106 title,
17107 instance_tooltip,
17108 ..
17109 } => Some((title.clone(), instance_tooltip.clone())),
17110 _ => None,
17111 });
17112 let (title, tip) = one.expect("one sword row");
17113 assert!(!title.contains('#'));
17114 assert!(tip.is_none(), "single row should not need instance tooltip");
17115 }
17116
17117 #[test]
17118 fn inventory_person_rows_group_by_category() {
17119 let mut state = sample_state();
17120 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
17121 sword.category = Some("weapon".into());
17122 sword.display_name = Some("Iron Sword".into());
17123 let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
17124 ore.category = Some("resource".into());
17125 ore.display_name = Some("Iron Ore".into());
17126 let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
17127 potion.category = Some("consumable".into());
17128 potion.display_name = Some("Health Potion".into());
17129 state.inventory_stacks = vec![ore, potion, sword];
17130 state.inventory_tab = InventoryTab::OnPerson;
17131
17132 let lines = state.inventory_browser_lines();
17133 let labels: Vec<&str> = lines
17134 .iter()
17135 .filter_map(|l| match l {
17136 InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
17137 _ => None,
17138 })
17139 .collect();
17140 assert!(
17141 labels.iter().any(|s| s.contains("Weapons")),
17142 "expected Weapons group: {labels:?}"
17143 );
17144 assert!(labels.iter().any(|s| s.contains("Consumables")));
17145 assert!(labels.iter().any(|s| s.contains("Resources")));
17146
17147 let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
17148 let consumable_pos = labels.iter().position(|s| s.contains("Consumables")).unwrap();
17149 let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
17150 assert!(weapon_pos < consumable_pos);
17151 assert!(consumable_pos < resource_pos);
17152 }
17153
17154 #[test]
17155 fn inventory_tab_cycle_resets_selection() {
17156 let mut state = sample_state();
17157 state.inventory_tab = InventoryTab::OnPerson;
17158 state.inventory_menu_index = 3;
17159 state.inventory_tab = state.inventory_tab.cycle(true);
17160 assert_eq!(state.inventory_tab, InventoryTab::Nearby);
17161 assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
17163 assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
17164 assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
17165 assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
17166 }
17167
17168 #[test]
17169 fn parse_bank_copper_amount_blank_and_zero_mean_all() {
17170 assert_eq!(parse_bank_copper_amount(""), Some(0));
17171 assert_eq!(parse_bank_copper_amount(" "), Some(0));
17172 assert_eq!(parse_bank_copper_amount("0"), Some(0));
17173 assert_eq!(parse_bank_copper_amount("250"), Some(250));
17174 assert_eq!(parse_bank_copper_amount("nope"), None);
17175 }
17176
17177 #[test]
17178 fn parse_storage_quantity_blank_and_zero_mean_all() {
17179 assert_eq!(parse_storage_quantity(""), Some(None));
17180 assert_eq!(parse_storage_quantity(" "), Some(None));
17181 assert_eq!(parse_storage_quantity("0"), Some(None));
17182 assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
17183 assert_eq!(parse_storage_quantity("nope"), None);
17184 }
17185
17186 #[test]
17187 fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
17188 assert!(worker_error_is_hud_noise("path stuck — repathing"));
17189 assert!(worker_error_is_hud_noise("path stuck — nudged clear, repathing"));
17190 assert!(worker_error_is_hud_noise("returned to lodging after path failures"));
17191 assert!(!worker_error_is_hud_noise(
17193 "path stuck — no lodging to reset to"
17194 ));
17195 }
17196
17197 #[test]
17198 fn leaving_building_restores_outdoor_z_bands() {
17199 use flatland_protocol::{InteriorMapView, ZPlatformView};
17200
17201 let mut state = sample_state();
17202 state.z_platforms.clear();
17203 state.z_transitions.clear();
17204 state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
17205 state.interior_map = Some(InteriorMapView {
17206 building_id: "broker_hut".into(),
17207 blueprint_id: "broker_hut".into(),
17208 background_color: "#000".into(),
17209 default_floor_color: None,
17210 floor_height_m: 3.0,
17211 z_platforms: vec![ZPlatformView {
17212 id: "floor_0".into(),
17213 z: 0.0,
17214 x0: 0.0,
17215 y0: 0.0,
17216 x1: 8.0,
17217 y1: 8.0,
17218 }],
17219 z_transitions: vec![],
17220 rooms: vec![],
17221 room_doors: vec![],
17222 });
17223 state.sync_interior_map_context();
17224 assert_eq!(state.z_platforms.len(), 1, "indoors installs interior platforms");
17225 assert!(state.z_bands_outdoor_backup.is_some());
17226
17227 state.player.as_mut().unwrap().inside_building = None;
17228 state.sync_interior_map_context();
17229 assert!(
17230 state.z_platforms.is_empty(),
17231 "leaving must restore outdoor bands (empty), not leave interior platforms"
17232 );
17233 assert!(state.z_bands_outdoor_backup.is_none());
17234 assert!(state.interior_map.is_none());
17235 }
17236
17237 #[test]
17238 fn resource_node_route_label_prefers_friendly_label_with_suffix() {
17239 let node = ResourceNodeView {
17240 id: "crop-carrot-1_copy10".into(),
17241 label: "crop-carrot-1_copy10".into(),
17242 x: 0.0,
17243 y: 0.0,
17244 z: 0.0,
17245 item_template: "carrot".into(),
17246 state: ResourceNodeState::Available,
17247 blocking: false,
17248 blocking_radius_m: 0.5,
17249 harvest_off: false,
17250 tile_id: None,
17251 yaw: 0.0,
17252 pitch: 0.0,
17253 roll: 0.0,
17254 draw_scale: 1.0,
17255 sprite_mode: None,
17256 growth_progress: None,
17257 presentation_state: None,
17258 channel_start_tick: None,
17259 channel_end_tick: None,
17260 harvest_drop_templates: vec![],
17261 };
17262 let label = super::resource_node_route_label(&node);
17263 assert!(label.starts_with("Carrot ("), "got {label}");
17264 assert!(label.ends_with(')'), "got {label}");
17265
17266 let mut named = node;
17267 named.label = "Sweet Pad".into();
17268 named.id = "crop-carrot-a3f2b1c0".into();
17269 assert_eq!(
17270 super::resource_node_route_label(&named),
17271 "Sweet Pad (b1c0)"
17272 );
17273 }
17274
17275 #[test]
17276 fn plot_public_label_uses_owner_zone_and_label() {
17277 let plot = flatland_protocol::PropertyPlotView {
17278 plot_id: uuid::Uuid::nil(),
17279 property_zone_id: "zone_a".into(),
17280 zone_label: Some("Starter Town East 1".into()),
17281 deed_instance_id: uuid::Uuid::nil(),
17282 x0: 0.0,
17283 y0: 0.0,
17284 x1: 4.0,
17285 y1: 4.0,
17286 upkeep_copper_per_day: 1,
17287 arrears_days: 0,
17288 is_mine: true,
17289 may_farm: true,
17290 purchase_basis_copper: 0,
17291 farm_public: false,
17292 public_tax_discount_bps: 0,
17293 farm_allow: vec![],
17294 owner_character_id: None,
17295 owner_label: Some("Madsin".into()),
17296 building_id: None,
17297 plot_code: "xyz1234a".into(),
17298 label: "Food Pad".into(),
17299 };
17300 assert_eq!(
17301 super::plot_public_label(&plot),
17302 "Madsin — Starter Town East 1 — Food Pad"
17303 );
17304 }
17305}