1use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
2use std::time::{Duration, Instant};
3
4use flatland_protocol::{
5 AbilityCooldownHud, BlueprintView, BodySlot, BuildingView, CastProgressHud, CombatHud,
6 CombatSlotHud, CombatTargetHud, DoorView, EntityId, EntityState, Intent, InteriorMapView,
7 LifeState, NpcView, RotationPreset, Seq, SessionId, TerrainKindView, TerrainZoneView, Tick,
8 ZPlatformView, ZTransitionView,
9};
10
11use crate::session::{PlayConnection, SessionEvent};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
14pub enum CharacterSheetTab {
15 #[default]
16 Character,
17 Ledger,
18 Career,
19}
20
21impl CharacterSheetTab {
22 pub fn cycle(self) -> Self {
23 match self {
24 Self::Character => Self::Ledger,
25 Self::Ledger => Self::Career,
26 Self::Career => Self::Character,
27 }
28 }
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum LedgerPeriod {
33 #[default]
34 Day,
35 Week,
36 Month,
37 Lifetime,
38}
39
40impl LedgerPeriod {
41 pub fn label(self) -> &'static str {
42 match self {
43 Self::Day => "Day",
44 Self::Week => "Week",
45 Self::Month => "Month",
46 Self::Lifetime => "All",
47 }
48 }
49
50 pub fn cycle(self) -> Self {
51 match self {
52 Self::Day => Self::Week,
53 Self::Week => Self::Month,
54 Self::Month => Self::Lifetime,
55 Self::Lifetime => Self::Day,
56 }
57 }
58
59 pub fn from_digit(c: char) -> Option<Self> {
60 match c {
61 '1' => Some(Self::Day),
62 '2' => Some(Self::Week),
63 '3' => Some(Self::Month),
64 '4' => Some(Self::Lifetime),
65 _ => None,
66 }
67 }
68}
69
70const KEY_TEMPLATE: &str = "container_key";
72const PROPERTY_DEED_TEMPLATE: &str = "property_deed";
73const PROP_LOCK_ID: &str = "lock_id";
74const PROP_OPENS_LOCK_ID: &str = "opens_lock_id";
75const PROP_OPENS_CONTAINER_NAME: &str = "opens_container_name";
76const PROP_CUSTOM_NAME: &str = "custom_name";
77const PROP_LOCKED: &str = "locked";
78
79#[derive(Debug, Clone, PartialEq)]
81pub struct ClaimModeState {
82 pub zone_id: String,
83 pub width_m: u32,
84 pub height_m: u32,
85 pub anchor_x: f32,
86 pub anchor_y: f32,
87}
88
89#[derive(Debug, Clone, PartialEq)]
91pub struct RelocateModeState {
92 pub container_id: String,
93 pub label: String,
94 pub cursor_x: f32,
95 pub cursor_y: f32,
96}
97
98fn stack_is_locked(stack: &flatland_protocol::ItemStack) -> bool {
99 stack
100 .props
101 .get(PROP_LOCKED)
102 .is_some_and(|v| v == "true" || v == "1")
103}
104
105const MAX_LOG_LINES: usize = 200;
106const MAX_SHOP_TRADE_LOG_LINES: usize = 40;
107const INTERACTION_RADIUS_M: f32 = 1.5;
108const DOOR_INTERACTION_RADIUS_M: f32 = 3.5;
109const QUEST_BOARD_INTERACTION_RADIUS_M: f32 = 3.0;
110const HARVEST_CLIENT_TIMEOUT: Duration = Duration::from_secs(12);
111const CRAFT_STAMINA_COST: f32 = 3.0;
113const WORKER_STEP_HOLD: Duration = Duration::from_millis(1200);
115const WORKER_ERROR_HOLD: Duration = Duration::from_secs(45);
117
118#[derive(Debug, Clone, Default)]
120pub struct InventoryHint {
121 pub display_name: String,
122 pub category: String,
123 pub base_mass: Option<f32>,
124 pub base_volume: Option<f32>,
125 pub capacity_volume: Option<f32>,
126 pub stackable: bool,
127 pub listable: bool,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub struct LoadoutHotbarChoice {
134 pub binding: String,
136 pub label: String,
138 pub meta: Option<String>,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
144pub enum RotationEditorMode {
145 #[default]
146 List,
147 EditSequence,
148 PickAbility,
149 EditLabel,
150}
151
152#[derive(Debug, Clone, Default)]
154pub struct RotationEditorState {
155 pub mode: RotationEditorMode,
156 pub list_index: usize,
157 pub ability_index: usize,
158 pub picker_index: usize,
159 pub draft: Option<RotationPreset>,
160 pub label_buffer: String,
161}
162
163impl RotationEditorState {
164 pub fn reset(&mut self) {
165 *self = Self::default();
166 }
167}
168
169pub const CONTAINER_RANGE_M: f32 = 3.0;
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum InventorySection {
179 Worn,
181 Person,
183 Nearby,
185}
186
187#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
189pub enum InventoryTab {
190 #[default]
191 OnPerson,
192 Nearby,
193}
194
195impl InventoryTab {
196 pub fn label(self) -> &'static str {
197 match self {
198 Self::OnPerson => "On person",
199 Self::Nearby => "Nearby storage",
200 }
201 }
202
203 pub fn cycle(self, forward: bool) -> Self {
204 match (self, forward) {
205 (Self::OnPerson, true) | (Self::OnPerson, false) => Self::Nearby,
206 (Self::Nearby, true) | (Self::Nearby, false) => Self::OnPerson,
207 }
208 }
209}
210
211pub const LIST_PAGE_SIZE: usize = 10;
213
214pub fn list_label_matches(haystack: &str, filter: &str) -> bool {
216 if filter.is_empty() {
217 return true;
218 }
219 haystack
220 .to_ascii_lowercase()
221 .contains(&filter.to_ascii_lowercase())
222}
223
224pub fn page_list_index(index: usize, pages: i32, len: usize) -> usize {
226 if len == 0 {
227 return 0;
228 }
229 let page = LIST_PAGE_SIZE as i32;
230 let next = index as i32 + pages * page;
231 next.clamp(0, (len as i32) - 1) as usize
232}
233
234pub fn step_filtered_index(index: usize, delta: i32, len: usize, pred: impl Fn(usize) -> bool) -> usize {
236 if len == 0 {
237 return 0;
238 }
239 let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
240 if matching.is_empty() {
241 return index.min(len - 1);
242 }
243 let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
244 let next = (pos as i32 + delta).rem_euclid(matching.len() as i32) as usize;
245 matching[next]
246}
247
248pub fn page_filtered_index(
250 index: usize,
251 pages: i32,
252 len: usize,
253 pred: impl Fn(usize) -> bool,
254) -> usize {
255 if len == 0 {
256 return 0;
257 }
258 let matching: Vec<usize> = (0..len).filter(|&i| pred(i)).collect();
259 if matching.is_empty() {
260 return index.min(len - 1);
261 }
262 let pos = matching.iter().position(|&i| i == index).unwrap_or(0);
263 let next = page_list_index(pos, pages, matching.len());
264 matching[next]
265}
266
267pub fn inventory_category_group(category: &str) -> (&'static str, u8) {
269 match category {
270 "weapon" | "ammo" => ("Weapons", 0),
271 "armor" | "shield" | "offhand" => ("Armor", 1),
272 "consumable" => ("Consumables", 2),
273 "resource" | "harvest_node" | "seed" => ("Resources", 3),
274 "container" | "lodging" => ("Containers", 4),
275 "currency" | "key" => ("Currency & keys", 5),
276 "tool" | "misc" | "furniture" | "quest" | "document" => ("Gear & misc", 6),
277 _ => ("Other", 7),
278 }
279}
280
281pub fn category_default_listable(category: &str) -> bool {
283 !matches!(
284 category,
285 "currency" | "harvest_node" | "key" | "quest" | "document" | "lodging"
286 )
287}
288
289fn parse_bank_copper_amount(input: &str) -> Option<u64> {
291 let s = input.trim();
292 if s.is_empty() {
293 return Some(0);
294 }
295 s.parse::<u64>().ok()
296}
297
298fn parse_storage_quantity(input: &str) -> Option<Option<u32>> {
300 let s = input.trim();
301 if s.is_empty() || s == "0" {
302 return Some(None);
303 }
304 let n = s.parse::<u32>().ok()?;
305 if n == 0 {
306 return Some(None);
307 }
308 Some(Some(n))
309}
310
311fn storage_stack_label(stack: &flatland_protocol::ItemStack) -> String {
312 let name = stack
313 .display_name
314 .as_deref()
315 .unwrap_or(stack.template_id.as_str());
316 if stack.quantity > 1 {
317 format!("{name} ×{}", stack.quantity)
318 } else {
319 name.to_string()
320 }
321}
322
323pub fn body_slot_label(slot: BodySlot) -> &'static str {
326 match slot {
327 BodySlot::Head => "Head",
328 BodySlot::Chest => "Chest",
329 BodySlot::Forearms => "Forearms",
330 BodySlot::Legs => "Legs",
331 BodySlot::Feet => "Feet",
332 BodySlot::Cloak => "Cloak",
333 BodySlot::Back => "Back",
334 BodySlot::Waist => "Waist",
335 BodySlot::Earrings => "Earrings",
336 BodySlot::Necklace => "Necklace",
337 BodySlot::Eyeglasses => "Eyeglasses",
338 BodySlot::RingLeft1 => "Ring L1",
339 BodySlot::RingLeft2 => "Ring L2",
340 BodySlot::RingRight1 => "Ring R1",
341 BodySlot::RingRight2 => "Ring R2",
342 }
343}
344
345fn grant_target_matches_mode(stack: &flatland_protocol::ItemStack, mode: &str) -> bool {
346 let cat = stack.category.as_deref().unwrap_or("");
347 match mode {
348 "while_equipped" => {
349 stack.equip_slot.is_some()
350 || cat == "weapon"
351 || cat == "shield"
352 || cat == "offhand"
353 || cat == "armor"
354 }
355 _ => cat == "weapon" || cat == "ammo" || stack.props.contains_key("weapon_ability_id"),
356 }
357}
358
359fn grant_tags_match(stack: &flatland_protocol::ItemStack, grant_tags: &[&str]) -> bool {
360 if grant_tags.is_empty() {
361 return true;
362 }
363 let target_tags: Vec<&str> = stack
364 .props
365 .get("allowed_enchant_tags")
366 .map(|s| {
367 s.split(',')
368 .map(str::trim)
369 .filter(|t| !t.is_empty())
370 .collect()
371 })
372 .unwrap_or_default();
373 if target_tags.is_empty() {
374 return true;
375 }
376 grant_tags.iter().any(|t| target_tags.contains(t))
377}
378
379pub const DEFAULT_TICK_HZ: u32 = 30;
381
382pub fn format_binding_ttl(
384 binding: &flatland_protocol::ItemStatusBinding,
385 tick: u64,
386 tick_hz: u32,
387) -> String {
388 let Some(expires) = binding.expires_at_tick else {
389 return "permanent".into();
390 };
391 let hz = tick_hz.max(1) as f32;
392 let remaining = expires.saturating_sub(tick) as f32 / hz;
393 if remaining <= 0.0 {
394 return "expired".into();
395 }
396 if remaining >= 120.0 {
397 format!("{:.0}m left", remaining / 60.0)
398 } else if remaining >= 10.0 {
399 format!("{remaining:.0}s left")
400 } else {
401 format!("{remaining:.1}s left")
402 }
403}
404
405pub fn format_binding_mode(mode: flatland_protocol::ItemStatusBindingMode) -> &'static str {
406 match mode {
407 flatland_protocol::ItemStatusBindingMode::OnHit => "on hit",
408 flatland_protocol::ItemStatusBindingMode::WhileEquipped => "while equipped",
409 }
410}
411
412pub fn format_status_bindings_suffix(
414 bindings: &[flatland_protocol::ItemStatusBinding],
415 tick: u64,
416 tick_hz: u32,
417) -> String {
418 if bindings.is_empty() {
419 return String::new();
420 }
421 let parts: Vec<String> = bindings
422 .iter()
423 .map(|b| {
424 format!(
425 "{} ({}, {})",
426 b.effect_id,
427 format_binding_mode(b.mode),
428 format_binding_ttl(b, tick, tick_hz)
429 )
430 })
431 .collect();
432 format!(" · {}", parts.join("; "))
433}
434
435#[derive(Debug, Clone, Copy, PartialEq, Eq)]
436pub enum EquipPaperdollRow {
437 Body { slot: BodySlot, filled: bool },
438 Mainhand { filled: bool },
439 Offhand { filled: bool, locked: bool },
440}
441
442pub fn equip_paperdoll_rows(state: &GameState) -> Vec<EquipPaperdollRow> {
443 let mut rows: Vec<EquipPaperdollRow> = BodySlot::ALL
444 .iter()
445 .map(|slot| EquipPaperdollRow::Body {
446 slot: *slot,
447 filled: state.worn.contains_key(slot),
448 })
449 .collect();
450 let two_hand = state.mainhand_hand_slots >= 2;
451 rows.push(EquipPaperdollRow::Mainhand {
452 filled: state.mainhand_template_id.is_some(),
453 });
454 rows.push(EquipPaperdollRow::Offhand {
455 filled: state.offhand_template_id.is_some(),
456 locked: two_hand,
457 });
458 rows
459}
460
461fn first_inventory_for_slot(state: &GameState, slot: BodySlot) -> Option<uuid::Uuid> {
462 for stack in &state.inventory_stacks {
463 let matches = stack
464 .equip_slot
465 .map(|s| s == slot || (is_client_ring(s) && is_client_ring(slot)))
466 .unwrap_or(false)
467 || guess_body_slot(&stack.template_id) == Some(slot);
468 if matches {
469 return stack.item_instance_id;
470 }
471 }
472 None
473}
474
475fn is_client_ring(slot: BodySlot) -> bool {
476 matches!(
477 slot,
478 BodySlot::RingLeft1
479 | BodySlot::RingLeft2
480 | BodySlot::RingRight1
481 | BodySlot::RingRight2
482 )
483}
484
485fn first_inventory_weapon(state: &GameState) -> Option<String> {
486 for stack in &state.inventory_stacks {
487 if stack.category.as_deref() == Some("weapon") {
488 return Some(stack.template_id.clone());
489 }
490 }
491 None
492}
493
494fn first_inventory_offhand(state: &GameState) -> Option<String> {
495 for stack in &state.inventory_stacks {
496 let cat = stack.category.as_deref().unwrap_or("");
497 if matches!(cat, "shield" | "offhand") {
498 return Some(stack.template_id.clone());
499 }
500 }
501 None
502}
503
504fn guess_body_slot(template_id: &str) -> Option<BodySlot> {
507 if template_id.contains("backpack") {
508 Some(BodySlot::Back)
509 } else if template_id.contains("belt") {
510 Some(BodySlot::Waist)
511 } else if template_id.contains("cloak") || template_id.contains("cape") {
512 Some(BodySlot::Cloak)
513 } else if template_id.contains("cap")
514 || template_id.contains("hat")
515 || template_id.contains("helm")
516 {
517 Some(BodySlot::Head)
518 } else if template_id.contains("shirt")
519 || template_id.contains("robe")
520 || template_id.contains("vest")
521 || template_id.contains("chest")
522 || template_id.contains("jerkin")
523 {
524 Some(BodySlot::Chest)
525 } else if template_id.contains("sleeves")
526 || template_id.contains("gloves")
527 || template_id.contains("gauntlets")
528 {
529 Some(BodySlot::Forearms)
530 } else if template_id.contains("pants") || template_id.contains("leggings") {
531 Some(BodySlot::Legs)
532 } else if template_id.contains("boots") || template_id.contains("shoes") {
533 Some(BodySlot::Feet)
534 } else if template_id.contains("earring") {
535 Some(BodySlot::Earrings)
536 } else if template_id.contains("necklace") || template_id.contains("amulet") {
537 Some(BodySlot::Necklace)
538 } else if template_id.contains("glass")
539 || template_id.contains("spectacles")
540 || template_id.contains("goggles")
541 {
542 Some(BodySlot::Eyeglasses)
543 } else if template_id.contains("ring") {
544 Some(BodySlot::RingLeft1)
545 } else {
546 None
547 }
548}
549
550#[derive(Debug, Clone)]
552pub struct InventoryRow {
553 pub depth: usize,
554 pub stack: flatland_protocol::ItemStack,
555 pub from: flatland_protocol::InventoryLocation,
557 pub from_parent_instance_id: Option<uuid::Uuid>,
559 pub is_equip_shell: bool,
561 pub is_chest_shell: bool,
563 pub section: InventorySection,
564}
565
566#[derive(Debug, Clone)]
568pub struct InventoryRowView {
569 pub depth: usize,
570 pub text: String,
572 pub title: String,
574 pub mass_kg: Option<f32>,
575 pub volume: Option<(f32, f32)>,
576 pub instance_tooltip: Option<String>,
578}
579
580#[derive(Debug, Clone)]
582pub enum InventoryBrowserLine {
583 Section(String),
584 SlotLabel(String),
585 Hint(String),
586 Blank,
587 Item {
588 selectable_index: usize,
589 selected: bool,
590 depth: usize,
591 text: String,
592 title: String,
593 mass_kg: Option<f32>,
594 volume: Option<(f32, f32)>,
595 instance_tooltip: Option<String>,
596 },
597}
598
599#[derive(Debug, Clone, PartialEq, Eq, Default)]
601pub enum BankUiMode {
602 #[default]
603 Menu,
604 DepositAmount {
605 input: String,
606 },
607 WithdrawAmount {
608 input: String,
609 },
610 TransferName {
611 input: String,
612 },
613 TransferAmount {
614 to_name: String,
615 input: String,
616 },
617}
618
619#[derive(Debug, Clone, PartialEq, Eq, Default)]
621pub enum StorageUiMode {
622 #[default]
623 Menu,
624 StorePick {
626 index: usize,
627 },
628 StoreAmount {
630 pick_index: usize,
631 item_instance_id: uuid::Uuid,
632 label: String,
633 max_qty: u32,
634 input: String,
635 },
636 TakePick {
638 index: usize,
639 },
640 TakeAmount {
642 pick_index: usize,
643 item_instance_id: uuid::Uuid,
644 label: String,
645 max_qty: u32,
646 input: String,
647 },
648 ShipPick {
650 dest_building_id: String,
651 dest_label: String,
652 index: usize,
653 },
654 ShipAmount {
656 dest_building_id: String,
657 dest_label: String,
658 pick_index: usize,
659 item_instance_id: uuid::Uuid,
660 label: String,
661 max_qty: u32,
662 input: String,
663 },
664}
665
666#[derive(Debug, Clone, PartialEq, Eq)]
668pub enum MarketListSourceKind {
669 Person,
670 TownStorage { building_id: String },
671}
672
673#[derive(Debug, Clone, PartialEq, Eq, Default)]
675pub enum MarketUiMode {
676 #[default]
677 Browse,
678 ListSource {
680 index: usize,
681 },
682 ListPick {
684 source: MarketListSourceKind,
685 index: usize,
686 },
687 ListAmount {
689 source: MarketListSourceKind,
690 pick_index: usize,
691 item_instance_id: uuid::Uuid,
692 label: String,
693 max_qty: u32,
694 input: String,
695 },
696 ListPricingMode {
698 source: MarketListSourceKind,
699 item_instance_id: uuid::Uuid,
700 label: String,
701 quantity: Option<u32>,
702 max_qty: u32,
703 index: usize,
705 },
706 ListPrice {
708 source: MarketListSourceKind,
709 item_instance_id: uuid::Uuid,
710 label: String,
711 quantity: Option<u32>,
713 max_qty: u32,
714 input: String,
715 },
716}
717
718#[derive(Debug, Clone)]
720pub struct StoragePickOption {
721 pub item_instance_id: uuid::Uuid,
722 pub label: String,
723 pub quantity: u32,
724 pub category: String,
726}
727
728#[derive(Debug, Clone)]
731pub struct NearbyContainer {
732 pub view: flatland_protocol::PlacedContainerView,
733 pub distance_m: f32,
734 pub rows: Vec<InventoryRow>,
735}
736
737#[derive(Debug, Clone)]
739pub struct KeychainEntry {
740 pub stack: flatland_protocol::ItemStack,
741 pub stowed: bool,
742}
743
744#[derive(Debug, Clone)]
746pub struct MoveOption {
747 pub label: String,
748 pub kind: MoveOptionKind,
749}
750
751#[derive(Debug, Clone, PartialEq)]
752pub enum MoveOptionKind {
753 Move {
754 location: flatland_protocol::InventoryLocation,
755 parent_instance_id: Option<uuid::Uuid>,
756 },
757 PickupPlaced {
759 container_id: String,
760 nest_location: flatland_protocol::InventoryLocation,
761 nest_parent_instance_id: Option<uuid::Uuid>,
762 },
763 RelocatePlaced {
765 container_id: String,
766 },
767 Use,
769 GrantApply,
771 Drop,
772 SellPlotToCrown {
774 plot_id: uuid::Uuid,
775 },
776 Cancel,
777}
778
779#[derive(Debug, Clone, PartialEq)]
781pub enum FarmAccessRow {
782 PublicToggle,
783 PublicDiscount,
784 AllowRemove {
785 character_id: uuid::Uuid,
786 label: String,
787 tax_discount_bps: u32,
788 },
789 NearbyAdd {
790 name: String,
791 },
792}
793
794#[derive(Debug, Clone)]
796pub struct GrantTargetPicker {
797 pub grant_instance_id: uuid::Uuid,
798 pub grant_label: String,
799 pub effect_id: String,
800 pub mode: String,
801 pub options: Vec<GrantTargetOption>,
802 pub filter: String,
803 pub filter_focused: bool,
804}
805
806#[derive(Debug, Clone)]
807pub struct GrantTargetOption {
808 pub label: String,
809 pub target_instance_id: uuid::Uuid,
810}
811
812#[derive(Debug, Clone)]
814pub struct MovePicker {
815 pub item_instance_id: uuid::Uuid,
816 pub from: flatland_protocol::InventoryLocation,
817 pub item_label: String,
818 pub template_id: String,
819 pub stack_quantity: u32,
820 pub quantity: u32,
821 pub options: Vec<MoveOption>,
822 pub filter: String,
823 pub filter_focused: bool,
824}
825
826#[derive(Debug, Clone)]
828pub struct DestroyPicker {
829 pub item_instance_id: uuid::Uuid,
830 pub from: flatland_protocol::InventoryLocation,
831 pub item_label: String,
832 pub stack_quantity: u32,
833 pub quantity: u32,
834}
835
836#[derive(Debug, Clone)]
838pub struct WorkerGiveOption {
839 pub item_instance_id: uuid::Uuid,
840 pub label: String,
841 pub quantity: u32,
842 pub template_id: String,
843}
844
845#[derive(Debug, Clone)]
847pub struct WorkerGivePicker {
848 pub worker_instance_id: String,
849 pub worker_label: String,
850 pub options: Vec<WorkerGiveOption>,
851}
852
853#[derive(Debug, Clone)]
855pub struct WorkerGiveTargetOption {
856 pub instance_id: String,
857 pub label: String,
858 pub distance_m: f32,
859}
860
861#[derive(Debug, Clone)]
863pub struct WorkerGiveTargetPicker {
864 pub item_instance_id: uuid::Uuid,
865 pub item_label: String,
866 pub quantity: Option<u32>,
867 pub options: Vec<WorkerGiveTargetOption>,
868}
869
870#[derive(Debug, Clone)]
872pub struct WorkerTakePicker {
873 pub worker_instance_id: String,
874 pub worker_label: String,
875 pub options: Vec<WorkerGiveOption>,
876 pub quantity: u32,
878}
879
880pub const WORKER_GIVE_RANGE_M: f32 = 4.0;
882
883#[derive(Debug, Clone)]
885pub struct WorkerTeachOption {
886 pub blueprint_id: String,
887 pub label: String,
888 pub cost_copper: u64,
889 pub min_level: u32,
890 pub worker_level: u32,
891 pub can_afford: bool,
892 pub level_ok: bool,
893}
894
895#[derive(Debug, Clone)]
897pub struct WorkerTeachPicker {
898 pub worker_instance_id: String,
899 pub worker_label: String,
900 pub worker_level: u32,
901 pub options: Vec<WorkerTeachOption>,
902}
903
904#[derive(Debug, Clone, Default)]
907pub struct StickyWorkerStep {
908 shown: String,
909 pending: String,
910 pending_since: Option<Instant>,
911}
912
913impl StickyWorkerStep {
914 fn from_label(label: String) -> Self {
915 Self {
916 shown: label.clone(),
917 pending: label,
918 pending_since: Some(Instant::now()),
919 }
920 }
921
922 fn observe(&mut self, label: &str, now: Instant) {
923 let pending_since = self.pending_since.unwrap_or(now);
924 if label == self.pending {
925 if self.shown != self.pending && now.duration_since(pending_since) >= WORKER_STEP_HOLD
926 {
927 self.shown = self.pending.clone();
928 }
929 return;
930 }
931 self.pending = label.to_string();
932 self.pending_since = Some(now);
933 if self.shown.is_empty() {
935 self.shown = self.pending.clone();
936 }
937 }
938}
939
940#[derive(Debug, Clone, Default)]
943pub struct StickyWorkerError {
944 message: String,
945 last_seen: Option<Instant>,
946}
947
948impl StickyWorkerError {
949 fn observe(&mut self, err: Option<&str>, now: Instant) {
950 if let Some(e) = err {
951 if !worker_error_is_transient(e) && !worker_error_is_hud_noise(e) {
952 self.message = e.to_string();
953 self.last_seen = Some(now);
954 }
955 return;
956 }
957 if let Some(seen) = self.last_seen {
958 if now.duration_since(seen) > WORKER_ERROR_HOLD {
959 self.message.clear();
960 self.last_seen = None;
961 }
962 }
963 }
964
965 pub fn shown(&self, now: Instant) -> Option<&str> {
966 if self.message.is_empty() {
967 return None;
968 }
969 let seen = self.last_seen?;
970 if now.duration_since(seen) > WORKER_ERROR_HOLD {
971 return None;
972 }
973 Some(self.message.as_str())
974 }
975}
976
977pub fn worker_attention_line(state: &GameState) -> Option<String> {
980 use flatland_protocol::WorkerStateView;
981 let now = Instant::now();
982 for w in &state.hired_workers {
983 if matches!(w.state, WorkerStateView::Strike) {
984 return Some(format!(
985 "Worker {}: on strike — fund bank, pay wages, or stock lodging chest",
986 w.label
987 ));
988 }
989 if let Some(err) = state
990 .worker_error_display
991 .get(&w.instance_id)
992 .and_then(|s| s.shown(now))
993 {
994 if !worker_error_is_hud_noise(err) {
995 return Some(format!("Worker {}: {err}", w.label));
996 }
997 }
998 if let Some(err) = &w.last_error {
999 if !worker_error_is_transient(err) && !worker_error_is_hud_noise(err) {
1000 return Some(format!("Worker {}: {err}", w.label));
1001 }
1002 }
1003 }
1004 None
1005}
1006
1007pub fn worker_error_is_transient(err: &str) -> bool {
1009 let e = err.to_ascii_lowercase();
1010 e.contains("continuing route")
1011 || e.contains("storage full")
1012 || e.starts_with("nothing to withdraw")
1013}
1014
1015pub fn worker_error_is_hud_noise(err: &str) -> bool {
1018 let e = err.to_ascii_lowercase();
1019 e.contains("returned to lodging after path")
1020 || e.contains("path failure")
1021 || e.contains("no path to")
1022 || e.contains("pathfinding")
1023 || e.contains("repathing")
1025 || e.contains("nudged clear")
1026}
1027
1028#[derive(Debug, Clone)]
1030pub struct PendingWorkerJobAck {
1031 pub seq: u32,
1032 pub worker_instance_id: String,
1033 pub worker_label: String,
1034 pub idle: bool,
1035 pub stop_count: usize,
1036 pub prev_route: Option<flatland_protocol::WorkerRouteView>,
1037 pub prev_mode: flatland_protocol::WorkerModeView,
1038 pub prev_step_label: String,
1039 pub prev_last_error: Option<String>,
1040}
1041
1042fn push_inventory_rows(
1043 rows: &mut Vec<InventoryRow>,
1044 depth: usize,
1045 stack: &flatland_protocol::ItemStack,
1046 from: &flatland_protocol::InventoryLocation,
1047 from_parent_instance_id: Option<uuid::Uuid>,
1048 section: InventorySection,
1049) {
1050 push_inventory_rows_filtered(
1051 rows,
1052 depth,
1053 stack,
1054 from,
1055 from_parent_instance_id,
1056 section,
1057 "",
1058 );
1059}
1060
1061fn stack_matches_filter(stack: &flatland_protocol::ItemStack, filter: &str) -> bool {
1062 if filter.is_empty() {
1063 return true;
1064 }
1065 let f = filter.to_ascii_lowercase();
1066 let name = stack
1067 .display_name
1068 .as_deref()
1069 .unwrap_or("")
1070 .to_ascii_lowercase();
1071 let tid = stack.template_id.to_ascii_lowercase();
1072 name.contains(&f)
1073 || tid.contains(&f)
1074 || stack
1075 .contents
1076 .iter()
1077 .any(|c| stack_matches_filter(c, filter))
1078}
1079
1080fn push_inventory_rows_filtered(
1081 rows: &mut Vec<InventoryRow>,
1082 depth: usize,
1083 stack: &flatland_protocol::ItemStack,
1084 from: &flatland_protocol::InventoryLocation,
1085 from_parent_instance_id: Option<uuid::Uuid>,
1086 section: InventorySection,
1087 filter: &str,
1088) {
1089 if !filter.is_empty() && !stack_matches_filter(stack, filter) {
1090 return;
1091 }
1092 let self_hit = filter.is_empty() || {
1093 let f = filter.to_ascii_lowercase();
1094 let name = stack
1095 .display_name
1096 .as_deref()
1097 .unwrap_or("")
1098 .to_ascii_lowercase();
1099 let tid = stack.template_id.to_ascii_lowercase();
1100 name.contains(&f) || tid.contains(&f)
1101 };
1102 rows.push(InventoryRow {
1103 depth,
1104 stack: stack.clone(),
1105 from: from.clone(),
1106 from_parent_instance_id,
1107 is_equip_shell: false,
1108 is_chest_shell: false,
1109 section,
1110 });
1111 for child in &stack.contents {
1112 if self_hit || filter.is_empty() || stack_matches_filter(child, filter) {
1113 push_inventory_rows_filtered(
1114 rows,
1115 depth + 1,
1116 child,
1117 from,
1118 stack.item_instance_id,
1119 section,
1120 if self_hit { "" } else { filter },
1121 );
1122 }
1123 }
1124}
1125
1126#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
1127pub enum ShopTab {
1128 #[default]
1129 Buy,
1130 Sell,
1131}
1132
1133#[derive(Debug, Clone)]
1134pub struct NpcChatState {
1135 pub npc_id: String,
1136 pub npc_label: String,
1137 pub lines: Vec<String>,
1138 pub input: String,
1139 pub pending: bool,
1140 pub talk_depth: flatland_protocol::NpcTalkDepth,
1141 pub trade_allowed: bool,
1142 pub banner: Option<String>,
1143}
1144
1145impl Default for NpcChatState {
1146 fn default() -> Self {
1147 Self {
1148 npc_id: String::new(),
1149 npc_label: String::new(),
1150 lines: Vec::new(),
1151 input: String::new(),
1152 pending: false,
1153 talk_depth: flatland_protocol::NpcTalkDepth::Full,
1154 trade_allowed: true,
1155 banner: None,
1156 }
1157 }
1158}
1159
1160#[derive(Debug, Clone)]
1161pub struct GameState {
1162 pub session_id: SessionId,
1163 pub entity_id: EntityId,
1164 pub character_id: Option<uuid::Uuid>,
1166 pub tick: Tick,
1167 pub chunk_rev: u64,
1168 pub content_rev: u64,
1169 pub publish_rev: u64,
1170 pub entities: Vec<EntityState>,
1171 pub player: Option<EntityState>,
1172 pub resource_nodes: Vec<flatland_protocol::ResourceNodeView>,
1173 pub ground_drops: Vec<flatland_protocol::GroundDropView>,
1174 pub placed_containers: Vec<flatland_protocol::PlacedContainerView>,
1175 pub buildings: Vec<BuildingView>,
1176 pub doors: Vec<DoorView>,
1177 pub interior_map: Option<InteriorMapView>,
1178 pub npcs: Vec<NpcView>,
1179 pub blueprints: Vec<BlueprintView>,
1180 pub building_materials: Vec<flatland_protocol::BuildingMaterialView>,
1182 pub world_x0: f32,
1184 pub world_y0: f32,
1185 pub world_width_m: f32,
1186 pub world_height_m: f32,
1187 pub terrain_zones: Vec<TerrainZoneView>,
1188 pub z_platforms: Vec<ZPlatformView>,
1189 pub z_transitions: Vec<ZTransitionView>,
1190 #[doc(hidden)]
1193 pub z_bands_outdoor_backup: Option<(Vec<ZPlatformView>, Vec<ZTransitionView>)>,
1194 pub world_clock: flatland_protocol::WorldClock,
1195 pub inventory: std::collections::HashMap<String, u32>,
1196 pub inventory_hints: std::collections::HashMap<String, InventoryHint>,
1197 pub logs: VecDeque<String>,
1198 pub intents_sent: u64,
1199 pub ticks_received: u64,
1200 pub connected: bool,
1201 pub disconnect_reason: Option<String>,
1202 pub show_stats: bool,
1203 pub hud_log_hidden: bool,
1205 pub show_equip_menu: bool,
1206 pub equip_menu_index: usize,
1207 pub show_craft_menu: bool,
1208 pub craft_menu_index: usize,
1209 pub craft_batch_quantity: u32,
1211 pub show_plot_build_menu: bool,
1213 pub plot_build_focus_wall: bool,
1215 pub plot_build_wall_index: usize,
1216 pub plot_build_roof_index: usize,
1217 pub show_shop_menu: bool,
1218 pub shop_catalog: Option<flatland_protocol::ShopCatalog>,
1219 pub bank_panel: Option<flatland_protocol::BankPanel>,
1220 pub bank_menu_index: usize,
1221 pub bank_ui_mode: BankUiMode,
1222 pub storage_panel: Option<flatland_protocol::StoragePanel>,
1223 pub market_panel: Option<flatland_protocol::MarketPanel>,
1224 pub market_menu_index: usize,
1226 pub market_filter: String,
1228 pub market_filter_focused: bool,
1229 pub market_category_filter: Option<&'static str>,
1231 pub market_buy_confirm: Option<(uuid::Uuid, u32, u64, u64, String)>,
1233 pub market_ui_mode: MarketUiMode,
1234 pub storage_menu_index: usize,
1235 pub storage_ui_mode: StorageUiMode,
1236 pub shop_tab: ShopTab,
1237 pub shop_menu_index: usize,
1238 pub shop_quantity: u32,
1239 pub shop_trade_log: VecDeque<String>,
1241 pub show_npc_verb_menu: bool,
1242 pub npc_verb_target: Option<String>,
1243 pub npc_verb_index: usize,
1244 pub player_verbs: crate::social::PlayerVerbState,
1246 pub social_chat: crate::social::SocialChatState,
1247 pub trade_ui: crate::social::TradeUiState,
1248 pub whisper_pouch_ui: crate::social::WhisperPouchUi,
1249 pub show_npc_chat: bool,
1250 pub npc_chat: Option<NpcChatState>,
1251 pub show_inventory_menu: bool,
1252 pub inventory_menu_index: usize,
1253 pub inventory_tab: InventoryTab,
1254 pub inventory_filter: String,
1255 pub inventory_filter_focused: bool,
1256 pub show_move_picker: bool,
1257 pub move_picker_index: usize,
1258 pub move_picker: Option<MovePicker>,
1259 pub show_grant_picker: bool,
1260 pub grant_picker_index: usize,
1261 pub grant_picker: Option<GrantTargetPicker>,
1262 pub show_destroy_picker: bool,
1263 pub destroy_confirm_pending: bool,
1264 pub destroy_picker: Option<DestroyPicker>,
1265 pub show_rename_prompt: bool,
1267 pub show_worker_rename: bool,
1269 pub rename_buffer: String,
1270 pub combat_target: Option<EntityId>,
1272 pub combat_target_label: Option<String>,
1273 pub ground_target: Option<(f32, f32, f32)>,
1276 pub combat_fx: Vec<flatland_protocol::CombatFx>,
1278 pub property_zones: Vec<flatland_protocol::PropertyZoneView>,
1280 pub tax_zones: Vec<flatland_protocol::TaxZoneView>,
1282 pub growth_zones: Vec<flatland_protocol::GrowthZoneView>,
1284 pub biome_zones: Vec<flatland_protocol::BiomeZoneView>,
1286 pub terrain_kind_nav: Vec<flatland_protocol::TerrainKindNavView>,
1288 pub property_plots: Vec<flatland_protocol::PropertyPlotView>,
1290 pub property_plot_settings: Option<flatland_protocol::PropertyPlotSettingsView>,
1292 pub claim_mode: Option<ClaimModeState>,
1294 pub relocate_mode: Option<RelocateModeState>,
1296 pub sell_plot_confirm: Option<uuid::Uuid>,
1298 pub sell_plot_armed_at: Option<Instant>,
1300 pub show_plant_menu: bool,
1302 pub plant_menu_index: usize,
1303 pub show_farm_access: bool,
1305 pub farm_access_name_draft: String,
1307 pub farm_access_discount_bps: u32,
1309 pub farm_access_index: usize,
1311 pub plant_quantity: u32,
1312 pub in_combat: bool,
1313 pub auto_attack: bool,
1314 pub combat_has_los: bool,
1315 pub attack_cd_ticks: u64,
1316 pub gcd_ticks: u64,
1317 pub weapon_ability_id: String,
1318 pub mainhand_template_id: Option<String>,
1319 pub mainhand_label: Option<String>,
1320 pub mainhand_instance_id: Option<uuid::Uuid>,
1321 pub offhand_template_id: Option<String>,
1322 pub offhand_label: Option<String>,
1323 pub offhand_instance_id: Option<uuid::Uuid>,
1324 pub mainhand_hand_slots: u8,
1325 pub defense: Option<flatland_protocol::DefenseHud>,
1326 pub worn: BTreeMap<BodySlot, flatland_protocol::ItemStack>,
1328 pub carry_mass: f32,
1329 pub carry_mass_max: f32,
1330 pub encumbrance: flatland_protocol::EncumbranceState,
1331 pub inventory_stacks: Vec<flatland_protocol::ItemStack>,
1333 pub keychain_stacks: Vec<flatland_protocol::ItemStack>,
1335 pub whisper_pouch_stacks: Vec<flatland_protocol::ItemStack>,
1337 pub statuses: Vec<flatland_protocol::StatusEffectHud>,
1339 pub combat_target_detail: Option<CombatTargetHud>,
1340 pub cast_progress: Option<CastProgressHud>,
1341 pub timed_channel: Option<flatland_protocol::TimedChannelHud>,
1343 pub plot_build_offer: Option<flatland_protocol::PlotBuildOfferHud>,
1345 pub ability_cooldowns: Vec<AbilityCooldownHud>,
1346 pub blocking_active: bool,
1347 pub max_target_slots: u8,
1348 pub combat_slots: Vec<CombatSlotHud>,
1349 pub rotation_presets: Vec<RotationPreset>,
1350 pub known_abilities: Vec<String>,
1352 pub ability_meta: std::collections::HashMap<String, flatland_protocol::AbilityMetaHud>,
1354 pub ability_mastery: std::collections::HashMap<String, flatland_protocol::AbilityMasteryHud>,
1356 pub hotbar: Vec<Option<String>>,
1358 pub max_abilities_per_rotation: u8,
1360 pub show_loadout_menu: bool,
1361 pub show_keychain_menu: bool,
1362 pub keychain_menu_index: usize,
1363 pub show_rotation_editor: bool,
1364 pub loadout_menu_index: usize,
1366 pub loadout_hotbar_slot: u8,
1368 pub loadout_ability_index: usize,
1370 pub loadout_focus_presets: bool,
1372 pub rotation_editor: RotationEditorState,
1373 pub harvest_in_progress: bool,
1375 pub harvest_started_at: Option<Instant>,
1377 pub pending_craft_ack: Option<(u32, String, u32)>,
1379 pub quest_log: Vec<flatland_protocol::QuestLogEntry>,
1380 pub interactables: Vec<flatland_protocol::InteractableView>,
1381 pub ledger: Option<flatland_protocol::PlayerLedgerView>,
1382 pub career: Option<flatland_protocol::PlayerCareerView>,
1383 pub character_sheet_tab: CharacterSheetTab,
1384 pub ledger_period: LedgerPeriod,
1385 pub show_quest_offer: bool,
1386 pub pending_quest_offer: Option<flatland_protocol::QuestOffer>,
1387 pub show_quest_menu: bool,
1388 pub quest_menu_index: usize,
1389 pub quest_withdraw_confirm: bool,
1390 pub hired_workers: Vec<flatland_protocol::HiredWorkerView>,
1391 pub show_workers_menu: bool,
1392 pub workers_menu_index: usize,
1393 pub workers_menu_compact: bool,
1395 pub worker_step_display: BTreeMap<String, StickyWorkerStep>,
1398 pub worker_error_display: BTreeMap<String, StickyWorkerError>,
1400 pub show_worker_give_picker: bool,
1402 pub worker_give_picker_index: usize,
1403 pub worker_give_picker: Option<WorkerGivePicker>,
1404 pub show_worker_give_target_picker: bool,
1406 pub worker_give_target_picker_index: usize,
1407 pub worker_give_target_picker: Option<WorkerGiveTargetPicker>,
1408 pub show_worker_take_picker: bool,
1410 pub worker_take_picker_index: usize,
1411 pub worker_take_picker: Option<WorkerTakePicker>,
1412 pub show_worker_teach_picker: bool,
1414 pub worker_teach_picker_index: usize,
1415 pub worker_teach_picker: Option<WorkerTeachPicker>,
1416 pub worker_route_editor: Option<crate::worker_route_editor::WorkerRouteEditorState>,
1418 pub pending_worker_job_ack: Option<PendingWorkerJobAck>,
1420 pub attending_worker_instance_id: Option<String>,
1422 pub progression_curve: Option<flatland_protocol::ProgressionCurve>,
1424}
1425
1426impl GameState {
1427 pub fn push_log(&mut self, line: impl Into<String>) {
1428 self.logs.push_back(line.into());
1429 while self.logs.len() > MAX_LOG_LINES {
1430 self.logs.pop_front();
1431 }
1432 }
1433
1434 pub fn push_shop_trade_log(&mut self, line: impl Into<String>) {
1435 self.shop_trade_log.push_back(line.into());
1436 while self.shop_trade_log.len() > MAX_SHOP_TRADE_LOG_LINES {
1437 self.shop_trade_log.pop_front();
1438 }
1439 }
1440
1441 pub fn clear_shop_trade_log(&mut self) {
1442 self.shop_trade_log.clear();
1443 }
1444
1445 fn record_shop_trade_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
1446 if !self.show_shop_menu {
1447 return;
1448 }
1449 let msg = notice.message.trim();
1450 if msg.is_empty() {
1451 return;
1452 }
1453 if notice.coins_delta != 0
1454 || msg.starts_with("Bought ")
1455 || msg.starts_with("Sold ")
1456 || msg.contains("taught you how to craft")
1457 || msg.starts_with("need ")
1458 {
1459 self.push_shop_trade_log(msg);
1460 }
1461 }
1462
1463 pub fn is_alive(&self) -> bool {
1464 self.player
1465 .as_ref()
1466 .and_then(|p| p.vitals)
1467 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
1468 .unwrap_or(true)
1469 }
1470
1471 pub fn npc_verb_options(&self) -> Vec<&'static str> {
1473 let Some(ref id) = self.npc_verb_target else {
1474 return vec![];
1475 };
1476 let Some(npc) = self.npcs.iter().find(|n| &n.id == id) else {
1477 return vec!["Talk"];
1478 };
1479 let role = npc.role.as_str();
1480 if Self::npc_role_is_bank(role) {
1481 return vec!["Bank", "Talk"];
1482 }
1483 if Self::npc_role_is_storage(role) {
1484 return vec!["Storage", "Talk"];
1485 }
1486 if Self::npc_role_is_market(role) {
1487 return vec!["Market", "Talk"];
1488 }
1489 if npc.can_trade || Self::npc_role_can_trade(role) {
1490 vec!["Talk", "Trade"]
1491 } else {
1492 vec!["Talk"]
1493 }
1494 }
1495
1496 fn npc_role_can_trade(role: &str) -> bool {
1497 matches!(role, "broker" | "cook" | "farmer" | "merchant")
1498 }
1499
1500 fn npc_role_is_bank(role: &str) -> bool {
1501 role.eq_ignore_ascii_case("bank_teller") || role.eq_ignore_ascii_case("banker")
1502 }
1503
1504 fn npc_role_is_storage(role: &str) -> bool {
1505 role.eq_ignore_ascii_case("storage_manager")
1506 }
1507
1508 fn npc_role_is_market(role: &str) -> bool {
1509 role.eq_ignore_ascii_case("market_clerk")
1510 }
1511
1512 pub fn bank_menu_options(&self) -> Vec<&'static str> {
1513 vec![
1514 "Deposit…",
1515 "Withdraw…",
1516 "Deposit all",
1517 "Withdraw all",
1518 "Transfer…",
1519 ]
1520 }
1521
1522 pub fn storage_menu_options(&self) -> Vec<String> {
1523 let mut opts = vec!["Store…".into(), "Take…".into()];
1524 if let Some(panel) = &self.storage_panel {
1525 for dest in &panel.ship_destinations {
1526 opts.push(format!(
1527 "Ship → {} ({} cp / {} ticks)",
1528 dest.label, dest.fee_copper, dest.travel_ticks
1529 ));
1530 }
1531 }
1532 opts
1533 }
1534
1535 pub fn storage_store_options(&self) -> Vec<StoragePickOption> {
1539 let equipped = self.hand_equipped_instance_ids();
1540 self.person_rows()
1541 .into_iter()
1542 .filter(|r| r.depth == 0)
1543 .filter_map(|r| {
1544 let id = r.stack.item_instance_id?;
1545 if equipped.contains(&id) {
1546 return None;
1547 }
1548 Some(StoragePickOption {
1549 item_instance_id: id,
1550 label: storage_stack_label(&r.stack),
1551 quantity: r.stack.quantity,
1552 category: r.stack.category.clone().unwrap_or_default(),
1553 })
1554 })
1555 .collect()
1556 }
1557
1558 pub fn hand_equipped_instance_ids(&self) -> std::collections::HashSet<uuid::Uuid> {
1560 let mut ids = std::collections::HashSet::new();
1561 if let Some(id) = self.mainhand_instance_id {
1562 ids.insert(id);
1563 } else if let Some(tid) = &self.mainhand_template_id {
1564 if let Some(id) = self
1565 .inventory_stacks
1566 .iter()
1567 .find(|s| &s.template_id == tid)
1568 .and_then(|s| s.item_instance_id)
1569 {
1570 ids.insert(id);
1571 }
1572 }
1573 if let Some(id) = self.offhand_instance_id {
1574 ids.insert(id);
1575 } else if let Some(tid) = &self.offhand_template_id {
1576 if let Some(id) = self
1577 .inventory_stacks
1578 .iter()
1579 .find(|s| {
1580 &s.template_id == tid
1581 && s.item_instance_id
1582 .is_some_and(|iid| !ids.contains(&iid))
1583 })
1584 .and_then(|s| s.item_instance_id)
1585 {
1586 ids.insert(id);
1587 }
1588 }
1589 ids
1590 }
1591
1592 pub fn storage_vault_options(&self) -> Vec<StoragePickOption> {
1594 let Some(panel) = &self.storage_panel else {
1595 return Vec::new();
1596 };
1597 panel
1598 .contents
1599 .iter()
1600 .filter_map(|s| {
1601 let id = s.item_instance_id?;
1602 Some(StoragePickOption {
1603 item_instance_id: id,
1604 label: storage_stack_label(s),
1605 quantity: s.quantity,
1606 category: s.category.clone().unwrap_or_default(),
1607 })
1608 })
1609 .collect()
1610 }
1611
1612 pub fn market_list_source_options(&self) -> Vec<(MarketListSourceKind, String)> {
1614 let mut opts = Vec::new();
1615 if !self
1616 .market_list_item_options(&MarketListSourceKind::Person)
1617 .is_empty()
1618 {
1619 opts.push((MarketListSourceKind::Person, "On person".into()));
1620 }
1621 if let Some(panel) = &self.market_panel {
1622 for vault in &panel.list_vaults {
1623 let source = MarketListSourceKind::TownStorage {
1624 building_id: vault.building_id.clone(),
1625 };
1626 if self.market_list_item_options(&source).is_empty() {
1627 continue;
1628 }
1629 let label = if vault.building_label.is_empty() {
1630 format!("Town storage ({})", vault.building_id)
1631 } else {
1632 format!("Town storage — {}", vault.building_label)
1633 };
1634 opts.push((source, label));
1635 }
1636 }
1637 opts
1638 }
1639
1640 pub fn market_list_item_options(
1642 &self,
1643 source: &MarketListSourceKind,
1644 ) -> Vec<StoragePickOption> {
1645 let filter = self.market_filter.as_str();
1646 let cat_filter = self.market_category_filter;
1647 let mut opts: Vec<StoragePickOption> = match source {
1648 MarketListSourceKind::Person => {
1649 let equipped = self.hand_equipped_instance_ids();
1650 self.person_rows()
1651 .into_iter()
1652 .filter(|r| r.depth == 0)
1653 .filter(|r| self.stack_is_market_listable(&r.stack))
1654 .filter_map(|r| {
1655 let id = r.stack.item_instance_id?;
1656 if equipped.contains(&id) {
1657 return None;
1658 }
1659 Some(StoragePickOption {
1660 item_instance_id: id,
1661 label: storage_stack_label(&r.stack),
1662 quantity: r.stack.quantity,
1663 category: r
1664 .stack
1665 .category
1666 .clone()
1667 .or_else(|| {
1668 self.inventory_item_category(&r.stack.template_id)
1669 .map(str::to_string)
1670 })
1671 .unwrap_or_default(),
1672 })
1673 })
1674 .collect()
1675 }
1676 MarketListSourceKind::TownStorage { building_id } => {
1677 let Some(panel) = &self.market_panel else {
1678 return Vec::new();
1679 };
1680 let Some(vault) = panel
1681 .list_vaults
1682 .iter()
1683 .find(|v| &v.building_id == building_id)
1684 else {
1685 return Vec::new();
1686 };
1687 vault
1688 .contents
1689 .iter()
1690 .filter(|s| self.stack_is_market_listable(s))
1691 .filter_map(|s| {
1692 let id = s.item_instance_id?;
1693 Some(StoragePickOption {
1694 item_instance_id: id,
1695 label: storage_stack_label(s),
1696 quantity: s.quantity,
1697 category: s
1698 .category
1699 .clone()
1700 .or_else(|| {
1701 self.inventory_item_category(&s.template_id)
1702 .map(str::to_string)
1703 })
1704 .unwrap_or_default(),
1705 })
1706 })
1707 .collect()
1708 }
1709 };
1710 opts.retain(|o| {
1711 if !list_label_matches(&o.label, filter) {
1712 return false;
1713 }
1714 if let Some(group) = cat_filter {
1715 inventory_category_group(&o.category).0 == group
1716 } else {
1717 true
1718 }
1719 });
1720 opts
1721 }
1722
1723 fn stack_is_market_listable(&self, stack: &flatland_protocol::ItemStack) -> bool {
1724 if crate::currency::is_currency(&stack.template_id) {
1725 return false;
1726 }
1727 if let Some(flag) = stack.listable {
1728 return flag;
1729 }
1730 if let Some(hint) = self.inventory_hints.get(&stack.template_id) {
1731 return hint.listable;
1732 }
1733 let cat = stack
1734 .category
1735 .as_deref()
1736 .or_else(|| self.inventory_item_category(&stack.template_id))
1737 .unwrap_or("");
1738 category_default_listable(cat)
1739 }
1740
1741 pub fn market_available_category_groups(&self) -> Vec<&'static str> {
1743 let mut seen = std::collections::BTreeMap::<u8, &'static str>::new();
1744 match &self.market_ui_mode {
1745 MarketUiMode::ListPick { source, .. } => {
1746 let raw: Vec<_> = match source {
1747 MarketListSourceKind::Person => self
1748 .person_rows()
1749 .into_iter()
1750 .filter(|r| r.depth == 0)
1751 .filter(|r| self.stack_is_market_listable(&r.stack))
1752 .filter(|r| list_label_matches(&storage_stack_label(&r.stack), &self.market_filter))
1753 .map(|r| {
1754 r.stack
1755 .category
1756 .clone()
1757 .or_else(|| {
1758 self.inventory_item_category(&r.stack.template_id)
1759 .map(str::to_string)
1760 })
1761 .unwrap_or_default()
1762 })
1763 .collect(),
1764 MarketListSourceKind::TownStorage { building_id } => self
1765 .market_panel
1766 .as_ref()
1767 .and_then(|p| {
1768 p.list_vaults
1769 .iter()
1770 .find(|v| &v.building_id == building_id)
1771 })
1772 .map(|vault| {
1773 vault
1774 .contents
1775 .iter()
1776 .filter(|s| self.stack_is_market_listable(s))
1777 .filter(|s| {
1778 list_label_matches(&storage_stack_label(s), &self.market_filter)
1779 })
1780 .map(|s| {
1781 s.category
1782 .clone()
1783 .or_else(|| {
1784 self.inventory_item_category(&s.template_id)
1785 .map(str::to_string)
1786 })
1787 .unwrap_or_default()
1788 })
1789 .collect::<Vec<_>>()
1790 })
1791 .unwrap_or_default(),
1792 };
1793 for category in raw {
1794 let (label, ord) = inventory_category_group(&category);
1795 seen.insert(ord, label);
1796 }
1797 }
1798 _ => {
1799 if let Some(panel) = &self.market_panel {
1800 for listing in &panel.listings {
1801 if !list_label_matches(&listing.display_name, &self.market_filter)
1802 && !list_label_matches(&listing.seller_label, &self.market_filter)
1803 {
1804 continue;
1805 }
1806 let (label, ord) = inventory_category_group(&listing.category);
1807 seen.insert(ord, label);
1808 }
1809 }
1810 }
1811 }
1812 seen.into_values().collect()
1813 }
1814
1815 pub fn market_filtered_listing_indices(&self) -> Vec<usize> {
1817 let Some(panel) = &self.market_panel else {
1818 return Vec::new();
1819 };
1820 let filter = self.market_filter.as_str();
1821 let cat_filter = self.market_category_filter;
1822 panel
1823 .listings
1824 .iter()
1825 .enumerate()
1826 .filter(|(_, listing)| {
1827 if !list_label_matches(&listing.display_name, filter)
1828 && !list_label_matches(&listing.seller_label, filter)
1829 && !list_label_matches(&listing.template_id, filter)
1830 {
1831 return false;
1832 }
1833 if let Some(group) = cat_filter {
1834 inventory_category_group(&listing.category).0 == group
1835 } else {
1836 true
1837 }
1838 })
1839 .map(|(i, _)| i)
1840 .collect()
1841 }
1842
1843 pub fn clear_harvest_state(&mut self) {
1844 self.harvest_in_progress = false;
1845 self.harvest_started_at = None;
1846 }
1847
1848 fn harvest_state_stale(&self) -> bool {
1849 match self.harvest_started_at {
1850 Some(started) => started.elapsed() > HARVEST_CLIENT_TIMEOUT,
1851 None => self.harvest_in_progress,
1852 }
1853 }
1854
1855 pub fn vitals(&self) -> Option<flatland_protocol::PlayerVitals> {
1856 self.player.as_ref().and_then(|p| p.vitals)
1857 }
1858
1859 pub fn can_craft_blueprint(&self, blueprint: &BlueprintView) -> bool {
1860 let materials_ok = blueprint.inputs.iter().all(|input| {
1861 self.inventory.get(&input.template_id).copied().unwrap_or(0) >= input.quantity
1862 });
1863 let tools_ok = blueprint
1864 .required_tools
1865 .iter()
1866 .all(|tool| self.inventory.get(&tool.item).copied().unwrap_or(0) >= 1);
1867 let station_ok = match blueprint.station.as_deref() {
1868 None | Some("hand") => true,
1869 Some(tag) => self.player_at_station_tag(tag),
1870 };
1871 materials_ok && tools_ok && station_ok
1872 }
1873
1874 pub fn max_craft_batches(&self, blueprint: &BlueprintView) -> u32 {
1875 if !self.can_craft_blueprint(blueprint) {
1876 return 0;
1877 }
1878 let mut limit = u32::MAX;
1879 for input in &blueprint.inputs {
1880 if input.quantity == 0 {
1881 continue;
1882 }
1883 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
1884 limit = limit.min(have / input.quantity);
1885 }
1886 for tool in &blueprint.required_tools {
1887 if tool.consumed {
1888 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
1889 limit = limit.min(have);
1890 }
1891 }
1892 let stamina = self.vitals().map(|v| v.stamina).unwrap_or(0.0);
1893 if CRAFT_STAMINA_COST > 0.0 {
1894 limit = limit.min((stamina / CRAFT_STAMINA_COST).floor() as u32);
1895 }
1896 limit
1897 }
1898
1899 pub fn clamp_craft_batch_quantity(&mut self) {
1900 let Some(bp) = self.blueprints.get(self.craft_menu_index) else {
1901 self.craft_batch_quantity = 1;
1902 return;
1903 };
1904 let max = self.max_craft_batches(bp).max(1);
1905 self.craft_batch_quantity = self.craft_batch_quantity.clamp(1, max);
1906 }
1907
1908 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
1909 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1910 return;
1911 };
1912 let max = self.max_craft_batches(&bp).max(1);
1913 let next = (self.craft_batch_quantity as i32 + delta).clamp(1, max as i32);
1914 self.craft_batch_quantity = next as u32;
1915 }
1916
1917 pub fn craft_batch_set_max(&mut self) {
1918 let Some(bp) = self.blueprints.get(self.craft_menu_index).cloned() else {
1919 return;
1920 };
1921 let max = self.max_craft_batches(&bp);
1922 self.craft_batch_quantity = if max == 0 { 1 } else { max };
1923 }
1924
1925 pub fn craft_batch_set_min(&mut self) {
1926 self.craft_batch_quantity = 1;
1927 }
1928
1929 pub fn apply_shop_catalog(&mut self, catalog: flatland_protocol::ShopCatalog) {
1930 let preserve_ui = self.show_shop_menu;
1931 let tab = self.shop_tab;
1932 let index = self.shop_menu_index;
1933 let qty = self.shop_quantity;
1934
1935 self.show_shop_menu = true;
1936 self.bank_panel = None;
1937 self.show_craft_menu = false;
1938 self.show_inventory_menu = false;
1939 self.show_stats = false;
1940 if self.npc_verb_target.is_none() && !catalog.npc_id.is_empty() {
1941 self.npc_verb_target = Some(catalog.npc_id.clone());
1942 }
1943 self.shop_catalog = Some(catalog);
1944
1945 if preserve_ui {
1946 self.shop_tab = tab;
1947 self.shop_menu_index = index;
1948 self.shop_quantity = qty;
1949 } else {
1950 self.shop_tab = ShopTab::Buy;
1951 self.shop_menu_index = 0;
1952 self.shop_quantity = 1;
1953 self.clear_shop_trade_log();
1954 }
1955 self.show_npc_verb_menu = false;
1956 self.clamp_shop_selection();
1957 }
1958
1959 pub fn apply_bank_panel(&mut self, panel: flatland_protocol::BankPanel) {
1960 let same_teller = self
1961 .bank_panel
1962 .as_ref()
1963 .is_some_and(|p| p.npc_id == panel.npc_id);
1964 self.bank_panel = Some(panel);
1965 self.storage_panel = None;
1966 self.market_panel = None;
1967 self.shop_catalog = None;
1968 self.show_shop_menu = false;
1969 self.show_craft_menu = false;
1970 self.show_inventory_menu = false;
1971 self.show_stats = false;
1972 self.show_npc_verb_menu = false;
1973 self.show_npc_chat = false;
1974 self.npc_chat = None;
1975 if !same_teller {
1976 self.bank_menu_index = 0;
1977 self.bank_ui_mode = BankUiMode::Menu;
1978 }
1979 if let Some(panel) = &self.bank_panel {
1980 if self.npc_verb_target.is_none() {
1981 self.npc_verb_target = Some(panel.npc_id.clone());
1982 }
1983 }
1984 }
1985
1986 pub fn apply_storage_panel(&mut self, panel: flatland_protocol::StoragePanel) {
1987 let same_manager = self
1988 .storage_panel
1989 .as_ref()
1990 .is_some_and(|p| p.npc_id == panel.npc_id);
1991 self.storage_panel = Some(panel);
1992 self.bank_panel = None;
1993 self.market_panel = None;
1994 self.bank_ui_mode = BankUiMode::Menu;
1995 self.shop_catalog = None;
1996 self.show_shop_menu = false;
1997 self.show_craft_menu = false;
1998 self.show_inventory_menu = false;
1999 self.show_stats = false;
2000 self.show_npc_verb_menu = false;
2001 self.show_npc_chat = false;
2002 self.npc_chat = None;
2003 if !same_manager {
2004 self.storage_menu_index = 0;
2005 self.storage_ui_mode = StorageUiMode::Menu;
2006 } else {
2007 self.clamp_storage_pick_index();
2008 }
2009 if let Some(panel) = &self.storage_panel {
2010 if self.npc_verb_target.is_none() {
2011 self.npc_verb_target = Some(panel.npc_id.clone());
2012 }
2013 }
2014 }
2015
2016 pub fn apply_market_panel(&mut self, panel: flatland_protocol::MarketPanel) {
2017 self.market_panel = Some(panel);
2018 self.bank_panel = None;
2019 self.storage_panel = None;
2020 self.shop_catalog = None;
2021 self.show_shop_menu = false;
2022 self.show_craft_menu = false;
2023 self.show_inventory_menu = false;
2024 self.show_stats = false;
2025 self.show_npc_verb_menu = false;
2026 self.show_npc_chat = false;
2027 self.npc_chat = None;
2028 self.market_menu_index = 0;
2029 self.market_buy_confirm = None;
2030 self.market_ui_mode = MarketUiMode::Browse;
2031 self.market_filter.clear();
2032 self.market_filter_focused = false;
2033 self.market_category_filter = None;
2034 if let Some(panel) = &self.market_panel {
2035 if self.npc_verb_target.is_none() {
2036 self.npc_verb_target = Some(panel.npc_id.clone());
2037 }
2038 }
2039 }
2040
2041 pub fn clear_market_panel(&mut self) {
2042 self.market_panel = None;
2043 self.market_menu_index = 0;
2044 self.market_buy_confirm = None;
2045 self.market_ui_mode = MarketUiMode::Browse;
2046 self.market_filter.clear();
2047 self.market_filter_focused = false;
2048 self.market_category_filter = None;
2049 }
2050
2051 pub fn clear_bank_panel(&mut self) {
2052 self.bank_panel = None;
2053 self.bank_menu_index = 0;
2054 self.bank_ui_mode = BankUiMode::Menu;
2055 }
2056
2057 pub fn clear_storage_panel(&mut self) {
2058 self.storage_panel = None;
2059 self.storage_menu_index = 0;
2060 self.storage_ui_mode = StorageUiMode::Menu;
2061 }
2062
2063 fn clamp_storage_pick_index(&mut self) {
2064 match &self.storage_ui_mode {
2065 StorageUiMode::StorePick { index } => {
2066 let n = self.storage_store_options().len();
2067 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2068 self.storage_ui_mode = StorageUiMode::StorePick { index: next };
2069 }
2070 StorageUiMode::TakePick { index } => {
2071 let n = self.storage_vault_options().len();
2072 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2073 self.storage_ui_mode = StorageUiMode::TakePick { index: next };
2074 }
2075 StorageUiMode::ShipPick {
2076 dest_building_id,
2077 dest_label,
2078 index,
2079 } => {
2080 let n = self.storage_vault_options().len();
2081 let next = if n == 0 { 0 } else { (*index).min(n - 1) };
2082 self.storage_ui_mode = StorageUiMode::ShipPick {
2083 dest_building_id: dest_building_id.clone(),
2084 dest_label: dest_label.clone(),
2085 index: next,
2086 };
2087 }
2088 StorageUiMode::Menu
2089 | StorageUiMode::StoreAmount { .. }
2090 | StorageUiMode::TakeAmount { .. }
2091 | StorageUiMode::ShipAmount { .. } => {}
2092 }
2093 }
2094
2095 pub fn shop_list_len(&self) -> usize {
2096 let Some(catalog) = &self.shop_catalog else {
2097 return 0;
2098 };
2099 match self.shop_tab {
2100 ShopTab::Buy => catalog.sells.len(),
2101 ShopTab::Sell => catalog.buys.len(),
2102 }
2103 }
2104
2105 pub fn shop_menu_move(&mut self, delta: i32) {
2106 let n = self.shop_list_len();
2107 if n == 0 {
2108 return;
2109 }
2110 let idx = self.shop_menu_index as i32;
2111 let next = (idx + delta).rem_euclid(n as i32);
2112 self.shop_menu_index = next as usize;
2113 self.clamp_shop_quantity();
2114 }
2115
2116 pub fn shop_quantity_adjust(&mut self, delta: i32) {
2117 let max = self.shop_quantity_max();
2118 if max == 0 {
2119 self.shop_quantity = 0;
2120 return;
2121 }
2122 let next = (self.shop_quantity as i32 + delta).clamp(1, max as i32);
2123 self.shop_quantity = next as u32;
2124 }
2125
2126 pub(crate) fn clamp_shop_selection(&mut self) {
2127 let n = self.shop_list_len();
2128 if n == 0 {
2129 self.shop_menu_index = 0;
2130 } else {
2131 self.shop_menu_index = self.shop_menu_index.min(n - 1);
2132 }
2133 self.clamp_shop_quantity();
2134 }
2135
2136 fn shop_quantity_max(&self) -> u32 {
2137 let Some(catalog) = &self.shop_catalog else {
2138 return 1;
2139 };
2140 match self.shop_tab {
2141 ShopTab::Buy => {
2142 if let Some(offer) = catalog.sells.get(self.shop_menu_index) {
2143 if offer.kind == flatland_protocol::ShopOfferKind::Blueprint {
2144 return 1;
2145 }
2146 }
2147 99
2148 }
2149 ShopTab::Sell => catalog
2150 .buys
2151 .get(self.shop_menu_index)
2152 .map(|l| l.quantity)
2153 .unwrap_or(0),
2154 }
2155 }
2156
2157 pub fn shop_quantity_set_max(&mut self) {
2158 self.shop_quantity = self.shop_quantity_max();
2159 }
2160
2161 pub fn shop_quantity_set_min(&mut self) {
2162 let max = self.shop_quantity_max();
2163 self.shop_quantity = if max == 0 { 0 } else { 1 };
2164 }
2165
2166 fn clamp_shop_quantity(&mut self) {
2167 let max = self.shop_quantity_max();
2168 if max == 0 {
2169 self.shop_quantity = 0;
2170 } else {
2171 self.shop_quantity = self.shop_quantity.max(1).min(max);
2172 }
2173 }
2174
2175 pub fn player_at_station_tag(&self, tag: &str) -> bool {
2176 let Some(id) = self.effective_inside_building() else {
2177 return false;
2178 };
2179 self.buildings
2180 .iter()
2181 .find(|b| b.id == id)
2182 .is_some_and(|b| b.tags.iter().any(|t| t == tag))
2183 }
2184
2185 pub fn craft_missing_hint(&self, blueprint: &BlueprintView) -> Option<String> {
2187 if self.can_craft_blueprint(blueprint) {
2188 return None;
2189 }
2190 let mut missing = Vec::new();
2191 for input in &blueprint.inputs {
2192 let have = self.inventory.get(&input.template_id).copied().unwrap_or(0);
2193 if have < input.quantity {
2194 let name = self.blueprint_ingredient_label(input);
2195 missing.push(format!("{}×{} (have {have})", input.quantity, name));
2196 }
2197 }
2198 for tool in &blueprint.required_tools {
2199 let have = self.inventory.get(&tool.item).copied().unwrap_or(0);
2200 if have < 1 {
2201 missing.push(format!("tool: {}", self.blueprint_tool_label(tool)));
2202 }
2203 }
2204 if let Some(station) = blueprint.station.as_deref() {
2205 if station != "hand" && !self.player_at_station_tag(station) {
2206 missing.push(format!("station: {station} (enter building)"));
2207 }
2208 }
2209 if missing.is_empty() {
2210 None
2211 } else {
2212 Some(missing.join(", "))
2213 }
2214 }
2215
2216 pub fn player_entity(&self) -> Option<&EntityState> {
2217 self.player
2218 .as_ref()
2219 .or_else(|| self.entities.iter().find(|e| e.id == self.entity_id))
2220 }
2221
2222 pub fn apply_client_ui_prefs(&mut self) {
2224 let cfg = crate::client_config::ClientConfig::load();
2225 if let Some(hidden) = cfg.hud_log_hidden {
2226 self.hud_log_hidden = hidden;
2227 }
2228 if let Some(compact) = cfg.workers_menu_compact {
2229 self.workers_menu_compact = compact;
2230 }
2231 }
2232
2233 pub fn player_position(&self) -> (f32, f32) {
2234 let (x, y, _) = self.player_position_with_z();
2235 (x, y)
2236 }
2237
2238 pub fn player_position_with_z(&self) -> (f32, f32, f32) {
2239 if let Some(p) = self.player_entity() {
2240 (
2241 p.transform.position.x,
2242 p.transform.position.y,
2243 p.transform.position.z,
2244 )
2245 } else {
2246 (0.0, 0.0, 0.0)
2247 }
2248 }
2249
2250 pub fn sorted_inventory(&self) -> Vec<(String, u32, String)> {
2251 let mut rows: Vec<(String, u32, String)> = self
2252 .inventory
2253 .iter()
2254 .filter(|(_, q)| **q > 0)
2255 .map(|(id, qty)| {
2256 let label = self
2257 .inventory_hints
2258 .get(id)
2259 .map(|h| h.display_name.clone())
2260 .unwrap_or_else(|| id.clone());
2261 (id.clone(), *qty, label)
2262 })
2263 .collect();
2264 rows.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
2265 rows
2266 }
2267
2268 pub fn inventory_item_category(&self, template_id: &str) -> Option<&str> {
2269 self.inventory_hints
2270 .get(template_id)
2271 .map(|h| h.category.as_str())
2272 .filter(|c| !c.is_empty())
2273 }
2274
2275 pub fn stack_is_item_grant(stack: &flatland_protocol::ItemStack) -> bool {
2276 stack
2277 .props
2278 .get("grants_item_status_effect")
2279 .map(|s| !s.is_empty())
2280 .unwrap_or(false)
2281 }
2282
2283 pub fn grant_effect_id(stack: &flatland_protocol::ItemStack) -> Option<&str> {
2284 stack
2285 .props
2286 .get("grants_item_status_effect")
2287 .map(String::as_str)
2288 .filter(|s| !s.is_empty())
2289 }
2290
2291 pub fn grant_mode(stack: &flatland_protocol::ItemStack) -> &str {
2292 stack
2293 .props
2294 .get("grants_item_status_mode")
2295 .map(String::as_str)
2296 .unwrap_or("on_hit")
2297 }
2298
2299 pub fn grant_target_options(
2301 &self,
2302 grant: &flatland_protocol::ItemStack,
2303 ) -> Vec<GrantTargetOption> {
2304 let mode = Self::grant_mode(grant);
2305 let grant_tags: Vec<&str> = grant
2306 .props
2307 .get("grants_item_status_tags")
2308 .map(|s| {
2309 s.split(',')
2310 .map(str::trim)
2311 .filter(|t| !t.is_empty())
2312 .collect()
2313 })
2314 .unwrap_or_default();
2315 let grant_id = grant.item_instance_id;
2316 let mut out = Vec::new();
2317 let mut push = |stack: &flatland_protocol::ItemStack, where_label: &str| {
2318 let Some(iid) = stack.item_instance_id else {
2319 return;
2320 };
2321 if Some(iid) == grant_id {
2322 return;
2323 }
2324 if stack.props.get("enchantable").map(String::as_str) == Some("0") {
2325 return;
2326 }
2327 if !grant_target_matches_mode(stack, mode) {
2328 return;
2329 }
2330 if !grant_tags_match(stack, &grant_tags) {
2331 return;
2332 }
2333 let name = stack
2334 .display_name
2335 .clone()
2336 .unwrap_or_else(|| stack.template_id.clone());
2337 let bindings = if stack.status_bindings.is_empty() {
2338 String::new()
2339 } else {
2340 format!(
2341 " · {}",
2342 stack
2343 .status_bindings
2344 .iter()
2345 .map(|b| b.effect_id.as_str())
2346 .collect::<Vec<_>>()
2347 .join(", ")
2348 )
2349 };
2350 out.push(GrantTargetOption {
2351 label: format!("{where_label}: {name}{bindings}"),
2352 target_instance_id: iid,
2353 });
2354 };
2355 fn walk(
2356 stacks: &[flatland_protocol::ItemStack],
2357 where_label: &str,
2358 push: &mut dyn FnMut(&flatland_protocol::ItemStack, &str),
2359 ) {
2360 for s in stacks {
2361 push(s, where_label);
2362 if !s.contents.is_empty() {
2363 let nested = format!(
2364 "{where_label}/{}",
2365 s.display_name
2366 .as_deref()
2367 .unwrap_or(s.template_id.as_str())
2368 );
2369 walk(&s.contents, &nested, push);
2370 }
2371 }
2372 }
2373 walk(&self.inventory_stacks, "Bag", &mut push);
2374 for (slot, stack) in &self.worn {
2375 push(stack, body_slot_label(*slot));
2376 let nest = format!(
2377 "{}/{}",
2378 body_slot_label(*slot),
2379 stack
2380 .display_name
2381 .as_deref()
2382 .unwrap_or(stack.template_id.as_str())
2383 );
2384 walk(&stack.contents, &nest, &mut push);
2385 }
2386 out
2387 }
2388
2389 pub fn item_base_mass(&self, template_id: &str) -> f32 {
2390 self.inventory_hints
2391 .get(template_id)
2392 .and_then(|h| h.base_mass)
2393 .unwrap_or(0.5)
2394 }
2395
2396 pub fn item_base_volume(&self, template_id: &str) -> f32 {
2397 self.inventory_hints
2398 .get(template_id)
2399 .and_then(|h| h.base_volume)
2400 .unwrap_or(1.0)
2401 }
2402
2403 pub fn stack_mass(&self, stack: &flatland_protocol::ItemStack) -> f32 {
2404 let unit = stack
2405 .base_mass
2406 .unwrap_or_else(|| self.item_base_mass(&stack.template_id));
2407 unit * stack.quantity as f32
2408 }
2409
2410 fn stack_tree_volume(stack: &flatland_protocol::ItemStack) -> f32 {
2411 let unit = stack.base_volume.unwrap_or(1.0);
2412 unit * stack.quantity as f32
2413 + stack
2414 .contents
2415 .iter()
2416 .map(Self::stack_tree_volume)
2417 .sum::<f32>()
2418 }
2419
2420 fn contents_used_volume(contents: &[flatland_protocol::ItemStack]) -> f32 {
2421 contents.iter().map(Self::stack_tree_volume).sum()
2422 }
2423
2424 fn template_capacity_volume(&self, template_id: &str) -> Option<f32> {
2425 self.inventory_hints
2426 .get(template_id)
2427 .and_then(|h| h.capacity_volume)
2428 .filter(|c| *c > 0.0)
2429 }
2430
2431 fn stack_capacity_volume(&self, stack: &flatland_protocol::ItemStack) -> Option<f32> {
2432 stack
2433 .capacity_volume
2434 .filter(|c| *c > 0.0)
2435 .or_else(|| self.template_capacity_volume(&stack.template_id))
2436 }
2437
2438 pub fn container_volume_label(&self, row: &InventoryRow) -> String {
2440 let Some((used, cap)) = self.container_volume_stats(row) else {
2441 return String::new();
2442 };
2443 let free = (cap - used).max(0.0);
2444 format!(" vol {used:.0}/{cap:.0} ({free:.0} free)")
2445 }
2446
2447 fn container_volume_stats(&self, row: &InventoryRow) -> Option<(f32, f32)> {
2448 if row.is_chest_shell {
2449 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
2450 return None;
2451 };
2452 let chest = self
2453 .placed_containers
2454 .iter()
2455 .find(|c| c.id == *container_id)?;
2456 let cap = self
2457 .stack_capacity_volume(&row.stack)
2458 .or(chest.capacity_volume.filter(|c| *c > 0.0))?;
2459 let used = if chest.accessible {
2460 Self::contents_used_volume(&chest.contents)
2461 } else {
2462 0.0
2463 };
2464 return Some((used, cap));
2465 }
2466
2467 let cap = self.stack_capacity_volume(&row.stack)?;
2468 let used = Self::contents_used_volume(&row.stack.contents);
2469 Some((used, cap))
2470 }
2471
2472 pub fn row_is_renameable_container(&self, row: &InventoryRow) -> bool {
2473 if row.is_chest_shell {
2474 return true;
2475 }
2476 if row.is_equip_shell {
2477 return self.inventory_item_category(&row.stack.template_id) == Some("container");
2478 }
2479 self.inventory_item_category(&row.stack.template_id) == Some("container")
2480 || row.stack.capacity_volume.is_some_and(|c| c > 0.0)
2481 }
2482
2483 fn container_stack_for(
2484 &self,
2485 location: &flatland_protocol::InventoryLocation,
2486 parent_instance_id: Option<uuid::Uuid>,
2487 ) -> Option<flatland_protocol::ItemStack> {
2488 match location {
2489 flatland_protocol::InventoryLocation::Root => {
2490 let pid = parent_instance_id?;
2491 self.find_stack_by_instance(&self.inventory_stacks, pid)
2492 }
2493 flatland_protocol::InventoryLocation::Worn { slot } => {
2494 let worn = self.worn.get(slot)?;
2495 if parent_instance_id.is_none_or(|id| worn.item_instance_id == Some(id)) {
2496 Some(worn.clone())
2497 } else {
2498 self.find_stack_by_instance(&worn.contents, parent_instance_id?)
2499 }
2500 }
2501 flatland_protocol::InventoryLocation::Placed { container_id } => {
2502 let chest = self
2503 .placed_containers
2504 .iter()
2505 .find(|c| c.id == *container_id)?;
2506 if parent_instance_id.is_none_or(|id| chest.item_instance_id == Some(id)) {
2507 Some(flatland_protocol::ItemStack {
2508 template_id: chest.template_id.clone(),
2509 quantity: 1,
2510 item_instance_id: chest.item_instance_id,
2511 props: Default::default(),
2512 status_bindings: Vec::new(),
2513 contents: chest.contents.clone(),
2514 display_name: Some(chest.display_name.clone()),
2515 category: Some("container".into()),
2516 capacity_volume: self
2517 .inventory_hints
2518 .get(&chest.template_id)
2519 .and_then(|h| h.capacity_volume),
2520 worker_lodging_capacity: chest.worker_lodging_capacity,
2521 ..Default::default()
2522 })
2523 } else {
2524 self.find_stack_by_instance(&chest.contents, parent_instance_id?)
2525 }
2526 }
2527 flatland_protocol::InventoryLocation::Keychain => None,
2528 flatland_protocol::InventoryLocation::WhisperPouch => None,
2529 }
2530 }
2531
2532 fn find_stack_by_instance(
2533 &self,
2534 stacks: &[flatland_protocol::ItemStack],
2535 instance_id: uuid::Uuid,
2536 ) -> Option<flatland_protocol::ItemStack> {
2537 for stack in stacks {
2538 if stack.item_instance_id == Some(instance_id) {
2539 return Some(stack.clone());
2540 }
2541 if let Some(found) = self.find_stack_by_instance(&stack.contents, instance_id) {
2542 return Some(found);
2543 }
2544 }
2545 None
2546 }
2547
2548 pub fn max_movable_to(
2550 &self,
2551 template_id: &str,
2552 stack_qty: u32,
2553 from: &flatland_protocol::InventoryLocation,
2554 to: &flatland_protocol::InventoryLocation,
2555 parent_instance_id: Option<uuid::Uuid>,
2556 ) -> u32 {
2557 let unit_vol = self.item_base_volume(template_id);
2558 let unit_mass = self.item_base_mass(template_id);
2559 let mut limit = stack_qty;
2560
2561 if let Some(parent) = self.container_stack_for(to, parent_instance_id) {
2562 let cap = parent
2563 .capacity_volume
2564 .or_else(|| {
2565 self.inventory_hints
2566 .get(&parent.template_id)
2567 .and_then(|h| h.capacity_volume)
2568 })
2569 .unwrap_or(0.0);
2570 if cap > 0.0 && unit_vol > 0.0 {
2571 let remaining = (cap - Self::contents_used_volume(&parent.contents)).max(0.0);
2572 limit = limit.min((remaining / unit_vol).floor().max(0.0) as u32);
2573 }
2574 }
2575
2576 let to_person = matches!(
2577 to,
2578 flatland_protocol::InventoryLocation::Root
2579 | flatland_protocol::InventoryLocation::Worn { .. }
2580 );
2581 let from_placed = matches!(from, flatland_protocol::InventoryLocation::Placed { .. });
2582 if to_person && from_placed && unit_mass > 0.0 {
2583 let headroom = (self.carry_mass_max - self.carry_mass).max(0.0);
2584 if self.encumbrance == flatland_protocol::EncumbranceState::Over {
2585 limit = 0;
2586 } else {
2587 limit = limit.min((headroom / unit_mass).floor().max(0.0) as u32);
2588 }
2589 }
2590
2591 limit.max(0).min(stack_qty)
2592 }
2593
2594 pub fn move_picker_max_at_selection(&self) -> u32 {
2595 let Some(picker) = &self.move_picker else {
2596 return 1;
2597 };
2598 let Some(opt) = picker.options.get(self.move_picker_index) else {
2599 return picker.stack_quantity;
2600 };
2601 match &opt.kind {
2602 MoveOptionKind::Cancel
2603 | MoveOptionKind::Drop
2604 | MoveOptionKind::Use
2605 | MoveOptionKind::GrantApply
2606 | MoveOptionKind::SellPlotToCrown { .. }
2607 | MoveOptionKind::PickupPlaced { .. }
2608 | MoveOptionKind::RelocatePlaced { .. } => picker.stack_quantity,
2609 MoveOptionKind::Move {
2610 location,
2611 parent_instance_id,
2612 } => self.max_movable_to(
2613 &picker.template_id,
2614 picker.stack_quantity,
2615 &picker.from,
2616 location,
2617 *parent_instance_id,
2618 ),
2619 }
2620 }
2621
2622 pub fn clamp_move_picker_quantity(&mut self) {
2623 let max = self.move_picker_max_at_selection();
2624 if let Some(picker) = &mut self.move_picker {
2625 if max == 0 {
2626 picker.quantity = 1;
2627 } else {
2628 picker.quantity = picker.quantity.clamp(1, max);
2629 }
2630 }
2631 }
2632
2633 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
2634 let max = self.move_picker_max_at_selection().max(1);
2635 if let Some(picker) = &mut self.move_picker {
2636 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2637 picker.quantity = next as u32;
2638 }
2639 }
2640
2641 pub fn move_picker_set_quantity_max(&mut self) {
2642 let max = self.move_picker_max_at_selection();
2643 if let Some(picker) = &mut self.move_picker {
2644 picker.quantity = if max == 0 {
2645 1
2646 } else {
2647 max.min(picker.stack_quantity)
2648 };
2649 }
2650 }
2651
2652 pub fn move_picker_set_quantity_min(&mut self) {
2653 if let Some(picker) = &mut self.move_picker {
2654 picker.quantity = 1;
2655 }
2656 }
2657
2658 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
2659 if let Some(picker) = &mut self.destroy_picker {
2660 let max = picker.stack_quantity.max(1);
2661 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
2662 picker.quantity = next as u32;
2663 }
2664 }
2665
2666 pub fn destroy_picker_set_quantity_max(&mut self) {
2667 if let Some(picker) = &mut self.destroy_picker {
2668 picker.quantity = picker.stack_quantity.max(1);
2669 }
2670 }
2671
2672 pub fn destroy_picker_set_quantity_min(&mut self) {
2673 if let Some(picker) = &mut self.destroy_picker {
2674 picker.quantity = 1;
2675 }
2676 }
2677
2678 pub fn ingredient_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2679 let have = self.inventory.get(template_id).copied().unwrap_or(0);
2680 (have, have >= need)
2681 }
2682
2683 pub fn plot_build_stock_status(&self, template_id: &str, need: u32) -> (u32, bool) {
2685 let have = self
2686 .plot_build_offer
2687 .as_ref()
2688 .and_then(|o| {
2689 o.available
2690 .iter()
2691 .find(|s| s.template_id == template_id)
2692 .map(|s| s.quantity)
2693 })
2694 .unwrap_or_else(|| self.inventory.get(template_id).copied().unwrap_or(0));
2695 (have, have >= need)
2696 }
2697
2698 pub fn plot_build_wall_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
2699 self.building_materials
2700 .iter()
2701 .filter(|m| m.can_wall)
2702 .collect()
2703 }
2704
2705 pub fn plot_build_roof_options(&self) -> Vec<&flatland_protocol::BuildingMaterialView> {
2706 self.building_materials
2707 .iter()
2708 .filter(|m| m.can_roof)
2709 .collect()
2710 }
2711
2712 pub fn plot_build_selected_wall(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
2713 self.plot_build_wall_options()
2714 .get(self.plot_build_wall_index)
2715 .copied()
2716 }
2717
2718 pub fn plot_build_selected_roof(&self) -> Option<&flatland_protocol::BuildingMaterialView> {
2719 self.plot_build_roof_options()
2720 .get(self.plot_build_roof_index)
2721 .copied()
2722 }
2723
2724 pub fn plot_build_bom_lines(&self) -> Vec<(String, String, u32)> {
2726 let Some(wall) = self.plot_build_selected_wall() else {
2727 return Vec::new();
2728 };
2729 let Some(roof) = self.plot_build_selected_roof() else {
2730 return Vec::new();
2731 };
2732 let area = self
2733 .plot_build_offer
2734 .as_ref()
2735 .filter(|o| o.pad_ok)
2736 .map(|o| o.pad_width_m * o.pad_depth_m)
2737 .unwrap_or(0.0);
2738 if area <= 0.0 {
2739 return Vec::new();
2740 }
2741 let mut map: std::collections::HashMap<String, (String, u32)> =
2742 std::collections::HashMap::new();
2743 for line in &wall.wall_bom {
2744 let qty = (area * line.per_m2).ceil().max(0.0) as u32;
2745 if qty == 0 {
2746 continue;
2747 }
2748 let name = if line.display_name.is_empty() {
2749 line.template_id.clone()
2750 } else {
2751 line.display_name.clone()
2752 };
2753 let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
2754 entry.1 = entry.1.saturating_add(qty);
2755 }
2756 for line in &roof.roof_bom {
2757 let qty = (area * line.per_m2).ceil().max(0.0) as u32;
2758 if qty == 0 {
2759 continue;
2760 }
2761 let name = if line.display_name.is_empty() {
2762 line.template_id.clone()
2763 } else {
2764 line.display_name.clone()
2765 };
2766 let entry = map.entry(line.template_id.clone()).or_insert((name, 0));
2767 entry.1 = entry.1.saturating_add(qty);
2768 }
2769 let mut out: Vec<_> = map
2770 .into_iter()
2771 .map(|(id, (name, qty))| (id, name, qty))
2772 .collect();
2773 out.sort_by(|a, b| a.0.cmp(&b.0));
2774 out
2775 }
2776
2777 pub fn plot_build_duration_secs(&self) -> Option<f32> {
2778 let wall = self.plot_build_selected_wall()?;
2779 let roof = self.plot_build_selected_roof()?;
2780 let offer = self.plot_build_offer.as_ref()?;
2781 if !offer.pad_ok {
2782 return None;
2783 }
2784 let area = offer.pad_width_m * offer.pad_depth_m;
2785 let mult = wall.tick_mult.max(roof.tick_mult).max(0.1);
2786 let ticks = (offer.base_ticks as f32 + area * offer.tick_per_m2 as f32 * mult).ceil();
2787 Some(ticks.max(2.0) / 30.0)
2788 }
2789
2790 pub fn plot_build_can_afford(&self) -> bool {
2791 if self
2792 .plot_build_offer
2793 .as_ref()
2794 .is_none_or(|o| !o.pad_ok)
2795 {
2796 return false;
2797 }
2798 self.plot_build_bom_lines()
2799 .iter()
2800 .all(|(id, _, need)| self.plot_build_stock_status(id, *need).1)
2801 }
2802
2803 pub fn currency_display(&self) -> String {
2804 crate::currency::currency_line(&self.inventory)
2805 }
2806
2807 pub fn in_shallow_water(&self) -> bool {
2809 let (px, py) = self.player_position();
2810 self.terrain_at(px, py)
2811 .is_some_and(|k| k == TerrainKindView::ShallowWater)
2812 }
2813
2814 pub fn terrain_at(&self, x: f32, y: f32) -> Option<TerrainKindView> {
2815 self.terrain_zone_at(x, y).map(|z| z.kind)
2816 }
2817
2818 pub fn terrain_zone_at(&self, x: f32, y: f32) -> Option<&TerrainZoneView> {
2820 use std::cell::RefCell;
2821
2822 const CHUNK: i32 = 8;
2823 thread_local! {
2824 static INDEX: RefCell<Option<(*const TerrainZoneView, usize, std::collections::HashMap<(i32, i32), Vec<usize>>)>> =
2825 RefCell::new(None);
2826 }
2827
2828 let zones = &self.terrain_zones;
2829 if zones.is_empty() {
2830 return None;
2831 }
2832 if zones.len() <= 48 {
2833 return zones
2834 .iter()
2835 .enumerate()
2836 .filter(|(_, z)| x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1)
2837 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
2838 .map(|(_, z)| z);
2839 }
2840
2841 let ptr = zones.as_ptr();
2842 let len = zones.len();
2843 INDEX.with(|cell| {
2844 let mut slot = cell.borrow_mut();
2845 let stale = match slot.as_ref() {
2846 Some((p, l, _)) => *p != ptr || *l != len,
2847 None => true,
2848 };
2849 if stale {
2850 let mut chunks: std::collections::HashMap<(i32, i32), Vec<usize>> =
2851 std::collections::HashMap::new();
2852 for (zi, z) in zones.iter().enumerate() {
2853 let x0 = z.x0.min(z.x1).floor() as i32;
2854 let y0 = z.y0.min(z.y1).floor() as i32;
2855 let x1 = (z.x0.max(z.x1).ceil() as i32 - 1).max(x0);
2856 let y1 = (z.y0.max(z.y1).ceil() as i32 - 1).max(y0);
2857 let cx0 = x0.div_euclid(CHUNK);
2858 let cy0 = y0.div_euclid(CHUNK);
2859 let cx1 = x1.div_euclid(CHUNK);
2860 let cy1 = y1.div_euclid(CHUNK);
2861 for cy in cy0..=cy1 {
2862 for cx in cx0..=cx1 {
2863 chunks.entry((cx, cy)).or_default().push(zi);
2864 }
2865 }
2866 }
2867 *slot = Some((ptr, len, chunks));
2868 }
2869 let chunks = &slot.as_ref().expect("index").2;
2870 let cx = (x.floor() as i32).div_euclid(CHUNK);
2871 let cy = (y.floor() as i32).div_euclid(CHUNK);
2872 let mut best: Option<(usize, &TerrainZoneView)> = None;
2873 if let Some(list) = chunks.get(&(cx, cy)) {
2874 for &zi in list {
2875 let Some(z) = zones.get(zi) else { continue };
2876 if !(x >= z.x0 && x <= z.x1 && y >= z.y0 && y <= z.y1) {
2877 continue;
2878 }
2879 best = match best {
2880 None => Some((zi, z)),
2881 Some((bi, bz)) => {
2882 if z.z_order > bz.z_order || (z.z_order == bz.z_order && zi > bi) {
2883 Some((zi, z))
2884 } else {
2885 Some((bi, bz))
2886 }
2887 }
2888 };
2889 }
2890 }
2891 best.map(|(_, z)| z)
2892 })
2893 }
2894
2895 pub fn elevation_at(&self, x: f32, y: f32) -> f32 {
2897 self.terrain_zone_at(x, y)
2898 .map(|z| z.elevation)
2899 .unwrap_or(0.0)
2900 }
2901
2902 pub fn walkable_levels_at(&self, x: f32, y: f32) -> Vec<f32> {
2904 const TOL: f32 = 0.35;
2905 let mut levels = vec![self.elevation_at(x, y)];
2906 for p in &self.z_platforms {
2907 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2908 levels.push(p.z);
2909 }
2910 }
2911 levels.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
2912 levels.dedup_by(|a, b| (*a - *b).abs() < TOL);
2913 levels
2914 }
2915
2916 pub fn is_walkable_at_z(&self, x: f32, y: f32, z: f32) -> bool {
2917 const TOL: f32 = 0.35;
2918 self.walkable_levels_at(x, y)
2919 .iter()
2920 .any(|&l| (l - z).abs() <= TOL)
2921 }
2922
2923 pub fn surface_elevation_at(&self, x: f32, y: f32) -> f32 {
2924 let mut top = self.elevation_at(x, y);
2925 for p in &self.z_platforms {
2926 if x >= p.x0 && x <= p.x1 && y >= p.y0 && y <= p.y1 {
2927 top = top.max(p.z);
2928 }
2929 }
2930 top
2931 }
2932
2933 pub fn effective_inside_building(&self) -> Option<String> {
2935 self.player_entity().and_then(|p| p.inside_building.clone())
2936 }
2937
2938 pub fn sync_inventory_from_stacks(&mut self, stacks: &[flatland_protocol::ItemStack]) {
2939 self.inventory_stacks = stacks.to_vec();
2940 self.inventory.clear();
2941 self.inventory_hints.clear();
2942 fn walk(
2943 stacks: &[flatland_protocol::ItemStack],
2944 inventory: &mut std::collections::HashMap<String, u32>,
2945 hints: &mut std::collections::HashMap<String, InventoryHint>,
2946 ) {
2947 for stack in stacks {
2948 *inventory.entry(stack.template_id.clone()).or_insert(0) += stack.quantity;
2949 if stack.display_name.is_some()
2950 || stack.category.is_some()
2951 || stack.base_mass.is_some()
2952 || stack.base_volume.is_some()
2953 {
2954 hints.insert(
2955 stack.template_id.clone(),
2956 InventoryHint {
2957 display_name: stack
2958 .display_name
2959 .clone()
2960 .unwrap_or_else(|| stack.template_id.clone()),
2961 category: stack.category.clone().unwrap_or_default(),
2962 base_mass: stack.base_mass,
2963 base_volume: stack.base_volume,
2964 capacity_volume: stack.capacity_volume,
2965 stackable: stack.stackable.unwrap_or(true),
2966 listable: stack.listable.unwrap_or_else(|| {
2967 category_default_listable(
2968 stack.category.as_deref().unwrap_or(""),
2969 )
2970 }),
2971 },
2972 );
2973 }
2974 walk(&stack.contents, inventory, hints);
2975 }
2976 }
2977 walk(stacks, &mut self.inventory, &mut self.inventory_hints);
2978 for item in self.worn.values() {
2980 walk(
2981 std::slice::from_ref(item),
2982 &mut self.inventory,
2983 &mut self.inventory_hints,
2984 );
2985 }
2986 }
2987
2988 pub fn apply_interaction_notice(&mut self, notice: &flatland_protocol::InteractionNotice) {
2991 let subtract_items =
2992 notice.message.starts_with("Sold ") || notice.message.starts_with("Consumed ");
2993 for stack in ¬ice.inventory_delta {
2994 if stack.quantity == 0 {
2995 continue;
2996 }
2997 if subtract_items {
2998 crate::currency::drain_template_stacks(
2999 &mut self.inventory_stacks,
3000 &stack.template_id,
3001 stack.quantity,
3002 );
3003 continue;
3004 }
3005 let stackable = self
3006 .inventory_hints
3007 .get(&stack.template_id)
3008 .map(|h| h.stackable)
3009 .or(stack.stackable)
3010 .unwrap_or(true);
3011 if stackable {
3012 if let Some(existing) = self
3013 .inventory_stacks
3014 .iter_mut()
3015 .find(|s| s.template_id == stack.template_id)
3016 {
3017 existing.quantity = existing.quantity.saturating_add(stack.quantity);
3018 if stack.display_name.is_some() {
3019 existing.display_name = stack.display_name.clone();
3020 }
3021 if stack.category.is_some() {
3022 existing.category = stack.category.clone();
3023 }
3024 continue;
3025 }
3026 }
3027 self.inventory_stacks.push(stack.clone());
3028 }
3029 if notice.coins_delta != 0 {
3030 crate::currency::apply_coins_delta(&mut self.inventory_stacks, notice.coins_delta);
3031 }
3032 if !notice.inventory_delta.is_empty() || notice.coins_delta != 0 {
3033 let stacks = self.inventory_stacks.clone();
3034 self.sync_inventory_from_stacks(&stacks);
3035 }
3036 self.record_shop_trade_notice(notice);
3037 }
3038
3039 pub fn worn_rows(&self) -> Vec<InventoryRow> {
3044 let mut rows = Vec::new();
3045 for (slot, item) in &self.worn {
3046 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3047 rows.push(InventoryRow {
3048 depth: 0,
3049 stack: item.clone(),
3050 from: from.clone(),
3051 from_parent_instance_id: None,
3052 is_equip_shell: true,
3053 is_chest_shell: false,
3054 section: InventorySection::Worn,
3055 });
3056 for child in &item.contents {
3057 push_inventory_rows(
3058 &mut rows,
3059 1,
3060 child,
3061 &from,
3062 item.item_instance_id,
3063 InventorySection::Worn,
3064 );
3065 }
3066 }
3067 rows
3068 }
3069
3070 pub fn trade_presentable_stacks(&self) -> Vec<&flatland_protocol::ItemStack> {
3072 let equipped = self.hand_equipped_instance_ids();
3073 self.inventory_stacks
3074 .iter()
3075 .filter(|s| {
3076 s.item_instance_id
3077 .is_some_and(|id| !equipped.contains(&id))
3078 })
3079 .collect()
3080 }
3081
3082 pub fn giveable_inventory_options(&self) -> Vec<WorkerGiveOption> {
3084 let equipped = self.hand_equipped_instance_ids();
3085 self.inventory_stacks
3086 .iter()
3087 .filter_map(|stack| {
3088 let item_instance_id = stack.item_instance_id?;
3089 if equipped.contains(&item_instance_id) {
3090 return None;
3091 }
3092 let label = stack
3093 .display_name
3094 .clone()
3095 .unwrap_or_else(|| stack.template_id.clone());
3096 let label = if stack.quantity > 1 {
3097 format!("{label} ×{}", stack.quantity)
3098 } else {
3099 label
3100 };
3101 Some(WorkerGiveOption {
3102 item_instance_id,
3103 label,
3104 quantity: stack.quantity,
3105 template_id: stack.template_id.clone(),
3106 })
3107 })
3108 .collect()
3109 }
3110
3111 pub fn teachable_blueprint_options(
3113 &self,
3114 worker: &flatland_protocol::HiredWorkerView,
3115 ) -> Vec<WorkerTeachOption> {
3116 let copper = crate::currency::copper_from_counts(&self.inventory);
3117 let mut options: Vec<WorkerTeachOption> = self
3118 .blueprints
3119 .iter()
3120 .filter(|bp| !worker.known_blueprint_ids.iter().any(|k| k == &bp.id))
3121 .map(|bp| {
3122 let min_level = bp.skill.as_ref().map(|s| s.level).unwrap_or(1);
3123 let cost = bp.worker_train_copper;
3124 WorkerTeachOption {
3125 blueprint_id: bp.id.clone(),
3126 label: if bp.label.is_empty() {
3127 bp.id.clone()
3128 } else {
3129 bp.label.clone()
3130 },
3131 cost_copper: cost,
3132 min_level,
3133 worker_level: worker.level,
3134 can_afford: copper >= cost,
3135 level_ok: worker.level >= min_level,
3136 }
3137 })
3138 .collect();
3139 options.sort_by(|a, b| a.label.cmp(&b.label));
3140 options
3141 }
3142
3143 pub fn person_rows(&self) -> Vec<InventoryRow> {
3146 self.person_rows_filtered("")
3147 }
3148
3149 pub fn person_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
3150 let mut roots: Vec<&flatland_protocol::ItemStack> = self.inventory_stacks.iter().collect();
3151 roots.sort_by(|a, b| {
3152 let ca = a
3153 .category
3154 .as_deref()
3155 .or_else(|| self.inventory_item_category(&a.template_id))
3156 .unwrap_or("");
3157 let cb = b
3158 .category
3159 .as_deref()
3160 .or_else(|| self.inventory_item_category(&b.template_id))
3161 .unwrap_or("");
3162 let ga = inventory_category_group(ca).1;
3163 let gb = inventory_category_group(cb).1;
3164 ga.cmp(&gb).then_with(|| {
3165 let na = a
3166 .display_name
3167 .as_deref()
3168 .unwrap_or(a.template_id.as_str());
3169 let nb = b
3170 .display_name
3171 .as_deref()
3172 .unwrap_or(b.template_id.as_str());
3173 na.cmp(nb)
3174 })
3175 });
3176 let mut rows = Vec::new();
3177 for stack in roots {
3178 push_inventory_rows_filtered(
3179 &mut rows,
3180 0,
3181 stack,
3182 &flatland_protocol::InventoryLocation::Root,
3183 None,
3184 InventorySection::Person,
3185 filter,
3186 );
3187 }
3188 rows
3189 }
3190
3191 pub fn worn_rows_filtered(&self, filter: &str) -> Vec<InventoryRow> {
3192 if filter.is_empty() {
3193 return self.worn_rows();
3194 }
3195 let mut rows = Vec::new();
3196 for (slot, item) in &self.worn {
3197 if !stack_matches_filter(item, filter) {
3198 continue;
3199 }
3200 let from = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3201 let self_hit = {
3202 let f = filter.to_ascii_lowercase();
3203 let name = item
3204 .display_name
3205 .as_deref()
3206 .unwrap_or("")
3207 .to_ascii_lowercase();
3208 let tid = item.template_id.to_ascii_lowercase();
3209 name.contains(&f) || tid.contains(&f)
3210 };
3211 rows.push(InventoryRow {
3212 depth: 0,
3213 stack: item.clone(),
3214 from: from.clone(),
3215 from_parent_instance_id: None,
3216 is_equip_shell: true,
3217 is_chest_shell: false,
3218 section: InventorySection::Worn,
3219 });
3220 for child in &item.contents {
3221 if self_hit || stack_matches_filter(child, filter) {
3222 push_inventory_rows_filtered(
3223 &mut rows,
3224 1,
3225 child,
3226 &from,
3227 item.item_instance_id,
3228 InventorySection::Worn,
3229 if self_hit { "" } else { filter },
3230 );
3231 }
3232 }
3233 }
3234 rows
3235 }
3236
3237 pub fn inventory_tree_rows(&self) -> Vec<(usize, flatland_protocol::ItemStack)> {
3239 let mut rows = self.worn_rows();
3240 rows.extend(self.person_rows());
3241 rows.into_iter().map(|r| (r.depth, r.stack)).collect()
3242 }
3243
3244 pub fn nearby_containers(&self) -> Vec<NearbyContainer> {
3248 let (px, py) = self.player_position();
3249 let mut list: Vec<NearbyContainer> = self
3250 .placed_containers
3251 .iter()
3252 .filter_map(|c| {
3253 let distance_m = (c.x - px).hypot(c.y - py);
3254 if distance_m > CONTAINER_RANGE_M {
3255 return None;
3256 }
3257 let mut rows = Vec::new();
3258 let from = flatland_protocol::InventoryLocation::Placed {
3259 container_id: c.id.clone(),
3260 };
3261 rows.push(InventoryRow {
3262 depth: 0,
3263 stack: flatland_protocol::ItemStack {
3264 template_id: c.template_id.clone(),
3265 quantity: 1,
3266 item_instance_id: c.item_instance_id,
3267 props: Default::default(),
3268 status_bindings: Vec::new(),
3269 contents: Vec::new(),
3270 display_name: Some(c.display_name.clone()),
3271 category: Some("container".into()),
3272 capacity_volume: c.capacity_volume,
3273 worker_lodging_capacity: c.worker_lodging_capacity,
3274 ..Default::default()
3275 },
3276 from: from.clone(),
3277 from_parent_instance_id: None,
3278 is_equip_shell: false,
3279 is_chest_shell: true,
3280 section: InventorySection::Nearby,
3281 });
3282 if c.accessible {
3283 for child in &c.contents {
3284 push_inventory_rows(
3285 &mut rows,
3286 1,
3287 child,
3288 &from,
3289 c.item_instance_id,
3290 InventorySection::Nearby,
3291 );
3292 }
3293 }
3294 Some(NearbyContainer {
3295 view: c.clone(),
3296 distance_m,
3297 rows,
3298 })
3299 })
3300 .collect();
3301 list.sort_by(|a, b| {
3302 a.distance_m
3303 .partial_cmp(&b.distance_m)
3304 .unwrap_or(std::cmp::Ordering::Equal)
3305 });
3306 list
3307 }
3308
3309 pub fn nearest_placed_container(
3311 &self,
3312 max_dist: f32,
3313 ) -> Option<flatland_protocol::PlacedContainerView> {
3314 let (px, py) = self.player_position();
3315 self.placed_containers
3316 .iter()
3317 .filter(|c| (c.x - px).hypot(c.y - py) <= max_dist)
3318 .min_by(|a, b| {
3319 let da = (a.x - px).hypot(a.y - py);
3320 let db = (b.x - px).hypot(b.y - py);
3321 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
3322 })
3323 .cloned()
3324 }
3325
3326 pub fn inventory_selectable_rows(&self) -> Vec<InventoryRow> {
3329 let filter = self.inventory_filter.as_str();
3330 match self.inventory_tab {
3331 InventoryTab::OnPerson => {
3332 let mut rows = self.worn_rows_filtered(filter);
3333 rows.extend(self.person_rows_filtered(filter));
3334 rows
3335 }
3336 InventoryTab::Nearby => {
3337 let mut rows = Vec::new();
3338 for nc in self.nearby_containers() {
3339 if filter.is_empty() {
3340 rows.extend(nc.rows);
3341 continue;
3342 }
3343 let shell = nc.rows.first().cloned();
3344 let contents: Vec<_> = nc
3345 .rows
3346 .iter()
3347 .skip(1)
3348 .filter(|r| stack_matches_filter(&r.stack, filter))
3349 .cloned()
3350 .collect();
3351 let shell_hit = shell
3352 .as_ref()
3353 .map(|s| stack_matches_filter(&s.stack, filter))
3354 .unwrap_or(false);
3355 if shell_hit || !contents.is_empty() {
3356 if let Some(s) = shell {
3357 rows.push(s);
3358 }
3359 if shell_hit {
3360 rows.extend(nc.rows.into_iter().skip(1));
3361 } else {
3362 rows.extend(contents);
3363 }
3364 }
3365 }
3366 rows
3367 }
3368 }
3369 }
3370
3371 pub fn inventory_selected_row(&self) -> Option<InventoryRow> {
3372 self.inventory_selectable_rows()
3373 .into_iter()
3374 .nth(self.inventory_menu_index)
3375 }
3376
3377 fn inventory_row_base_label(&self, row: &InventoryRow) -> String {
3378 let cat = self
3379 .inventory_item_category(&row.stack.template_id)
3380 .unwrap_or("");
3381 if cat == "key" {
3382 self.key_inventory_label(&row.stack)
3383 } else {
3384 row.stack
3385 .display_name
3386 .clone()
3387 .unwrap_or_else(|| row.stack.template_id.clone())
3388 }
3389 }
3390
3391 fn inventory_row_visible_mod_signature(&self, row: &InventoryRow) -> String {
3393 let bindings = format_status_bindings_suffix(
3394 &row.stack.status_bindings,
3395 self.tick,
3396 DEFAULT_TICK_HZ,
3397 );
3398 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3399 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3400 let mode = Self::grant_mode(&row.stack);
3401 format!(" [grant {effect} · {mode} — e apply]")
3402 } else {
3403 String::new()
3404 };
3405 let qty = if row.stack.quantity > 1 {
3406 format!(" ×{}", row.stack.quantity)
3407 } else {
3408 String::new()
3409 };
3410 let worn_slot = if row.is_equip_shell {
3411 match row.from {
3412 flatland_protocol::InventoryLocation::Worn { slot } => {
3413 format!(" ({})", body_slot_label(slot))
3414 }
3415 _ => String::new(),
3416 }
3417 } else {
3418 String::new()
3419 };
3420 format!("{grant_hint}{bindings}{qty}{worn_slot}")
3421 }
3422
3423 fn inventory_row_instance_identity_key(&self, row: &InventoryRow) -> (String, String, String) {
3424 (
3425 row.stack.template_id.clone(),
3426 self.inventory_row_base_label(row),
3427 self.inventory_row_visible_mod_signature(row),
3428 )
3429 }
3430
3431 fn inventory_ambiguous_instance_identity_keys(&self) -> HashSet<(String, String, String)> {
3433 let mut counts: HashMap<(String, String, String), usize> = HashMap::new();
3434 for row in self.inventory_selectable_rows() {
3435 if row.stack.item_instance_id.is_none() {
3436 continue;
3437 }
3438 let key = self.inventory_row_instance_identity_key(&row);
3439 *counts.entry(key).or_default() += 1;
3440 }
3441 counts
3442 .into_iter()
3443 .filter(|(_, n)| *n > 1)
3444 .map(|(k, _)| k)
3445 .collect()
3446 }
3447
3448 fn format_instance_hover_tooltip(id: uuid::Uuid) -> String {
3449 let hex: String = id
3450 .as_simple()
3451 .to_string()
3452 .chars()
3453 .filter(|c| c.is_ascii_hexdigit())
3454 .collect();
3455 let short = if hex.len() >= 4 {
3456 &hex[hex.len() - 4..]
3457 } else {
3458 hex.as_str()
3459 };
3460 format!("Instance {id} (#{short})")
3461 }
3462
3463 pub fn format_inventory_row(&self, row: &InventoryRow) -> InventoryRowView {
3465 let cat = self
3466 .inventory_item_category(&row.stack.template_id)
3467 .unwrap_or("");
3468 let label = self.inventory_row_base_label(row);
3469 let hint: String = if row.is_equip_shell {
3470 " [worn — Enter to unequip]".into()
3471 } else if row.is_chest_shell {
3472 let (locked, lodging_note) = match &row.from {
3473 flatland_protocol::InventoryLocation::Placed { container_id } => {
3474 let locked = self
3475 .placed_containers
3476 .iter()
3477 .find(|c| c.id == *container_id)
3478 .map(|c| c.locked)
3479 .unwrap_or(false);
3480 let lodging_note = self
3481 .lodging_occupancy_label(container_id)
3482 .map(|who| format!(" [lodging: {who}]"))
3483 .unwrap_or_default();
3484 (locked, lodging_note)
3485 }
3486 _ => (false, String::new()),
3487 };
3488 if locked {
3489 format!(" [locked — Enter pick up · l unlock]{lodging_note}")
3490 } else {
3491 format!(" [Enter pick up · l lock]{lodging_note}")
3492 }
3493 } else if cat == "key" {
3494 self.key_inventory_hint(&row.stack)
3495 } else {
3496 match cat {
3497 "weapon" => " [weapon]".into(),
3498 "container" => " [bag/chest/belt]".into(),
3499 "lodging" => " [worker lodging]".into(),
3500 "armor" => " [armor]".into(),
3501 _ => String::new(),
3502 }
3503 };
3504 let qty = if row.stack.quantity > 1 {
3505 format!(" ×{}", row.stack.quantity)
3506 } else {
3507 String::new()
3508 };
3509 let bindings = format_status_bindings_suffix(
3510 &row.stack.status_bindings,
3511 self.tick,
3512 DEFAULT_TICK_HZ,
3513 );
3514 let grant_hint = if Self::stack_is_item_grant(&row.stack) {
3515 let effect = Self::grant_effect_id(&row.stack).unwrap_or("?");
3516 let mode = Self::grant_mode(&row.stack);
3517 format!(" [grant {effect} · {mode} — e apply]")
3518 } else {
3519 String::new()
3520 };
3521 let mass = self.stack_mass(&row.stack);
3522 let mass_kg = (mass >= 0.05).then_some(mass);
3523 let mass_str = mass_kg
3524 .map(|m| format!(" {m:.1} kg"))
3525 .unwrap_or_default();
3526 let volume = self.container_volume_stats(row);
3527 let vol_str = self.container_volume_label(row);
3528
3529 let mut title = label.clone();
3530 title.push_str(&qty);
3531 if row.is_equip_shell {
3532 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3533 title.push_str(&format!(" ({})", body_slot_label(slot)));
3534 }
3535 }
3536
3537 InventoryRowView {
3538 depth: row.depth,
3539 text: format!("{label}{hint}{grant_hint}{bindings}{qty}{mass_str}{vol_str}"),
3540 title: format!("{title}{grant_hint}{bindings}"),
3541 mass_kg,
3542 volume,
3543 instance_tooltip: None,
3544 }
3545 }
3546
3547 fn push_browser_item(
3548 &self,
3549 lines: &mut Vec<InventoryBrowserLine>,
3550 row: &InventoryRow,
3551 global_idx: &mut usize,
3552 target: usize,
3553 highlight: bool,
3554 ambiguous_instance_keys: &HashSet<(String, String, String)>,
3555 ) {
3556 let mut view = self.format_inventory_row(row);
3557 if let Some(id) = row.stack.item_instance_id {
3558 let key = self.inventory_row_instance_identity_key(row);
3559 if ambiguous_instance_keys.contains(&key) {
3560 view.instance_tooltip = Some(Self::format_instance_hover_tooltip(id));
3561 }
3562 }
3563 lines.push(InventoryBrowserLine::Item {
3564 selectable_index: *global_idx,
3565 selected: highlight && *global_idx == target,
3566 depth: view.depth,
3567 text: view.text,
3568 title: view.title,
3569 mass_kg: view.mass_kg,
3570 volume: view.volume,
3571 instance_tooltip: view.instance_tooltip,
3572 });
3573 *global_idx += 1;
3574 }
3575
3576 pub fn inventory_browser_lines(&self) -> Vec<InventoryBrowserLine> {
3579 let mut lines = Vec::new();
3580 let target = self.inventory_menu_index;
3581 let highlight = !self.show_move_picker && !self.show_grant_picker;
3582 let filter = self.inventory_filter.as_str();
3583 let mut global_idx = 0usize;
3584 let ambiguous_instance_keys = self.inventory_ambiguous_instance_identity_keys();
3585
3586 match self.inventory_tab {
3587 InventoryTab::OnPerson => {
3588 lines.push(InventoryBrowserLine::Section("— Worn —".into()));
3589 let worn = self.worn_rows_filtered(filter);
3590 if worn.is_empty() {
3591 lines.push(InventoryBrowserLine::Hint(
3592 " (nothing equipped — wear a backpack/belt from \"On you\" below)".into(),
3593 ));
3594 } else {
3595 for row in &worn {
3596 if row.is_equip_shell {
3597 if let flatland_protocol::InventoryLocation::Worn { slot } = row.from {
3598 lines.push(InventoryBrowserLine::SlotLabel(format!(
3599 " {}:",
3600 body_slot_label(slot)
3601 )));
3602 }
3603 }
3604 self.push_browser_item(
3605 &mut lines,
3606 row,
3607 &mut global_idx,
3608 target,
3609 highlight,
3610 &ambiguous_instance_keys,
3611 );
3612 }
3613 }
3614
3615 lines.push(InventoryBrowserLine::Blank);
3616 lines.push(InventoryBrowserLine::Section(
3617 "— On you (loose, not worn) —".into(),
3618 ));
3619 let person = self.person_rows_filtered(filter);
3620 if person.is_empty() {
3621 lines.push(InventoryBrowserLine::Hint(" (empty)".into()));
3622 } else {
3623 let mut last_group: Option<&'static str> = None;
3624 for row in &person {
3625 if row.depth == 0 {
3626 let cat = row
3627 .stack
3628 .category
3629 .as_deref()
3630 .or_else(|| self.inventory_item_category(&row.stack.template_id))
3631 .unwrap_or("");
3632 let (group, _) = inventory_category_group(cat);
3633 if last_group != Some(group) {
3634 lines.push(InventoryBrowserLine::SlotLabel(format!(
3635 " {group}"
3636 )));
3637 last_group = Some(group);
3638 }
3639 }
3640 self.push_browser_item(
3641 &mut lines,
3642 row,
3643 &mut global_idx,
3644 target,
3645 highlight,
3646 &ambiguous_instance_keys,
3647 );
3648 }
3649 }
3650 }
3651 InventoryTab::Nearby => {
3652 let nearby = self.nearby_containers();
3653 if nearby.is_empty() {
3654 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3655 lines.push(InventoryBrowserLine::Hint(
3656 " (none within reach — walk up to a chest)".into(),
3657 ));
3658 lines.push(InventoryBrowserLine::Hint(
3659 " Select an on-person item, then m / Enter → move into chest.".into(),
3660 ));
3661 } else {
3662 let mut any_visible = false;
3663 for nc in &nearby {
3664 let shell = nc.rows.first();
3665 let contents: Vec<&InventoryRow> = if filter.is_empty() {
3666 nc.rows.iter().skip(1).collect()
3667 } else {
3668 let shell_hit = shell
3669 .map(|s| {
3670 let f = filter.to_ascii_lowercase();
3671 let name = s
3672 .stack
3673 .display_name
3674 .as_deref()
3675 .unwrap_or("")
3676 .to_ascii_lowercase();
3677 let tid = s.stack.template_id.to_ascii_lowercase();
3678 name.contains(&f) || tid.contains(&f)
3679 })
3680 .unwrap_or(false);
3681 if shell_hit {
3682 nc.rows.iter().skip(1).collect()
3683 } else {
3684 nc.rows
3685 .iter()
3686 .skip(1)
3687 .filter(|r| stack_matches_filter(&r.stack, filter))
3688 .collect()
3689 }
3690 };
3691 let shell_visible = filter.is_empty()
3692 || shell
3693 .map(|s| stack_matches_filter(&s.stack, filter))
3694 .unwrap_or(false)
3695 || !contents.is_empty();
3696 if !shell_visible && shell.is_some() {
3697 continue;
3698 }
3699 any_visible = true;
3700 lines.push(InventoryBrowserLine::Blank);
3701 let lock_note = if nc.view.locked && nc.view.accessible {
3702 " unlocked with your key"
3703 } else if nc.view.locked {
3704 " locked"
3705 } else {
3706 ""
3707 };
3708 lines.push(InventoryBrowserLine::Section(format!(
3709 "— {} ({:.0}m away){lock_note} —",
3710 nc.view.display_name, nc.distance_m
3711 )));
3712 if !nc.view.accessible {
3713 lines.push(InventoryBrowserLine::Hint(
3714 " locked — need the matching key (l to try)".into(),
3715 ));
3716 } else if nc.rows.is_empty() {
3717 lines.push(InventoryBrowserLine::Hint(
3718 " (empty — switch to On person, select an item, m to move in)"
3719 .into(),
3720 ));
3721 } else if let Some(shell_row) = shell {
3722 self.push_browser_item(
3723 &mut lines,
3724 shell_row,
3725 &mut global_idx,
3726 target,
3727 highlight,
3728 &ambiguous_instance_keys,
3729 );
3730 for row in contents {
3731 self.push_browser_item(
3732 &mut lines,
3733 row,
3734 &mut global_idx,
3735 target,
3736 highlight,
3737 &ambiguous_instance_keys,
3738 );
3739 }
3740 }
3741 }
3742 if !any_visible {
3743 lines.push(InventoryBrowserLine::Section("— Nearby storage —".into()));
3744 lines.push(InventoryBrowserLine::Hint(
3745 " (no matching items — clear filter with Esc)".into(),
3746 ));
3747 }
3748 }
3749 }
3750 }
3751 lines
3752 }
3753
3754 pub fn chest_pickup_destinations(&self, container_id: &str) -> Vec<MoveOption> {
3756 let mut opts = Vec::new();
3757 opts.push(MoveOption {
3758 label: "Relocate…".into(),
3759 kind: MoveOptionKind::RelocatePlaced {
3760 container_id: container_id.to_string(),
3761 },
3762 });
3763 opts.push(MoveOption {
3764 label: "On your person (loose)".into(),
3765 kind: MoveOptionKind::PickupPlaced {
3766 container_id: container_id.to_string(),
3767 nest_location: flatland_protocol::InventoryLocation::Root,
3768 nest_parent_instance_id: None,
3769 },
3770 });
3771 for (slot, item) in &self.worn {
3772 if item.category.as_deref() != Some("container") {
3773 continue;
3774 }
3775 if *slot == BodySlot::Waist || !Self::is_volume_container_stack(item) {
3776 continue;
3777 }
3778 let Some(parent_id) = item.item_instance_id else {
3779 continue;
3780 };
3781 let shell_name = item
3782 .display_name
3783 .clone()
3784 .unwrap_or_else(|| item.template_id.clone());
3785 opts.push(MoveOption {
3786 label: format!("{shell_name} (worn {})", body_slot_label(*slot)),
3787 kind: MoveOptionKind::PickupPlaced {
3788 container_id: container_id.to_string(),
3789 nest_location: flatland_protocol::InventoryLocation::Worn { slot: *slot },
3790 nest_parent_instance_id: Some(parent_id),
3791 },
3792 });
3793 Self::append_chest_pickup_nested(
3795 &mut opts,
3796 container_id,
3797 flatland_protocol::InventoryLocation::Worn { slot: *slot },
3798 item,
3799 &format!("in {shell_name}"),
3800 );
3801 }
3802 opts.push(MoveOption {
3803 label: "Cancel".into(),
3804 kind: MoveOptionKind::Cancel,
3805 });
3806 opts
3807 }
3808
3809 fn append_chest_pickup_nested(
3810 opts: &mut Vec<MoveOption>,
3811 container_id: &str,
3812 location: flatland_protocol::InventoryLocation,
3813 parent: &flatland_protocol::ItemStack,
3814 context: &str,
3815 ) {
3816 for child in &parent.contents {
3817 if child.category.as_deref() != Some("container") {
3818 continue;
3819 }
3820 if !Self::is_volume_container_stack(child) {
3821 continue;
3822 }
3823 if child.world_placeable == Some(true) {
3825 continue;
3826 }
3827 let Some(child_id) = child.item_instance_id else {
3828 continue;
3829 };
3830 let name = child
3831 .display_name
3832 .clone()
3833 .unwrap_or_else(|| child.template_id.clone());
3834 opts.push(MoveOption {
3835 label: format!("{name} ({context})"),
3836 kind: MoveOptionKind::PickupPlaced {
3837 container_id: container_id.to_string(),
3838 nest_location: location.clone(),
3839 nest_parent_instance_id: Some(child_id),
3840 },
3841 });
3842 Self::append_chest_pickup_nested(
3843 opts,
3844 container_id,
3845 location.clone(),
3846 child,
3847 &format!("in {name}"),
3848 );
3849 }
3850 }
3851
3852 pub fn move_destinations_for(
3854 &self,
3855 from: &flatland_protocol::InventoryLocation,
3856 from_parent_instance_id: Option<uuid::Uuid>,
3857 moving_instance_id: Option<uuid::Uuid>,
3858 moving_template_id: &str,
3859 ) -> Vec<MoveOption> {
3860 let mut opts = Vec::new();
3861 if *from != flatland_protocol::InventoryLocation::Root {
3862 opts.push(MoveOption {
3863 label: "On your person (loose)".into(),
3864 kind: MoveOptionKind::Move {
3865 location: flatland_protocol::InventoryLocation::Root,
3866 parent_instance_id: None,
3867 },
3868 });
3869 }
3870 for (slot, item) in &self.worn {
3871 if item.category.as_deref() != Some("container") {
3872 continue;
3873 }
3874 let location = flatland_protocol::InventoryLocation::Worn { slot: *slot };
3875 let shell_name = item
3876 .display_name
3877 .clone()
3878 .unwrap_or_else(|| item.template_id.clone());
3879
3880 if *slot != BodySlot::Waist
3882 && item.item_instance_id != moving_instance_id
3883 && Self::is_volume_container_stack(item)
3884 {
3885 Self::push_move_destination(
3886 &mut opts,
3887 format!("{shell_name} (worn {})", body_slot_label(*slot)),
3888 location.clone(),
3889 item.item_instance_id,
3890 from,
3891 from_parent_instance_id,
3892 );
3893 }
3894
3895 if *slot == BodySlot::Waist
3897 && Self::attaches_to_belt_loop(moving_template_id)
3898 && item.item_instance_id != moving_instance_id
3899 {
3900 Self::push_move_destination(
3901 &mut opts,
3902 format!("{shell_name} (belt loop)"),
3903 location.clone(),
3904 item.item_instance_id,
3905 from,
3906 from_parent_instance_id,
3907 );
3908 }
3909
3910 let context = if *slot == BodySlot::Waist {
3911 format!("on {shell_name}")
3912 } else {
3913 format!("in {shell_name}")
3914 };
3915 Self::append_nested_container_destinations(
3916 &mut opts,
3917 location,
3918 item,
3919 &context,
3920 from,
3921 from_parent_instance_id,
3922 moving_instance_id,
3923 );
3924 }
3925 for nc in self.nearby_containers() {
3926 if !nc.view.accessible {
3927 continue;
3928 }
3929 let location = flatland_protocol::InventoryLocation::Placed {
3930 container_id: nc.view.id.clone(),
3931 };
3932 Self::push_move_destination(
3933 &mut opts,
3934 format!("{} ({:.0}m away)", nc.view.display_name, nc.distance_m),
3935 location,
3936 nc.view.item_instance_id,
3937 from,
3938 from_parent_instance_id,
3939 );
3940 }
3941 let allow_drop = moving_instance_id
3942 .map(|id| !self.hand_equipped_instance_ids().contains(&id))
3943 .unwrap_or(true)
3944 && moving_instance_id
3945 .and_then(|id| self.stack_for_instance(id))
3946 .map(|stack| {
3947 !self.key_drop_blocked(&stack) && stack.template_id != PROPERTY_DEED_TEMPLATE
3948 })
3949 .unwrap_or(
3950 moving_template_id != KEY_TEMPLATE
3951 && moving_template_id != PROPERTY_DEED_TEMPLATE,
3952 );
3953 if allow_drop {
3954 opts.push(MoveOption {
3955 label: "Drop on the ground".into(),
3956 kind: MoveOptionKind::Drop,
3957 });
3958 }
3959 opts.push(MoveOption {
3960 label: "Cancel".into(),
3961 kind: MoveOptionKind::Cancel,
3962 });
3963 opts
3964 }
3965
3966 fn is_same_container_dest(
3967 dest_location: &flatland_protocol::InventoryLocation,
3968 dest_parent: Option<uuid::Uuid>,
3969 from: &flatland_protocol::InventoryLocation,
3970 from_parent: Option<uuid::Uuid>,
3971 ) -> bool {
3972 dest_location == from && dest_parent == from_parent
3973 }
3974
3975 fn push_move_destination(
3976 opts: &mut Vec<MoveOption>,
3977 label: String,
3978 location: flatland_protocol::InventoryLocation,
3979 parent_instance_id: Option<uuid::Uuid>,
3980 from: &flatland_protocol::InventoryLocation,
3981 from_parent_instance_id: Option<uuid::Uuid>,
3982 ) {
3983 if Self::is_same_container_dest(
3984 &location,
3985 parent_instance_id,
3986 from,
3987 from_parent_instance_id,
3988 ) {
3989 return;
3990 }
3991 opts.push(MoveOption {
3992 label,
3993 kind: MoveOptionKind::Move {
3994 location,
3995 parent_instance_id,
3996 },
3997 });
3998 }
3999
4000 fn is_volume_container_stack(stack: &flatland_protocol::ItemStack) -> bool {
4001 stack.capacity_volume.is_some_and(|c| c > 0.0)
4002 }
4003
4004 fn attaches_to_belt_loop(template_id: &str) -> bool {
4005 matches!(template_id, "leather_pouch" | "dimensional_pouch")
4006 }
4007
4008 fn append_nested_container_destinations(
4009 opts: &mut Vec<MoveOption>,
4010 location: flatland_protocol::InventoryLocation,
4011 container: &flatland_protocol::ItemStack,
4012 context: &str,
4013 from: &flatland_protocol::InventoryLocation,
4014 from_parent_instance_id: Option<uuid::Uuid>,
4015 moving_instance_id: Option<uuid::Uuid>,
4016 ) {
4017 for child in &container.contents {
4018 if Self::is_volume_container_stack(child)
4019 && child.item_instance_id != moving_instance_id
4020 {
4021 let name = child
4022 .display_name
4023 .clone()
4024 .unwrap_or_else(|| child.template_id.clone());
4025 Self::push_move_destination(
4026 opts,
4027 format!("{name} ({context})"),
4028 location.clone(),
4029 child.item_instance_id,
4030 from,
4031 from_parent_instance_id,
4032 );
4033 }
4034 let nested_context = format!(
4035 "in {}",
4036 child.display_name.as_deref().unwrap_or(&child.template_id)
4037 );
4038 Self::append_nested_container_destinations(
4039 opts,
4040 location.clone(),
4041 child,
4042 &nested_context,
4043 from,
4044 from_parent_instance_id,
4045 moving_instance_id,
4046 );
4047 }
4048 }
4049
4050 fn clamp_inventory_indices(&mut self) {
4051 let n = self.inventory_selectable_rows().len();
4052 self.inventory_menu_index = if n == 0 {
4053 0
4054 } else {
4055 self.inventory_menu_index.min(n - 1)
4056 };
4057 if let Some(picker) = &self.move_picker {
4058 let pn = picker.options.len();
4059 self.move_picker_index = if pn == 0 {
4060 0
4061 } else {
4062 self.move_picker_index.min(pn - 1)
4063 };
4064 }
4065 }
4066
4067 fn sync_interior_map_context(&mut self) {
4072 if self.effective_inside_building().is_none() {
4073 self.interior_map = None;
4074 if let Some((platforms, transitions)) = self.z_bands_outdoor_backup.take() {
4075 self.z_platforms = platforms;
4076 self.z_transitions = transitions;
4077 }
4078 return;
4079 }
4080 self.sync_interior_z_bands();
4081 }
4082
4083 fn sync_interior_z_bands(&mut self) {
4085 if self.effective_inside_building().is_some() {
4086 if let Some(map) = &self.interior_map {
4087 if !map.z_platforms.is_empty() || !map.z_transitions.is_empty() {
4088 if self.z_bands_outdoor_backup.is_none() {
4089 self.z_bands_outdoor_backup = Some((
4090 std::mem::take(&mut self.z_platforms),
4091 std::mem::take(&mut self.z_transitions),
4092 ));
4093 }
4094 self.z_platforms = map.z_platforms.clone();
4095 self.z_transitions = map.z_transitions.clone();
4096 }
4097 }
4098 }
4099 }
4100
4101 fn apply_snapshot_fields(
4102 &mut self,
4103 snapshot: &flatland_protocol::Snapshot,
4104 entity_id: EntityId,
4105 ) {
4106 self.tick = snapshot.tick;
4107 self.chunk_rev = snapshot.chunk_rev;
4108 self.content_rev = snapshot.content_rev;
4109 self.publish_rev = snapshot.publish_rev;
4110 self.resource_nodes = snapshot.resource_nodes.clone();
4111 self.ground_drops = snapshot.ground_drops.clone();
4112 self.placed_containers = snapshot.placed_containers.clone();
4113 self.world_x0 = snapshot.world_x0;
4114 self.world_y0 = snapshot.world_y0;
4115 self.world_width_m = snapshot.world_width_m;
4116 self.world_height_m = snapshot.world_height_m;
4117 self.world_clock = snapshot.world_clock;
4118 self.terrain_zones = snapshot.terrain_zones.clone();
4119 self.z_platforms = snapshot.z_platforms.clone();
4120 self.z_transitions = snapshot.z_transitions.clone();
4121 self.z_bands_outdoor_backup = None;
4123 self.buildings = snapshot.buildings.clone();
4124 self.doors = snapshot.doors.clone();
4125 self.interior_map = snapshot.interior_map.clone();
4126 self.npcs = snapshot.npcs.clone();
4127 self.blueprints = snapshot.blueprints.clone();
4128 self.building_materials = snapshot.building_materials.clone();
4129 self.sync_inventory_from_stacks(&snapshot.inventory);
4130 self.player = snapshot
4131 .entities
4132 .iter()
4133 .find(|e| e.id == entity_id)
4134 .cloned();
4135 self.entities = snapshot.entities.clone();
4136 self.quest_log = snapshot.quest_log.clone();
4137 self.apply_hired_workers(snapshot.hired_workers.clone());
4138 self.interactables = snapshot.interactables.clone();
4139 self.ledger = snapshot.ledger.clone();
4140 self.career = snapshot.career.clone();
4141 self.combat_fx = snapshot.combat_fx.clone();
4142 self.property_zones = snapshot.property_zones.clone();
4143 self.tax_zones = snapshot.tax_zones.clone();
4144 self.growth_zones = snapshot.growth_zones.clone();
4145 self.biome_zones = snapshot.biome_zones.clone();
4146 self.terrain_kind_nav = snapshot.terrain_kind_nav.clone();
4147 self.property_plots = snapshot.property_plots.clone();
4148 self.property_plot_settings = snapshot.property_plot_settings.clone();
4149 if self.effective_inside_building().is_some() {
4152 self.z_bands_outdoor_backup = Some((Vec::new(), Vec::new()));
4153 }
4154 self.sync_interior_map_context();
4155 self.refresh_whisper_range();
4156 }
4157
4158 fn refresh_inventory_ui(&mut self) {
4162 if let Some(picker) = &self.move_picker {
4163 let instance_id = picker.item_instance_id;
4164 let still_exists = self
4165 .inventory_selectable_rows()
4166 .iter()
4167 .any(|r| r.stack.item_instance_id == Some(instance_id));
4168 if !still_exists {
4169 self.move_picker = None;
4170 self.show_move_picker = false;
4171 }
4172 }
4173 if let Some(picker) = &self.destroy_picker {
4174 let instance_id = picker.item_instance_id;
4175 let still_exists = self
4176 .inventory_selectable_rows()
4177 .iter()
4178 .any(|r| r.stack.item_instance_id == Some(instance_id));
4179 if !still_exists {
4180 self.destroy_picker = None;
4181 self.show_destroy_picker = false;
4182 self.destroy_confirm_pending = false;
4183 }
4184 }
4185 self.clamp_inventory_indices();
4186 }
4187
4188 fn apply_hired_workers(&mut self, mut workers: Vec<flatland_protocol::HiredWorkerView>) {
4194 let selected_id = self
4195 .hired_workers
4196 .get(self.workers_menu_index)
4197 .map(|w| w.instance_id.clone());
4198 workers.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
4199 let now = Instant::now();
4200 for w in &workers {
4201 let prev_err = self
4202 .hired_workers
4203 .iter()
4204 .find(|p| p.instance_id == w.instance_id)
4205 .and_then(|p| p.last_error.as_deref());
4206 let new_err = w.last_error.as_deref();
4207 if new_err != prev_err {
4208 if let Some(err) = new_err {
4209 if !worker_error_is_transient(err) {
4210 self.push_log(format!("Worker {}: {err}", w.label));
4211 }
4212 }
4213 }
4214 }
4215 let mut next_display = BTreeMap::new();
4216 let mut next_errors = BTreeMap::new();
4217 for w in &workers {
4218 let mut sticky = self
4219 .worker_step_display
4220 .remove(&w.instance_id)
4221 .unwrap_or_else(|| StickyWorkerStep::from_label(w.step_label.clone()));
4222 sticky.observe(&w.step_label, now);
4223 next_display.insert(w.instance_id.clone(), sticky);
4224
4225 let mut err_sticky = self
4226 .worker_error_display
4227 .remove(&w.instance_id)
4228 .unwrap_or_default();
4229 err_sticky.observe(w.last_error.as_deref(), now);
4230 if err_sticky.shown(now).is_some() {
4231 next_errors.insert(w.instance_id.clone(), err_sticky);
4232 }
4233 }
4234 self.worker_step_display = next_display;
4235 self.worker_error_display = next_errors;
4236 self.hired_workers = workers;
4237 self.sync_worker_take_picker_from_hired();
4238 if let Some(id) = selected_id {
4239 if let Some(idx) = self.hired_workers.iter().position(|w| w.instance_id == id) {
4240 self.workers_menu_index = idx;
4241 return;
4242 }
4243 }
4244 if self.workers_menu_index >= self.hired_workers.len() {
4245 self.workers_menu_index = self.hired_workers.len().saturating_sub(1);
4246 }
4247 }
4248
4249 fn sync_worker_take_picker_from_hired(&mut self) {
4251 if !self.show_worker_take_picker {
4252 return;
4253 }
4254 let Some(picker) = self.worker_take_picker.clone() else {
4255 return;
4256 };
4257 let Some(worker) = self
4258 .hired_workers
4259 .iter()
4260 .find(|w| w.instance_id == picker.worker_instance_id)
4261 .cloned()
4262 else {
4263 self.show_worker_take_picker = false;
4264 self.worker_take_picker = None;
4265 self.worker_take_picker_index = 0;
4266 return;
4267 };
4268 let options: Vec<WorkerGiveOption> = worker
4269 .inventory
4270 .iter()
4271 .filter_map(|stack| {
4272 let item_instance_id = stack.item_instance_id?;
4273 let label = stack
4274 .display_name
4275 .clone()
4276 .unwrap_or_else(|| stack.template_id.clone());
4277 let label = if stack.quantity > 1 {
4278 format!("{label} ×{}", stack.quantity)
4279 } else {
4280 label
4281 };
4282 Some(WorkerGiveOption {
4283 item_instance_id,
4284 label,
4285 quantity: stack.quantity,
4286 template_id: stack.template_id.clone(),
4287 })
4288 })
4289 .collect();
4290 if options.is_empty() {
4291 self.show_worker_take_picker = false;
4292 self.worker_take_picker = None;
4293 self.worker_take_picker_index = 0;
4294 return;
4295 }
4296 let prev_id = picker
4297 .options
4298 .get(self.worker_take_picker_index)
4299 .map(|o| o.item_instance_id);
4300 let idx = prev_id
4301 .and_then(|id| options.iter().position(|o| o.item_instance_id == id))
4302 .unwrap_or(0)
4303 .min(options.len().saturating_sub(1));
4304 let max_qty = options.get(idx).map(|o| o.quantity.max(1)).unwrap_or(1);
4305 let quantity = picker.quantity.clamp(1, max_qty);
4306 self.worker_take_picker_index = idx;
4307 self.worker_take_picker = Some(WorkerTakePicker {
4308 worker_instance_id: picker.worker_instance_id,
4309 worker_label: picker.worker_label,
4310 options,
4311 quantity,
4312 });
4313 }
4314
4315 pub fn worker_step_display_label(&self, worker_instance_id: &str) -> &str {
4317 self.worker_step_display
4318 .get(worker_instance_id)
4319 .map(|s| s.shown.as_str())
4320 .or_else(|| {
4321 self.hired_workers
4322 .iter()
4323 .find(|w| w.instance_id == worker_instance_id)
4324 .map(|w| w.step_label.as_str())
4325 })
4326 .unwrap_or("")
4327 }
4328
4329 pub fn worker_error_display_label(&self, worker_instance_id: &str) -> Option<&str> {
4331 let now = Instant::now();
4332 self.worker_error_display
4333 .get(worker_instance_id)
4334 .and_then(|s| s.shown(now))
4335 .or_else(|| {
4336 self.hired_workers
4337 .iter()
4338 .find(|w| w.instance_id == worker_instance_id)
4339 .and_then(|w| w.last_error.as_deref())
4340 .filter(|e| !worker_error_is_transient(e) && !worker_error_is_hud_noise(e))
4341 })
4342 .filter(|e| !worker_error_is_hud_noise(e))
4343 }
4344
4345 fn apply_combat_hud(&mut self, combat: &CombatHud) {
4346 self.in_combat = combat.in_combat;
4347 self.auto_attack = combat.auto_attack;
4348 self.combat_has_los = combat.has_los;
4349 self.attack_cd_ticks = combat.attack_cd_ticks;
4350 self.gcd_ticks = combat.gcd_ticks;
4351 self.weapon_ability_id = combat.ability_id.clone();
4352 self.mainhand_template_id = combat.mainhand_template_id.clone();
4353 self.mainhand_label = combat.mainhand_label.clone();
4354 self.mainhand_instance_id = combat.mainhand_instance_id;
4355 self.offhand_template_id = combat.offhand_template_id.clone();
4356 self.offhand_label = combat.offhand_label.clone();
4357 self.offhand_instance_id = combat.offhand_instance_id;
4358 self.mainhand_hand_slots = if combat.mainhand_hand_slots == 0 {
4359 1
4360 } else {
4361 combat.mainhand_hand_slots
4362 };
4363 self.defense = combat.defense.clone();
4364 self.worn = combat.worn.iter().cloned().collect();
4365 self.carry_mass = combat.carry_mass;
4366 self.carry_mass_max = combat.carry_mass_max;
4367 self.encumbrance = combat.encumbrance;
4368 self.cast_progress = combat.cast.clone();
4369 self.timed_channel = combat.timed_channel.clone();
4370 self.plot_build_offer = combat.plot_build.clone();
4371 self.ability_cooldowns = combat.ability_cooldowns.clone();
4372 self.blocking_active = combat.blocking_active;
4373 self.max_target_slots = combat.max_target_slots.max(1);
4374 self.combat_slots = combat.slots.clone();
4375 self.rotation_presets = combat.rotation_presets.clone();
4376 self.known_abilities = combat.known_abilities.clone();
4377 self.ability_meta = combat
4378 .ability_meta
4379 .iter()
4380 .cloned()
4381 .map(|meta| (meta.id.clone(), meta))
4382 .collect();
4383 self.ability_mastery = combat
4384 .ability_mastery
4385 .iter()
4386 .cloned()
4387 .map(|row| (row.ability_id.clone(), row))
4388 .collect();
4389 self.hotbar = combat.hotbar.clone();
4390 self.max_abilities_per_rotation = combat.max_abilities_per_rotation;
4391 self.keychain_stacks = combat.keychain.clone();
4392 self.whisper_pouch_stacks = combat.whisper_pouch.clone();
4393 self.combat_target_detail = combat.target.clone();
4394 self.statuses = combat.statuses.clone();
4395 self.combat_target = combat.target_entity_id;
4396 if combat.progression_xp_base > 0.0 {
4397 self.progression_curve = Some(flatland_protocol::ProgressionCurve {
4398 baseline_display: combat.progression_baseline,
4399 xp_base: combat.progression_xp_base,
4400 xp_growth: combat.progression_xp_growth,
4401 });
4402 }
4403 if let Some(xp) = &combat.progression_xp {
4404 if let Some(player) = &mut self.player {
4405 player.progression_xp = Some(xp.clone());
4406 if let Some(attrs) = combat.attributes {
4407 player.attributes = Some(attrs);
4408 }
4409 if let Some(skills) = &combat.skills {
4410 player.skills = Some(skills.clone());
4411 }
4412 }
4413 }
4414 if let Some(label) = &combat.target_label {
4415 self.combat_target_label = Some(label.clone());
4416 } else if let Some(id) = combat.target_entity_id {
4417 self.combat_target_label = self
4418 .entities
4419 .iter()
4420 .find(|e| e.id == id)
4421 .map(|e| e.label.clone())
4422 .or_else(|| self.combat_target_label.clone());
4423 }
4424 self.refresh_inventory_ui();
4425 }
4426
4427 pub fn target_for_slot(&self, slot: u8) -> Option<EntityId> {
4429 self.combat_slots
4430 .iter()
4431 .find(|s| s.slot_index == slot)
4432 .and_then(|s| s.target_entity_id)
4433 .or_else(|| if slot == 1 { self.combat_target } else { None })
4434 }
4435
4436 pub fn ability_allows_ground(&self, ability_id: &str) -> bool {
4438 self.ability_meta
4439 .get(ability_id)
4440 .map(|meta| matches!(meta.aim_mode.as_str(), "ground" | "either"))
4441 .unwrap_or(self.ground_target.is_some())
4444 }
4445
4446 pub fn ability_requires_ground(&self, ability_id: &str) -> bool {
4448 self.ability_meta
4449 .get(ability_id)
4450 .map(|meta| meta.aim_mode == "ground")
4451 .unwrap_or(false)
4452 }
4453
4454 pub fn ability_auto_rotation_eligible(&self, ability_id: &str) -> bool {
4457 self.ability_meta
4458 .get(ability_id)
4459 .map(|meta| meta.auto_rotation_eligible)
4460 .unwrap_or(true)
4461 }
4462
4463 pub fn set_ground_target(&mut self, x: f32, y: f32) {
4465 self.ground_target = Some((x, y, 0.0));
4466 }
4467
4468 pub fn clear_ground_target(&mut self) {
4470 self.ground_target = None;
4471 }
4472
4473 pub fn hotbar_ability(&self, slot_1_to_9: u8) -> Option<&str> {
4476 if !(1..=9).contains(&slot_1_to_9) {
4477 return None;
4478 }
4479 self.hotbar
4480 .get((slot_1_to_9 - 1) as usize)
4481 .and_then(|a| a.as_deref())
4482 .filter(|id| !id.is_empty())
4483 }
4484
4485 pub fn hotbar_slot_label(&self, slot_1_to_9: u8) -> Option<String> {
4487 let binding = self.hotbar_ability(slot_1_to_9)?;
4488 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(binding) {
4489 let name = self
4490 .inventory_hints
4491 .get(template_id)
4492 .map(|h| h.display_name.as_str())
4493 .unwrap_or(template_id);
4494 let qty = self.inventory.get(template_id).copied().unwrap_or(0);
4495 Some(format!("{name}×{qty}"))
4496 } else {
4497 Some(binding.to_string())
4498 }
4499 }
4500
4501 pub fn loadout_ability_choices(&self) -> Vec<String> {
4503 let mut out = self.known_abilities.clone();
4504 let weapon = self.weapon_ability_id.trim();
4505 if !weapon.is_empty() && !out.iter().any(|a| a == weapon) {
4506 out.push(weapon.to_string());
4507 }
4508 out
4509 }
4510
4511 pub fn loadout_hotbar_choices(&self) -> Vec<LoadoutHotbarChoice> {
4513 let mut out = Vec::new();
4514 for ability in self.loadout_ability_choices() {
4515 let meta = if ability == self.weapon_ability_id {
4516 Some("weapon".into())
4517 } else {
4518 None
4519 };
4520 out.push(LoadoutHotbarChoice {
4521 binding: ability.clone(),
4522 label: ability,
4523 meta,
4524 });
4525 }
4526 let mut consumables: Vec<(String, String, u32)> = Vec::new();
4527 for stack in &self.inventory_stacks {
4528 if Self::stack_is_item_grant(stack) {
4529 continue;
4530 }
4531 if self.inventory_item_category(&stack.template_id) != Some("consumable") {
4532 continue;
4533 }
4534 let qty = stack.quantity.max(1);
4535 if let Some((_, _, existing)) = consumables
4536 .iter_mut()
4537 .find(|(id, _, _)| id == &stack.template_id)
4538 {
4539 *existing = existing.saturating_add(qty);
4540 } else {
4541 let label = stack
4542 .display_name
4543 .clone()
4544 .or_else(|| {
4545 self.inventory_hints
4546 .get(&stack.template_id)
4547 .map(|h| h.display_name.clone())
4548 })
4549 .unwrap_or_else(|| stack.template_id.clone());
4550 consumables.push((stack.template_id.clone(), label, qty));
4551 }
4552 }
4553 consumables.sort_by(|a, b| a.1.cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
4554 for (template_id, label, qty) in consumables {
4555 out.push(LoadoutHotbarChoice {
4556 binding: flatland_protocol::hotbar_consumable_binding(&template_id),
4557 label: format!("{label} ×{qty}"),
4558 meta: Some("use".into()),
4559 });
4560 }
4561 out
4562 }
4563
4564 pub fn t1_candidates(&self) -> Vec<(EntityId, String)> {
4566 self.combat_candidates()
4567 }
4568
4569 pub fn t2_candidates(&self) -> Vec<(EntityId, String)> {
4571 let (px, py) = self.player_position();
4572 let dist = |id: EntityId| {
4573 self.entities
4574 .iter()
4575 .find(|e| e.id == id)
4576 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4577 .unwrap_or(f32::MAX)
4578 };
4579
4580 let mut allies = Vec::new();
4581 if let Some(me) = self.player.as_ref() {
4583 let alive = me
4584 .vitals
4585 .as_ref()
4586 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4587 .unwrap_or(true);
4588 if alive {
4589 allies.push((self.entity_id, "Yourself".into()));
4590 }
4591 }
4592 for entity in &self.entities {
4593 if entity.id == self.entity_id {
4594 continue;
4595 }
4596 if entity.vitals.is_some() {
4597 let alive = entity
4598 .vitals
4599 .as_ref()
4600 .map(|v| v.life_state == LifeState::Alive && v.health > 0.0)
4601 .unwrap_or(true);
4602 if alive {
4603 allies.push((entity.id, entity.label.clone()));
4604 }
4605 }
4606 }
4607 allies.sort_by(|(a, _), (b, _)| {
4608 if *a == self.entity_id {
4609 return std::cmp::Ordering::Less;
4610 }
4611 if *b == self.entity_id {
4612 return std::cmp::Ordering::Greater;
4613 }
4614 dist(*a)
4615 .partial_cmp(&dist(*b))
4616 .unwrap_or(std::cmp::Ordering::Equal)
4617 });
4618
4619 let mut monsters = self.combat_candidates();
4620 monsters.retain(|(id, _)| !allies.iter().any(|(aid, _)| aid == id));
4621 allies.into_iter().chain(monsters).collect()
4622 }
4623
4624 fn candidates_for_slot(&self, slot_index: u8) -> Vec<(EntityId, String)> {
4625 match slot_index {
4626 2 => self.t2_candidates(),
4627 _ => self.t1_candidates(),
4628 }
4629 }
4630
4631 pub fn pick_combat_target_at(
4633 &self,
4634 wx: f32,
4635 wy: f32,
4636 slot_index: u8,
4637 radius_m: f32,
4638 ) -> Option<(EntityId, String)> {
4639 let mut best: Option<(f32, EntityId, String)> = None;
4640 for (id, label) in self.candidates_for_slot(slot_index) {
4641 let Some(entity) = self.entities.iter().find(|e| e.id == id) else {
4642 if let Some(npc) = self.npcs.iter().find(|n| n.entity_id == Some(id)) {
4644 let d = distance(wx, wy, npc.x, npc.y);
4645 if d <= radius_m {
4646 best = match best {
4647 Some((bd, _, _)) if bd <= d => best,
4648 _ => Some((d, id, label)),
4649 };
4650 }
4651 }
4652 continue;
4653 };
4654 let d = distance(
4655 wx,
4656 wy,
4657 entity.transform.position.x,
4658 entity.transform.position.y,
4659 );
4660 if d <= radius_m {
4661 best = match best {
4662 Some((bd, _, _)) if bd <= d => best,
4663 _ => Some((d, id, label)),
4664 };
4665 }
4666 }
4667 best.map(|(_, id, label)| (id, label))
4668 }
4669
4670 pub(crate) fn restore_from_welcome(
4672 &mut self,
4673 session_id: SessionId,
4674 entity_id: EntityId,
4675 snapshot: &flatland_protocol::Snapshot,
4676 ) {
4677 self.clear_harvest_state();
4678 self.disconnect_reason = None;
4679 self.show_stats = false;
4680 self.show_craft_menu = false;
4681 self.show_shop_menu = false;
4682 self.shop_catalog = None;
4683 self.show_inventory_menu = false;
4684 self.session_id = session_id;
4685 self.entity_id = entity_id;
4686 self.connected = true;
4687 self.apply_snapshot_fields(snapshot, entity_id);
4688 if let Some(combat) = &snapshot.combat {
4689 self.apply_combat_hud(combat);
4690 let stacks = self.inventory_stacks.clone();
4691 self.sync_inventory_from_stacks(&stacks);
4692 }
4693 }
4694
4695 fn apply_tick_fields(&mut self, delta: &flatland_protocol::TickDelta, entity_id: EntityId) {
4696 self.tick = delta.tick;
4697 self.world_clock = delta.world_clock;
4698
4699 if delta.entities.is_empty() {
4701 self.ground_drops = delta.ground_drops.clone();
4702 self.combat_fx = delta.combat_fx.clone();
4703 self.property_plots = delta.property_plots.clone();
4704 self.apply_terrain_overlays(&delta.terrain_overlays);
4705 if let Some(combat) = &delta.combat {
4706 self.apply_combat_hud(combat);
4707 let stacks = self.inventory_stacks.clone();
4708 self.sync_inventory_from_stacks(&stacks);
4709 }
4710 self.refresh_whisper_range();
4712 return;
4713 }
4714 if !delta.buildings.is_empty() {
4715 self.buildings = delta.buildings.clone();
4716 }
4717 if !delta.blueprints.is_empty() {
4718 self.blueprints = delta.blueprints.clone();
4719 }
4720 if !delta.building_materials.is_empty() {
4721 self.building_materials = delta.building_materials.clone();
4722 }
4723 self.sync_inventory_from_stacks(&delta.inventory);
4724
4725 if let Some(updated) = delta.entities.iter().find(|e| e.id == entity_id) {
4726 self.player = Some(updated.clone());
4727 }
4728 self.entities = delta.entities.clone();
4729 if self.player.is_none() {
4730 self.player = self.entities.iter().find(|e| e.id == entity_id).cloned();
4731 }
4732
4733 self.sync_interior_map_context();
4734
4735 if !delta.resource_nodes.is_empty() {
4739 self.resource_nodes = delta.resource_nodes.clone();
4740 } else if delta.interior_map.is_some()
4741 || self.effective_inside_building().is_some()
4742 {
4743 self.resource_nodes = delta.resource_nodes.clone();
4744 }
4745 self.ground_drops = delta.ground_drops.clone();
4746 self.placed_containers = delta.placed_containers.clone();
4748 if !delta.doors.is_empty() {
4749 self.doors = delta.doors.clone();
4750 }
4751 if self.effective_inside_building().is_some() {
4752 if let Some(map) = &delta.interior_map {
4753 self.interior_map = Some(map.clone());
4754 }
4755 } else {
4756 self.interior_map = None;
4757 }
4758 self.sync_interior_z_bands();
4759 self.npcs = delta.npcs.clone();
4761 if !delta.quest_log.is_empty() {
4762 self.quest_log = delta.quest_log.clone();
4763 }
4764 self.apply_hired_workers(delta.hired_workers.clone());
4765 if !delta.interactables.is_empty() {
4766 self.interactables = delta.interactables.clone();
4767 }
4768 if delta.ledger.is_some() {
4769 self.ledger = delta.ledger.clone();
4770 }
4771 if delta.career.is_some() {
4772 self.career = delta.career.clone();
4773 }
4774 self.combat_fx = delta.combat_fx.clone();
4775 if !delta.property_plots.is_empty() {
4777 self.property_plots = delta.property_plots.clone();
4778 }
4779 self.apply_terrain_overlays(&delta.terrain_overlays);
4780 if let Some(combat) = &delta.combat {
4781 self.apply_combat_hud(combat);
4782 let stacks = self.inventory_stacks.clone();
4783 self.sync_inventory_from_stacks(&stacks);
4784 } else {
4785 self.refresh_inventory_ui();
4786 }
4787 self.refresh_whisper_range();
4788 }
4789
4790 fn apply_terrain_overlays(&mut self, overlays: &[TerrainZoneView]) {
4793 self.terrain_zones
4794 .retain(|z| !z.id.starts_with("rt:"));
4795 self.terrain_zones.extend(overlays.iter().cloned());
4796 }
4797
4798 fn refresh_whisper_range(&mut self) {
4801 let crate::social::ChatThreadKind::Whisper { peer } = self.social_chat.thread else {
4802 return;
4803 };
4804 let (px, py) = self.player_position();
4805 let in_range = self.entities.iter().any(|e| {
4806 e.id == peer
4807 && distance(
4808 px,
4809 py,
4810 e.transform.position.x,
4811 e.transform.position.y,
4812 ) <= INTERACTION_RADIUS_M
4813 });
4814 if !in_range {
4815 self.social_chat.cancel_whisper_out_of_range();
4816 }
4817 }
4818
4819 pub fn combat_candidates(&self) -> Vec<(EntityId, String)> {
4821 let (px, py) = self.player_position();
4822 let mut out = Vec::new();
4823 for npc in &self.npcs {
4824 let Some(eid) = npc.entity_id else {
4825 continue;
4826 };
4827 let alive = npc.life_state.is_none_or(|s| s == LifeState::Alive);
4828 let has_hp = npc.hp_pct.is_none_or(|h| h > 0.0);
4829 if alive && has_hp {
4830 out.push((eid, npc.label.clone()));
4831 }
4832 }
4833 out.sort_by(|(a_id, a_label), (b_id, b_label)| {
4834 let dist = |id: EntityId| {
4835 self.entities
4836 .iter()
4837 .find(|e| e.id == id)
4838 .map(|e| distance(px, py, e.transform.position.x, e.transform.position.y))
4839 .unwrap_or(f32::MAX)
4840 };
4841 dist(*a_id)
4842 .partial_cmp(&dist(*b_id))
4843 .unwrap_or(std::cmp::Ordering::Equal)
4844 .then_with(|| a_label.cmp(b_label))
4845 .then_with(|| a_id.cmp(b_id))
4846 });
4847 out
4848 }
4849
4850 pub fn refresh_combat_target_label(&mut self) {
4851 let Some(id) = self.combat_target else {
4852 return;
4853 };
4854 if let Some((_, label)) = self
4855 .combat_candidates()
4856 .into_iter()
4857 .find(|(eid, _)| *eid == id)
4858 {
4859 self.combat_target_label = Some(label);
4860 } else if let Some(label) = self
4861 .entities
4862 .iter()
4863 .find(|e| e.id == id)
4864 .map(|e| e.label.clone())
4865 {
4866 self.combat_target_label = Some(label);
4867 }
4868 }
4869
4870 pub fn active_quest_entries(&self) -> Vec<&flatland_protocol::QuestLogEntry> {
4871 self.quest_log
4872 .iter()
4873 .filter(|q| q.status == flatland_protocol::QuestStatusView::Active)
4874 .collect()
4875 }
4876
4877 pub fn has_worker_lodging(&self) -> bool {
4879 self.free_worker_lodging_slots() > 0
4880 }
4881
4882 pub fn free_worker_lodging_slots(&self) -> i64 {
4884 let slots: u32 = self
4885 .placed_containers
4886 .iter()
4887 .filter(|c| match (self.character_id, c.owner_character_id) {
4888 (Some(me), Some(owner)) => me == owner,
4889 (Some(_), None) => false,
4890 (None, _) => c.worker_lodging_capacity.unwrap_or(0) > 0,
4891 })
4892 .map(|c| c.worker_lodging_capacity.unwrap_or(0))
4893 .sum();
4894 let used = self.hired_workers.len() as u32;
4895 slots as i64 - used as i64
4896 }
4897
4898 pub fn lodging_occupant_labels(&self, container_id: &str) -> Vec<String> {
4900 let mut names: Vec<String> = self
4901 .hired_workers
4902 .iter()
4903 .filter(|w| w.lodging_container_id.as_deref() == Some(container_id))
4904 .map(|w| w.label.clone())
4905 .collect();
4906 names.sort();
4907 names
4908 }
4909
4910 pub fn lodging_occupancy_label(&self, container_id: &str) -> Option<String> {
4912 let is_lodging = self
4913 .placed_containers
4914 .iter()
4915 .find(|c| c.id == container_id)
4916 .is_some_and(|c| c.worker_lodging_capacity.unwrap_or(0) > 0);
4917 if !is_lodging {
4918 return None;
4919 }
4920 let names = self.lodging_occupant_labels(container_id);
4921 Some(if names.is_empty() {
4922 "vacant".into()
4923 } else {
4924 names.join(", ")
4925 })
4926 }
4927
4928 pub fn tracked_quest(&self) -> Option<&flatland_protocol::QuestLogEntry> {
4929 self.quest_log
4930 .iter()
4931 .find(|q| q.is_tracked && q.status == flatland_protocol::QuestStatusView::Active)
4932 .or_else(|| {
4933 self.quest_log
4934 .iter()
4935 .find(|q| q.status == flatland_protocol::QuestStatusView::Active)
4936 })
4937 }
4938
4939 pub fn nearby_lockable_door(&self) -> bool {
4941 let (px, py) = self.player_position();
4942 self.doors
4943 .iter()
4944 .any(|d| d.lock_id.is_some() && (d.x - px).hypot(d.y - py) <= 3.5)
4945 }
4946
4947 pub fn nearby_open_player_door(&self) -> bool {
4949 if self.effective_inside_building().is_some() {
4950 return false;
4951 }
4952 let (px, py) = self.player_position();
4953 self.doors.iter().any(|d| {
4954 if !d.open || d.locked {
4955 return false;
4956 }
4957 let player_house = self
4958 .buildings
4959 .iter()
4960 .find(|b| b.id == d.building_id)
4961 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
4962 player_house && (d.x - px).hypot(d.y - py) <= 3.5
4963 })
4964 }
4965
4966 pub fn nearby_player_exit_door(&self) -> bool {
4968 let Some(bid) = self.effective_inside_building() else {
4969 return false;
4970 };
4971 let (px, py) = self.player_position();
4972 self.doors.iter().any(|d| {
4973 if d.building_id != bid || d.portal.is_none() {
4974 return false;
4975 }
4976 let player_house = self
4977 .buildings
4978 .iter()
4979 .find(|b| b.id == d.building_id)
4980 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
4981 player_house && (d.x - px).hypot(d.y - py) <= 1.5
4982 })
4983 }
4984
4985 pub fn nearest_interact_target(&self) -> Option<String> {
4987 let (px, py) = self.player_position();
4988 let inside = self.effective_inside_building();
4989
4990 #[derive(Clone, Copy, PartialEq, Eq)]
4991 enum Kind {
4992 Player,
4993 Npc,
4994 HiredWorker,
4995 QuestBoard,
4996 ExitDoor,
4997 EnterDoor,
4998 Well,
4999 Water,
5000 }
5001
5002 fn kind_priority(kind: Kind) -> u8 {
5003 match kind {
5004 Kind::Player => 0,
5005 Kind::Npc => 0,
5006 Kind::HiredWorker => 0,
5007 Kind::QuestBoard => 1,
5008 Kind::ExitDoor => 2,
5009 Kind::EnterDoor => 3,
5010 Kind::Well => 4,
5011 Kind::Water => 5,
5012 }
5013 }
5014
5015 let mut best: Option<(f32, Kind, String)> = None;
5016
5017 let mut consider = |dist: f32, max: f32, kind: Kind, id: String| {
5018 if dist > max {
5019 return;
5020 }
5021 let replace = match best {
5022 None => true,
5023 Some((bd, _bk, _)) if dist < bd - 0.05 => true,
5024 Some((bd, bk, _)) if (dist - bd).abs() <= 0.05 => {
5025 kind_priority(kind) < kind_priority(bk)
5026 }
5027 _ => false,
5028 };
5029 if replace {
5030 best = Some((dist, kind, id));
5031 }
5032 };
5033
5034 for npc in &self.npcs {
5035 consider(
5036 distance(px, py, npc.x, npc.y),
5037 INTERACTION_RADIUS_M,
5038 Kind::Npc,
5039 npc.id.clone(),
5040 );
5041 }
5042
5043 for worker in &self.hired_workers {
5044 consider(
5045 distance(px, py, worker.x, worker.y),
5046 INTERACTION_RADIUS_M,
5047 Kind::HiredWorker,
5048 worker.instance_id.clone(),
5049 );
5050 }
5051
5052 for entity in &self.entities {
5053 if entity.id == self.entity_id || entity.vitals.is_none() || entity.label.trim().is_empty()
5054 {
5055 continue;
5056 }
5057 if self
5059 .hired_workers
5060 .iter()
5061 .any(|w| w.entity_id == entity.id)
5062 {
5063 continue;
5064 }
5065 consider(
5066 distance(
5067 px,
5068 py,
5069 entity.transform.position.x,
5070 entity.transform.position.y,
5071 ),
5072 INTERACTION_RADIUS_M,
5073 Kind::Player,
5074 entity.id.to_string(),
5075 );
5076 }
5077
5078 for door in &self.doors {
5079 if let Some(ref bid) = inside {
5080 if door.building_id != *bid {
5081 continue;
5082 }
5083 let is_exit = door.portal.is_some();
5084 let max = if is_exit {
5085 INTERACTION_RADIUS_M
5086 } else {
5087 DOOR_INTERACTION_RADIUS_M
5088 };
5089 let kind = if is_exit {
5090 Kind::ExitDoor
5091 } else {
5092 Kind::EnterDoor
5093 };
5094 consider(distance(px, py, door.x, door.y), max, kind, door.id.clone());
5095 continue;
5096 }
5097 consider(
5098 distance(px, py, door.x, door.y),
5099 DOOR_INTERACTION_RADIUS_M,
5100 Kind::EnterDoor,
5101 door.id.clone(),
5102 );
5103 }
5104
5105 if inside.is_none() {
5106 for inter in &self.interactables {
5107 if inter.kind == "quest_board" {
5108 consider(
5109 distance(px, py, inter.x, inter.y),
5110 QUEST_BOARD_INTERACTION_RADIUS_M,
5111 Kind::QuestBoard,
5112 inter.id.clone(),
5113 );
5114 }
5115 }
5116 for building in &self.buildings {
5117 if !building.tags.iter().any(|t| t == "well") {
5118 continue;
5119 }
5120 consider(
5121 distance(px, py, building.x, building.y),
5122 INTERACTION_RADIUS_M,
5123 Kind::Well,
5124 building.id.clone(),
5125 );
5126 }
5127 if self.in_shallow_water() {
5128 consider(
5129 0.0,
5130 INTERACTION_RADIUS_M,
5131 Kind::Water,
5132 "water_source".into(),
5133 );
5134 }
5135 }
5136
5137 best.map(|(_, _, id)| id)
5138 }
5139
5140 pub fn nearest_quest_board(&self) -> Option<(String, f32)> {
5142 if self.effective_inside_building().is_some() {
5143 return None;
5144 }
5145 let (px, py) = self.player_position();
5146 self.interactables
5147 .iter()
5148 .filter(|i| i.kind == "quest_board")
5149 .map(|i| {
5150 let label = if i.label.is_empty() {
5151 "Quest board".to_string()
5152 } else {
5153 i.label.clone()
5154 };
5155 (label, distance(px, py, i.x, i.y))
5156 })
5157 .min_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
5158 }
5159
5160 pub fn template_display_name(&self, template_id: &str) -> String {
5162 self.inventory_hints
5163 .get(template_id)
5164 .map(|h| h.display_name.clone())
5165 .filter(|n| !n.is_empty())
5166 .unwrap_or_else(|| humanize_template_id(template_id))
5167 }
5168
5169 pub fn blueprint_item_label(&self, template_id: &str, display_name: &str) -> String {
5171 if !display_name.is_empty() {
5172 display_name.to_string()
5173 } else {
5174 self.template_display_name(template_id)
5175 }
5176 }
5177
5178 pub fn blueprint_output_label(&self, blueprint: &BlueprintView) -> String {
5179 self.blueprint_item_label(&blueprint.output, &blueprint.output_display_name)
5180 }
5181
5182 pub fn blueprint_ingredient_label(
5183 &self,
5184 input: &flatland_protocol::BlueprintIngredientView,
5185 ) -> String {
5186 self.blueprint_item_label(&input.template_id, &input.display_name)
5187 }
5188
5189 pub fn blueprint_tool_label(&self, tool: &flatland_protocol::ToolRequirementView) -> String {
5190 self.blueprint_item_label(&tool.item, &tool.display_name)
5191 }
5192
5193 pub fn route_editor_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
5195 use crate::worker_route_editor::{
5196 node_candidates, node_candidates_stable, route_editor_lodging_anchor,
5197 };
5198 let lodging = self
5199 .worker_route_editor
5200 .as_ref()
5201 .and_then(|ed| ed.lodging_container_id.as_deref());
5202 match route_editor_lodging_anchor(lodging, &self.placed_containers) {
5203 Some((ax, ay)) => node_candidates(&self.resource_nodes, ax, ay),
5204 None => node_candidates_stable(&self.resource_nodes),
5205 }
5206 }
5207
5208 pub fn route_editor_harvest_dist_label(&self, dist_m: f32) -> String {
5209 if dist_m.is_nan() {
5210 return "—".into();
5211 }
5212 let from_bed = self
5213 .worker_route_editor
5214 .as_ref()
5215 .and_then(|ed| ed.lodging_container_id.as_deref())
5216 .and_then(|id| {
5217 self.placed_containers
5218 .iter()
5219 .find(|c| c.id == id)
5220 .map(|c| c.display_name.clone())
5221 });
5222 match from_bed {
5223 Some(bed) => format!("{dist_m:.0}m from {bed}"),
5224 None => format!("{dist_m:.0}m"),
5225 }
5226 }
5227
5228 pub fn placed_container_public_label(
5230 &self,
5231 c: &flatland_protocol::PlacedContainerView,
5232 ) -> String {
5233 let is_owner = match (self.character_id, c.owner_character_id) {
5234 (Some(me), Some(owner)) => me == owner,
5235 _ => false,
5236 };
5237 if is_owner {
5238 c.display_name.clone()
5239 } else {
5240 self.template_display_name(&c.template_id)
5241 }
5242 }
5243
5244 pub fn keychain_entries(&self) -> Vec<KeychainEntry> {
5246 let mut out = Vec::new();
5247 for stack in &self.inventory_stacks {
5248 if stack.template_id == KEY_TEMPLATE {
5249 out.push(KeychainEntry {
5250 stack: stack.clone(),
5251 stowed: false,
5252 });
5253 }
5254 }
5255 for stack in &self.keychain_stacks {
5256 if stack.template_id == KEY_TEMPLATE {
5257 out.push(KeychainEntry {
5258 stack: stack.clone(),
5259 stowed: true,
5260 });
5261 }
5262 }
5263 out
5264 }
5265
5266 pub fn key_pair_chest_label(&self, stack: &flatland_protocol::ItemStack) -> Option<String> {
5268 if stack.template_id != KEY_TEMPLATE {
5269 return None;
5270 }
5271 if let Some(name) = stack
5272 .props
5273 .get(PROP_OPENS_CONTAINER_NAME)
5274 .filter(|n| !n.is_empty())
5275 {
5276 return Some(name.clone());
5277 }
5278 let opens = stack.props.get(PROP_OPENS_LOCK_ID)?;
5279 self.container_name_for_lock_id(opens)
5280 }
5281
5282 pub fn key_inventory_label(&self, stack: &flatland_protocol::ItemStack) -> String {
5284 if stack.template_id == KEY_TEMPLATE {
5285 self.template_display_name(KEY_TEMPLATE)
5286 } else {
5287 stack
5288 .display_name
5289 .clone()
5290 .unwrap_or_else(|| stack.template_id.clone())
5291 }
5292 }
5293
5294 pub fn key_inventory_hint(&self, stack: &flatland_protocol::ItemStack) -> String {
5296 if stack.template_id != KEY_TEMPLATE {
5297 return String::new();
5298 }
5299 match self.key_pair_chest_label(stack) {
5300 Some(chest) if self.key_drop_blocked(stack) => {
5301 format!(" [key for {chest} — can't drop while locked]")
5302 }
5303 Some(chest) => format!(" [key for {chest}]"),
5304 None => " [key — unpaired]".into(),
5305 }
5306 }
5307
5308 pub fn container_name_for_lock_id(&self, lock: &str) -> Option<String> {
5310 for c in &self.placed_containers {
5311 if c.lock_id.as_deref() == Some(lock) {
5312 return Some(c.display_name.clone());
5313 }
5314 }
5315 Self::container_name_in_stacks(&self.inventory_stacks, lock).or_else(|| {
5316 self.worn
5317 .values()
5318 .find_map(|worn| Self::container_name_in_stacks(std::slice::from_ref(worn), lock))
5319 })
5320 }
5321
5322 pub fn key_drop_blocked(&self, stack: &flatland_protocol::ItemStack) -> bool {
5324 if stack.template_id != KEY_TEMPLATE {
5325 return false;
5326 }
5327 let Some(opens) = stack.props.get(PROP_OPENS_LOCK_ID) else {
5328 return false;
5329 };
5330 for c in &self.placed_containers {
5331 if c.lock_id.as_deref() == Some(opens.as_str()) && c.locked {
5332 return true;
5333 }
5334 }
5335 if Self::has_locked_container_with_lock(&self.inventory_stacks, opens) {
5336 return true;
5337 }
5338 self.worn
5339 .values()
5340 .any(|worn| Self::has_locked_container_with_lock(std::slice::from_ref(worn), opens))
5341 }
5342
5343 pub fn deed_bound(&self, stack: &flatland_protocol::ItemStack) -> bool {
5345 stack.template_id == PROPERTY_DEED_TEMPLATE
5346 }
5347
5348 pub fn is_property_deed_template(template_id: &str) -> bool {
5349 template_id == PROPERTY_DEED_TEMPLATE
5350 }
5351
5352 pub fn deed_plot_id(stack: &flatland_protocol::ItemStack) -> Option<uuid::Uuid> {
5353 stack
5354 .props
5355 .get("plot_id")
5356 .and_then(|s| uuid::Uuid::parse_str(s).ok())
5357 }
5358
5359 pub fn cultivate_target_under_player(&self) -> Option<(f32, f32)> {
5361 let (px, py) = self.player_position();
5362 let (cx, cy) = self.farm_plot_cell_under_player()?;
5363 let tx = cx as f32 + 0.5;
5364 let ty = cy as f32 + 0.5;
5365 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5366 return None;
5367 }
5368 let kind = self
5369 .terrain_at(tx, ty)
5370 .or_else(|| self.terrain_at(px, py));
5371 if kind == Some(TerrainKindView::Tilled) {
5372 return None;
5373 }
5374 if matches!(
5375 kind,
5376 Some(TerrainKindView::ShallowWater)
5377 | Some(TerrainKindView::DeepWater)
5378 | Some(TerrainKindView::Rock)
5379 ) {
5380 return None;
5381 }
5382 Some((tx, ty))
5383 }
5384
5385 fn container_name_in_stacks(
5386 stacks: &[flatland_protocol::ItemStack],
5387 lock: &str,
5388 ) -> Option<String> {
5389 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> Option<String> {
5390 for s in stacks {
5391 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) {
5392 return Some(GameState::stack_container_label(s));
5393 }
5394 if let Some(name) = walk(&s.contents, lock) {
5395 return Some(name);
5396 }
5397 }
5398 None
5399 }
5400 walk(stacks, lock)
5401 }
5402
5403 fn stack_container_label(stack: &flatland_protocol::ItemStack) -> String {
5404 stack
5405 .props
5406 .get(PROP_CUSTOM_NAME)
5407 .cloned()
5408 .or_else(|| stack.display_name.clone())
5409 .unwrap_or_else(|| stack.template_id.clone())
5410 }
5411
5412 fn has_locked_container_with_lock(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5413 fn walk(stacks: &[flatland_protocol::ItemStack], lock: &str) -> bool {
5414 for s in stacks {
5415 if s.props.get(PROP_LOCK_ID) == Some(&lock.to_string()) && stack_is_locked(s) {
5416 return true;
5417 }
5418 if walk(&s.contents, lock) {
5419 return true;
5420 }
5421 }
5422 false
5423 }
5424 walk(stacks, lock)
5425 }
5426
5427 fn stack_for_instance(&self, instance_id: uuid::Uuid) -> Option<flatland_protocol::ItemStack> {
5428 if let Some(stack) = self.find_stack_by_instance(&self.inventory_stacks, instance_id) {
5429 return Some(stack.clone());
5430 }
5431 for worn in self.worn.values() {
5432 if worn.item_instance_id == Some(instance_id) {
5433 return Some(worn.clone());
5434 }
5435 if let Some(stack) = self.find_stack_by_instance(&worn.contents, instance_id) {
5436 return Some(stack.clone());
5437 }
5438 }
5439 None
5440 }
5441
5442 pub fn property_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::PropertyZoneView> {
5444 self.property_zones
5445 .iter()
5446 .enumerate()
5447 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5448 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5449 .map(|(_, z)| z)
5450 }
5451
5452 pub fn tax_zone_at(&self, x: f32, y: f32) -> Option<&flatland_protocol::TaxZoneView> {
5454 self.tax_zones
5455 .iter()
5456 .enumerate()
5457 .filter(|(_, z)| zone_rects_contain(&z.rects, x, y))
5458 .max_by(|(ia, a), (ib, b)| a.z_order.cmp(&b.z_order).then(ia.cmp(ib)))
5459 .map(|(_, z)| z)
5460 }
5461
5462 pub fn tax_rate_bps_at_rect(&self, x0: f32, y0: f32, x1: f32, y1: f32) -> u32 {
5464 let mut max_bps = 0u32;
5465 let mut y = y0 + 0.5;
5466 while y < y1 {
5467 let mut x = x0 + 0.5;
5468 while x < x1 {
5469 if let Some(tz) = self.tax_zone_at(x, y) {
5470 max_bps = max_bps.max(tz.rate_bps);
5471 }
5472 x += 1.0;
5473 }
5474 y += 1.0;
5475 }
5476 max_bps
5477 }
5478
5479 pub fn claim_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5481 let mode = self.claim_mode.as_ref()?;
5482 let w = mode.width_m.max(1) as f32;
5483 let h = mode.height_m.max(1) as f32;
5484 Some((mode.anchor_x, mode.anchor_y, mode.anchor_x + w, mode.anchor_y + h))
5485 }
5486
5487 pub fn relocate_footprint_rect(&self) -> Option<(f32, f32, f32, f32)> {
5489 let mode = self.relocate_mode.as_ref()?;
5490 let x0 = mode.cursor_x.floor();
5491 let y0 = mode.cursor_y.floor();
5492 Some((x0, y0, x0 + 1.0, y0 + 1.0))
5493 }
5494
5495 pub fn claim_quote(
5498 &self,
5499 ) -> Option<(u64, u64, f32, f32, bool, bool, String)> {
5500 let mode = self.claim_mode.as_ref()?;
5501 let zone = self
5502 .property_zones
5503 .iter()
5504 .find(|z| z.id == mode.zone_id)?;
5505 let (x0, y0, x1, y1) = self.claim_footprint_rect()?;
5506 let area = (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
5507 let zone_area = zone_view_area_m2(zone).max(1.0);
5508 let area_frac = (area / zone_area).clamp(0.0, 1.0);
5509 let weight = self
5510 .property_plot_settings
5511 .as_ref()
5512 .map(|s| s.tax_premium_weight)
5513 .unwrap_or(0.5)
5514 .max(0.0);
5515 let rate = self.tax_rate_bps_at_rect(x0, y0, x1, y1);
5516 let premium = 1.0 + (rate as f32 / 10_000.0) * weight;
5517 let purchase = ((zone.crown_price_copper as f64)
5518 * (area_frac as f64)
5519 * (premium as f64))
5520 .ceil()
5521 .max(0.0) as u64;
5522 let upkeep = if zone.upkeep_copper_per_day == 0 {
5523 0
5524 } else {
5525 ((zone.upkeep_copper_per_day as f64) * (area_frac as f64) * (premium as f64))
5526 .ceil()
5527 .max(1.0) as u64
5528 };
5529 let copper = crate::currency::copper_from_counts(&self.inventory);
5530 let can_afford = copper >= purchase;
5531 let (valid, reason) = self.validate_claim_footprint(zone, x0, y0, x1, y1, area);
5532 Some((purchase, upkeep, area, premium, can_afford, valid, reason))
5533 }
5534
5535 fn validate_claim_footprint(
5536 &self,
5537 zone: &flatland_protocol::PropertyZoneView,
5538 x0: f32,
5539 y0: f32,
5540 x1: f32,
5541 y1: f32,
5542 area: f32,
5543 ) -> (bool, String) {
5544 let min_area = self
5545 .property_plot_settings
5546 .as_ref()
5547 .map(|s| s.min_plot_area_m2)
5548 .unwrap_or(4.0);
5549 if area + f32::EPSILON < min_area {
5550 return (false, "plot too small".into());
5551 }
5552 if zone.max_area_m2.is_some_and(|m| area > m) {
5553 return (false, "plot exceeds max area".into());
5554 }
5555 if !claim_rect_fully_inside_zone(zone, x0, y0, x1, y1) {
5556 return (false, "plot must lie inside the property zone".into());
5557 }
5558 if self.property_plots.iter().any(|p| {
5559 rects_overlap_half_open(x0, y0, x1, y1, p.x0, p.y0, p.x1, p.y1)
5560 }) {
5561 return (false, "plot overlaps an existing claim".into());
5562 }
5563 (true, String::new())
5564 }
5565
5566 pub fn free_property_zone_under_player(
5568 &self,
5569 ) -> Option<&flatland_protocol::PropertyZoneView> {
5570 let (px, py) = self.player_position();
5571 let zone = self.property_zone_at(px, py)?;
5572 if self
5573 .property_plots
5574 .iter()
5575 .any(|p| point_in_plot(px, py, p))
5576 {
5577 return None;
5578 }
5579 Some(zone)
5580 }
5581
5582 pub fn my_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5584 let (px, py) = self.player_position();
5585 self.property_plots
5586 .iter()
5587 .find(|p| p.is_mine && point_in_plot(px, py, p))
5588 }
5589
5590 pub fn farmable_plot_under_player(&self) -> Option<&flatland_protocol::PropertyPlotView> {
5592 let (px, py) = self.player_position();
5593 self.property_plots
5594 .iter()
5595 .find(|p| (p.is_mine || p.may_farm) && point_in_plot(px, py, p))
5596 }
5597
5598 pub fn farm_plot_cell_under_player(&self) -> Option<(i32, i32)> {
5600 if self.farmable_plot_under_player().is_none() {
5601 return None;
5602 }
5603 let (px, py) = self.player_position();
5604 Some((px.floor() as i32, py.floor() as i32))
5605 }
5606
5607 fn resource_node_occupies_farm_cell(&self, cx: i32, cy: i32) -> bool {
5608 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5609 self.resource_nodes.iter().any(|n| {
5610 let (ncx, ncy) = (n.x.floor() as i32, n.y.floor() as i32);
5611 ncx == cx && ncy == cy
5612 || ((n.x - tx).abs() < 0.51 && (n.y - ty).abs() < 0.51)
5613 })
5614 }
5615
5616 fn free_tilled_plant_slot_at(&self, cx: i32, cy: i32) -> bool {
5617 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5618 let tilled = self.terrain_at(tx, ty) == Some(TerrainKindView::Tilled)
5619 || self
5620 .terrain_zone_at(cx as f32 + 0.25, cy as f32 + 0.25)
5621 .is_some_and(|z| z.kind == TerrainKindView::Tilled);
5622 if !tilled {
5623 return false;
5624 }
5625 !self.resource_node_occupies_farm_cell(cx, cy)
5626 }
5627
5628 pub fn underfoot_free_tilled_plant_slot(&self) -> bool {
5630 let Some((cx, cy)) = self.farm_plot_cell_under_player() else {
5631 return false;
5632 };
5633 self.free_tilled_plant_slot_at(cx, cy)
5634 }
5635
5636 pub fn has_nearby_free_tilled_plant_slot(&self) -> bool {
5638 let (px, py) = self.player_position();
5639 for dy in -2..=2 {
5640 for dx in -2..=2 {
5641 let cx = px.floor() as i32 + dx;
5642 let cy = py.floor() as i32 + dy;
5643 let (tx, ty) = (cx as f32 + 0.5, cy as f32 + 0.5);
5644 if distance(px, py, tx, ty) > INTERACTION_RADIUS_M {
5645 continue;
5646 }
5647 if self.free_tilled_plant_slot_at(cx, cy) {
5648 return true;
5649 }
5650 }
5651 }
5652 false
5653 }
5654
5655 fn stack_is_farm_seed(stack: &flatland_protocol::ItemStack) -> bool {
5656 stack.quantity > 0
5657 && (stack.props.contains_key("seed_for")
5658 || stack.template_id.ends_with("_seed")
5659 || stack.template_id == "potato_seed"
5660 || stack.template_id == "carrot_seed")
5661 }
5662
5663 pub fn farm_seed_entries(&self) -> Vec<(String, u32, String)> {
5665 let mut counts: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
5666 fn walk(
5667 stacks: &[flatland_protocol::ItemStack],
5668 counts: &mut std::collections::HashMap<String, u32>,
5669 ) {
5670 for s in stacks {
5671 if GameState::stack_is_farm_seed(s) {
5672 *counts.entry(s.template_id.clone()).or_default() += s.quantity;
5673 }
5674 walk(&s.contents, counts);
5675 }
5676 }
5677 walk(&self.inventory_stacks, &mut counts);
5678 for worn in self.worn.values() {
5679 walk(std::slice::from_ref(worn), &mut counts);
5680 }
5681 let mut out: Vec<_> = counts
5682 .into_iter()
5683 .map(|(template_id, quantity)| {
5684 let label = self
5685 .inventory_hints
5686 .get(&template_id)
5687 .map(|h| h.display_name.clone())
5688 .filter(|n| !n.trim().is_empty())
5689 .unwrap_or_else(|| humanize_template_id(&template_id));
5690 (template_id, quantity, label)
5691 })
5692 .collect();
5693 out.sort_by(|a, b| a.2.cmp(&b.2).then_with(|| a.0.cmp(&b.0)));
5694 out
5695 }
5696
5697 pub fn first_farm_seed_template(&self) -> Option<String> {
5699 self.farm_seed_entries()
5700 .into_iter()
5701 .next()
5702 .map(|(id, _, _)| id)
5703 }
5704
5705 pub fn clamp_plant_menu(&mut self) {
5706 let n = self.farm_seed_entries().len();
5707 if n == 0 {
5708 self.plant_menu_index = 0;
5709 self.plant_quantity = 1;
5710 return;
5711 }
5712 self.plant_menu_index = self.plant_menu_index.min(n - 1);
5713 let max_qty = self
5714 .farm_seed_entries()
5715 .get(self.plant_menu_index)
5716 .map(|(_, q, _)| *q)
5717 .unwrap_or(1)
5718 .max(1);
5719 self.plant_quantity = self.plant_quantity.clamp(1, max_qty);
5720 }
5721
5722 pub fn plant_menu_selection(&self) -> Option<(String, u32, String)> {
5723 let entries = self.farm_seed_entries();
5724 let (id, max, label) = entries.get(self.plant_menu_index)?;
5725 let qty = self.plant_quantity.min(*max).max(1);
5726 Some((id.clone(), qty, label.clone()))
5727 }
5728
5729 pub fn location_context_lines(&self) -> Vec<ContextLine> {
5731 let (px, py) = self.player_position();
5732 let inside = self.effective_inside_building();
5733 let mut lines = Vec::new();
5734
5735 if let Some(kind) = self.terrain_at(px, py) {
5736 lines.push(ContextLine {
5737 on_top: true,
5738 text: format!("Terrain: {}", terrain_kind_label(kind)),
5739 });
5740 }
5741
5742 if let Some(id) = inside.as_ref() {
5743 if let Some(b) = self.buildings.iter().find(|b| &b.id == id) {
5744 lines.push(ContextLine {
5745 on_top: true,
5746 text: format!("Inside: {}", b.label),
5747 });
5748 }
5749 }
5750
5751 let mut nearby: Vec<(f32, ContextLine)> = Vec::new();
5752
5753 for node in &self.resource_nodes {
5754 if node.id.starts_with("preview:") {
5755 continue;
5756 }
5757 let dist = distance(px, py, node.x, node.y);
5758 if dist > NEARBY_SCAN_M {
5759 continue;
5760 }
5761 let on_top = dist <= ON_TOP_RADIUS_M;
5762 let prefix = if on_top { "On" } else { "Near" };
5763 let name = resource_node_near_display_label(&node.label);
5764 let action = resource_node_near_action_suffix(node);
5765 nearby.push((
5766 dist,
5767 ContextLine {
5768 on_top,
5769 text: format!("{prefix}: {name} ({dist:.1}m){action}"),
5770 },
5771 ));
5772 }
5773
5774 for drop in &self.ground_drops {
5775 let dist = distance(px, py, drop.x, drop.y);
5776 if dist > INTERACTION_RADIUS_M {
5777 continue;
5778 }
5779 let on_top = dist <= ON_TOP_RADIUS_M;
5780 let name = self.template_display_name(&drop.template_id);
5781 let prefix = if on_top { "On" } else { "Near" };
5782 let qty = if drop.quantity > 1 {
5783 format!(" ×{}", drop.quantity)
5784 } else {
5785 String::new()
5786 };
5787 nearby.push((
5788 dist,
5789 ContextLine {
5790 on_top,
5791 text: format!("{prefix}: {name}{qty} ({dist:.1}m) — f pickup"),
5792 },
5793 ));
5794 }
5795
5796 for c in &self.placed_containers {
5797 let dist = distance(px, py, c.x, c.y);
5798 if dist > CONTAINER_RANGE_M {
5799 continue;
5800 }
5801 let on_top = dist <= ON_TOP_RADIUS_M;
5802 let name = self.placed_container_public_label(c);
5803 let lock = if c.locked { " [locked]" } else { "" };
5804 let prefix = if on_top { "On" } else { "Near" };
5805 nearby.push((
5806 dist,
5807 ContextLine {
5808 on_top,
5809 text: format!("{prefix}: {name}{lock} ({dist:.1}m) — f pickup"),
5810 },
5811 ));
5812 }
5813
5814 for npc in &self.npcs {
5815 let dist = distance(px, py, npc.x, npc.y);
5816 if dist > NEARBY_SCAN_M {
5817 continue;
5818 }
5819 let on_top = dist <= ON_TOP_RADIUS_M;
5820 let prefix = if on_top { "On" } else { "Near" };
5821 nearby.push((
5822 dist,
5823 ContextLine {
5824 on_top,
5825 text: format!("{prefix}: {} ({dist:.1}m) — f talk", npc.label),
5826 },
5827 ));
5828 }
5829
5830 for door in &self.doors {
5831 let dist = distance(px, py, door.x, door.y);
5832 if dist > DOOR_INTERACTION_RADIUS_M {
5833 continue;
5834 }
5835 let building = self
5836 .buildings
5837 .iter()
5838 .find(|b| b.id == door.building_id)
5839 .map(|b| b.label.as_str())
5840 .unwrap_or(door.building_id.as_str());
5841 let player_house = self
5842 .buildings
5843 .iter()
5844 .find(|b| b.id == door.building_id)
5845 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
5846 let action = if inside.is_some() && door.portal.is_some() {
5847 if player_house {
5848 if door.locked {
5849 "locked — l unlock · Enter exit".to_string()
5850 } else if door.open {
5851 "close · Enter exit · l lock".to_string()
5852 } else {
5853 "open · Enter exit · l lock".to_string()
5854 }
5855 } else {
5856 "exit".to_string()
5857 }
5858 } else if player_house {
5859 if door.locked {
5860 "locked — l unlock".to_string()
5861 } else if door.open {
5862 "close · Enter go inside · l lock".to_string()
5863 } else {
5864 "open · l lock".to_string()
5865 }
5866 } else {
5867 "enter".to_string()
5868 };
5869 nearby.push((
5870 dist,
5871 ContextLine {
5872 on_top: dist <= ON_TOP_RADIUS_M,
5873 text: format!("{building} door ({dist:.1}m) — f {action}"),
5874 },
5875 ));
5876 }
5877
5878 if inside.is_none() {
5879 for inter in &self.interactables {
5880 if inter.kind != "quest_board" {
5881 continue;
5882 }
5883 let dist = distance(px, py, inter.x, inter.y);
5884 if dist > QUEST_BOARD_INTERACTION_RADIUS_M {
5885 continue;
5886 }
5887 let on_top = dist <= ON_TOP_RADIUS_M;
5888 let prefix = if on_top { "On" } else { "Near" };
5889 let label = if inter.label.is_empty() {
5890 "Quest board".to_string()
5891 } else {
5892 inter.label.clone()
5893 };
5894 nearby.push((
5895 dist,
5896 ContextLine {
5897 on_top,
5898 text: format!("{prefix}: {label} ({dist:.1}m) — f view quests"),
5899 },
5900 ));
5901 }
5902 }
5903
5904 if self.in_shallow_water() {
5905 let already = self
5906 .terrain_at(px, py)
5907 .is_some_and(|k| k == flatland_protocol::TerrainKindView::ShallowWater);
5908 if !already {
5909 nearby.push((
5910 0.0,
5911 ContextLine {
5912 on_top: true,
5913 text: "Shallow water — f fill bottle".into(),
5914 },
5915 ));
5916 } else if let Some(line) = lines.iter_mut().find(|l| l.text.starts_with("Terrain:")) {
5917 line.text.push_str(" — f fill bottle");
5918 }
5919 }
5920
5921 if self.claim_mode.is_some() {
5922 nearby.push((
5923 0.0,
5924 ContextLine {
5925 on_top: true,
5926 text: "Claim mode — WASD move · [ ] size · 2/4/8 · Enter buy · Esc cancel"
5927 .into(),
5928 },
5929 ));
5930 } else if let Some(plot) = self.my_plot_under_player() {
5931 let zone = plot
5932 .zone_label
5933 .as_deref()
5934 .filter(|s| !s.trim().is_empty())
5935 .or_else(|| {
5936 self.property_zones
5937 .iter()
5938 .find(|z| z.id == plot.property_zone_id)
5939 .and_then(|z| z.label.as_deref().filter(|s| !s.trim().is_empty()))
5940 })
5941 .unwrap_or(plot.property_zone_id.as_str());
5942 let prompt = if self.sell_plot_confirm == Some(plot.plot_id) {
5943 format!("Your plot ({zone}) — f again to sell to crown")
5944 } else {
5945 format!(
5946 "Your plot ({zone}) — c till · p plant · f harvest · B build · l door lock · o farm access"
5947 )
5948 };
5949 nearby.push((
5950 0.0,
5951 ContextLine {
5952 on_top: true,
5953 text: prompt,
5954 },
5955 ));
5956 } else if let Some(plot) = self.farmable_plot_under_player() {
5957 let owner = plot
5958 .owner_label
5959 .as_deref()
5960 .filter(|s| !s.trim().is_empty())
5961 .unwrap_or("owner");
5962 let disc = if plot.farm_public {
5963 plot.public_tax_discount_bps / 100
5964 } else {
5965 plot.farm_allow
5966 .iter()
5967 .find(|g| Some(g.character_id) == self.character_id)
5968 .map(|g| g.tax_discount_bps / 100)
5969 .unwrap_or(0)
5970 };
5971 nearby.push((
5972 0.0,
5973 ContextLine {
5974 on_top: true,
5975 text: format!(
5976 "Farming permitted — {owner} (tax −{disc}%) — c till · p plant · f harvest"
5977 ),
5978 },
5979 ));
5980 } else if let Some(zone) = self.free_property_zone_under_player() {
5981 let label = zone
5982 .label
5983 .as_deref()
5984 .filter(|s| !s.trim().is_empty())
5985 .unwrap_or(zone.id.as_str());
5986 nearby.push((
5987 0.0,
5988 ContextLine {
5989 on_top: true,
5990 text: format!("Claimable land: {label} — k buy plot"),
5991 },
5992 ));
5993 }
5994
5995 for entity in &self.entities {
5996 if entity.id == self.entity_id {
5997 continue;
5998 }
5999 let dist = distance(
6000 px,
6001 py,
6002 entity.transform.position.x,
6003 entity.transform.position.y,
6004 );
6005 if dist > NEARBY_SCAN_M {
6006 continue;
6007 }
6008 let label = if entity.label.is_empty() {
6009 format!("entity {}", entity.id)
6010 } else {
6011 entity.label.clone()
6012 };
6013 nearby.push((
6014 dist,
6015 ContextLine {
6016 on_top: dist <= ON_TOP_RADIUS_M,
6017 text: format!("Near: {label} ({dist:.1}m)"),
6018 },
6019 ));
6020 }
6021
6022 nearby.sort_by(|a, b| {
6023 a.0.partial_cmp(&b.0)
6024 .unwrap_or(std::cmp::Ordering::Equal)
6025 .then_with(|| a.1.on_top.cmp(&b.1.on_top).reverse())
6026 });
6027 lines.extend(nearby.into_iter().map(|(_, l)| l));
6028
6029 if lines.is_empty() {
6030 lines.push(ContextLine {
6031 on_top: false,
6032 text: "(nothing notable nearby)".into(),
6033 });
6034 }
6035
6036 lines
6037 }
6038}
6039
6040#[derive(Debug, Clone)]
6042pub struct ContextLine {
6043 pub on_top: bool,
6044 pub text: String,
6045}
6046
6047const ON_TOP_RADIUS_M: f32 = 0.65;
6048const NEARBY_SCAN_M: f32 = 5.0;
6049
6050pub fn resource_node_near_display_label(label: &str) -> String {
6052 label
6053 .strip_suffix(" (growing)")
6054 .unwrap_or(label)
6055 .to_string()
6056}
6057
6058pub fn resource_node_near_action_suffix(node: &flatland_protocol::ResourceNodeView) -> String {
6060 use flatland_protocol::ResourceNodeState;
6061 if node.harvest_off {
6062 return " (decorative)".to_string();
6063 }
6064 if let Some(p) = node.growth_progress {
6065 if p < 1.0 - f32::EPSILON {
6066 let pct = (p.clamp(0.0, 1.0) * 100.0).round() as u32;
6067 return format!(" (growing, {pct}%)");
6068 }
6069 return " — f harvest".to_string();
6070 }
6071 match node.state {
6072 ResourceNodeState::Available => " — f harvest".to_string(),
6073 ResourceNodeState::Harvesting => " (being harvested)".to_string(),
6074 ResourceNodeState::Cooldown => " (depleted)".to_string(),
6075 }
6076}
6077
6078fn terrain_kind_label(kind: flatland_protocol::TerrainKindView) -> &'static str {
6079 use flatland_protocol::TerrainKindView;
6080 match kind {
6081 TerrainKindView::Grass => "Grass",
6082 TerrainKindView::Dirt => "Dirt",
6083 TerrainKindView::Tilled => "Tilled",
6084 TerrainKindView::Desert => "Desert",
6085 TerrainKindView::Hill => "Hills",
6086 TerrainKindView::Bog => "Bog",
6087 TerrainKindView::Beach => "Beach",
6088 TerrainKindView::ShallowWater => "Shallow water",
6089 TerrainKindView::DeepWater => "Deep water",
6090 TerrainKindView::Trail => "Trail",
6091 TerrainKindView::Road => "Road",
6092 TerrainKindView::Rock => "Rock",
6093 }
6094}
6095
6096fn zone_rects_contain(rects: &[flatland_protocol::ZoneRectView], x: f32, y: f32) -> bool {
6097 crate::world_zones::zone_rects_contain(rects, x, y)
6098}
6099
6100fn zone_view_area_m2(zone: &flatland_protocol::PropertyZoneView) -> f32 {
6101 zone.rects
6102 .iter()
6103 .map(|r| (r.x1 - r.x0).max(0.0) * (r.y1 - r.y0).max(0.0))
6104 .sum()
6105}
6106
6107fn claim_rect_fully_inside_zone(
6108 zone: &flatland_protocol::PropertyZoneView,
6109 x0: f32,
6110 y0: f32,
6111 x1: f32,
6112 y1: f32,
6113) -> bool {
6114 let mut y = y0 + 0.5;
6115 while y < y1 {
6116 let mut x = x0 + 0.5;
6117 while x < x1 {
6118 if !zone_rects_contain(&zone.rects, x, y) {
6119 return false;
6120 }
6121 x += 1.0;
6122 }
6123 y += 1.0;
6124 }
6125 true
6126}
6127
6128fn rects_overlap_half_open(
6129 ax0: f32,
6130 ay0: f32,
6131 ax1: f32,
6132 ay1: f32,
6133 bx0: f32,
6134 by0: f32,
6135 bx1: f32,
6136 by1: f32,
6137) -> bool {
6138 ax0 < bx1 && ax1 > bx0 && ay0 < by1 && ay1 > by0
6139}
6140
6141fn point_in_plot(x: f32, y: f32, p: &flatland_protocol::PropertyPlotView) -> bool {
6142 x >= p.x0 && x < p.x1 && y >= p.y0 && y < p.y1
6143}
6144
6145fn plot_route_label(p: &flatland_protocol::PropertyPlotView) -> String {
6146 p.zone_label
6147 .as_deref()
6148 .filter(|s| !s.trim().is_empty())
6149 .map(|s| s.to_string())
6150 .unwrap_or_else(|| format!("plot {}", &p.plot_id.to_string()[..8]))
6151}
6152
6153fn snap_claim_rect_client(x0: f32, y0: f32, x1: f32, y1: f32) -> (f32, f32, f32, f32) {
6155 let a = x0.min(x1).floor();
6156 let b = y0.min(y1).floor();
6157 let mut c = x0.max(x1).ceil();
6158 let mut d = y0.max(y1).ceil();
6159 if (c - a) < 1.0 {
6160 c = a + 1.0;
6161 }
6162 if (d - b) < 1.0 {
6163 d = b + 1.0;
6164 }
6165 (a, b, c, d)
6166}
6167
6168fn humanize_template_id(template_id: &str) -> String {
6169 if looks_like_template_uuid(template_id) {
6171 return "Unknown item".into();
6172 }
6173 template_id
6174 .split('_')
6175 .map(|word| {
6176 let mut chars = word.chars();
6177 match chars.next() {
6178 None => String::new(),
6179 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
6180 }
6181 })
6182 .collect::<Vec<_>>()
6183 .join(" ")
6184}
6185
6186fn looks_like_template_uuid(template_id: &str) -> bool {
6187 let bytes = template_id.as_bytes();
6188 if bytes.len() != 36 {
6189 return false;
6190 }
6191 let is_hex = |b: u8| b.is_ascii_hexdigit();
6192 let groups = [8usize, 4, 4, 4, 12];
6193 let mut i = 0;
6194 for (gi, &len) in groups.iter().enumerate() {
6195 if gi > 0 {
6196 if bytes.get(i) != Some(&b'-') {
6197 return false;
6198 }
6199 i += 1;
6200 }
6201 for _ in 0..len {
6202 if !bytes.get(i).copied().is_some_and(is_hex) {
6203 return false;
6204 }
6205 i += 1;
6206 }
6207 }
6208 true
6209}
6210
6211const HARVEST_RANGE_M: f32 = 1.5;
6213
6214pub struct GameClient<S: PlayConnection> {
6215 session: S,
6216 seq: Seq,
6217 pub state: GameState,
6218 last_move_forward: f32,
6219 last_move_strafe: f32,
6220}
6221
6222impl<S: PlayConnection> GameClient<S> {
6223 pub fn new(session: S) -> Self {
6224 let session_id = session.session_id();
6225 let entity_id = session.entity_id();
6226 let mut client = Self {
6227 session,
6228 seq: 0,
6229 last_move_forward: 0.0,
6230 last_move_strafe: 0.0,
6231 state: GameState {
6232 session_id,
6233 entity_id,
6234 character_id: None,
6235 tick: 0,
6236 chunk_rev: 0,
6237 content_rev: 0,
6238 publish_rev: 0,
6239 entities: Vec::new(),
6240 player: None,
6241 resource_nodes: Vec::new(),
6242 ground_drops: Vec::new(),
6243 placed_containers: Vec::new(),
6244 buildings: Vec::new(),
6245 doors: Vec::new(),
6246 interior_map: None,
6247 npcs: Vec::new(),
6248 blueprints: Vec::new(),
6249 building_materials: Vec::new(),
6250 world_x0: 0.0,
6251 world_y0: 0.0,
6252 world_width_m: 0.0,
6253 world_height_m: 0.0,
6254 terrain_zones: Vec::new(),
6255 z_platforms: Vec::new(),
6256 z_transitions: Vec::new(),
6257 z_bands_outdoor_backup: None,
6258 world_clock: flatland_protocol::WorldClock::default(),
6259 inventory: std::collections::HashMap::new(),
6260 inventory_hints: std::collections::HashMap::new(),
6261 logs: VecDeque::new(),
6262 intents_sent: 0,
6263 ticks_received: 0,
6264 connected: false,
6265 disconnect_reason: None,
6266 show_stats: false,
6267 hud_log_hidden: false,
6268 show_equip_menu: false,
6269 equip_menu_index: 0,
6270 show_craft_menu: false,
6271 show_plot_build_menu: false,
6272 plot_build_focus_wall: true,
6273 plot_build_wall_index: 0,
6274 plot_build_roof_index: 0,
6275 craft_menu_index: 0,
6276 craft_batch_quantity: 1,
6277 show_shop_menu: false,
6278 shop_catalog: None,
6279 bank_panel: None,
6280 bank_menu_index: 0,
6281 bank_ui_mode: BankUiMode::Menu,
6282 storage_panel: None,
6283 market_panel: None,
6284 market_menu_index: 0,
6285 market_filter: String::new(),
6286 market_filter_focused: false,
6287 market_category_filter: None,
6288 market_buy_confirm: None,
6289 market_ui_mode: MarketUiMode::Browse,
6290 storage_menu_index: 0,
6291 storage_ui_mode: StorageUiMode::Menu,
6292 shop_tab: ShopTab::default(),
6293 shop_menu_index: 0,
6294 shop_quantity: 1,
6295 shop_trade_log: VecDeque::new(),
6296 show_npc_verb_menu: false,
6297 npc_verb_target: None,
6298 npc_verb_index: 0,
6299 player_verbs: crate::social::PlayerVerbState::default(),
6300 social_chat: crate::social::SocialChatState::default(),
6301 trade_ui: crate::social::TradeUiState::default(),
6302 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
6303 show_npc_chat: false,
6304 npc_chat: None,
6305 show_inventory_menu: false,
6306 inventory_menu_index: 0,
6307 inventory_tab: InventoryTab::OnPerson,
6308 inventory_filter: String::new(),
6309 inventory_filter_focused: false,
6310 show_move_picker: false,
6311 show_rename_prompt: false,
6312 show_worker_rename: false,
6313 rename_buffer: String::new(),
6314 move_picker_index: 0,
6315 move_picker: None,
6316 show_grant_picker: false,
6317 grant_picker_index: 0,
6318 grant_picker: None,
6319 show_destroy_picker: false,
6320 destroy_confirm_pending: false,
6321 destroy_picker: None,
6322 combat_target: None,
6323 combat_target_label: None,
6324 ground_target: None,
6325 combat_fx: Vec::new(),
6326 property_zones: Vec::new(),
6327 tax_zones: Vec::new(),
6328 growth_zones: Vec::new(),
6329 biome_zones: Vec::new(),
6330 terrain_kind_nav: Vec::new(),
6331 property_plots: Vec::new(),
6332 property_plot_settings: None,
6333 claim_mode: None,
6334 relocate_mode: None,
6335 sell_plot_confirm: None,
6336 sell_plot_armed_at: None,
6337 show_plant_menu: false,
6338 plant_menu_index: 0,
6339 show_farm_access: false,
6340 farm_access_name_draft: String::new(),
6341 farm_access_discount_bps: 0,
6342 farm_access_index: 0,
6343 plant_quantity: 1,
6344 in_combat: false,
6345 auto_attack: true,
6346 combat_has_los: false,
6347 attack_cd_ticks: 0,
6348 gcd_ticks: 0,
6349 weapon_ability_id: "unarmed".into(),
6350 mainhand_template_id: None,
6351 mainhand_label: None,
6352 mainhand_instance_id: None,
6353 offhand_template_id: None,
6354 offhand_label: None,
6355 offhand_instance_id: None,
6356 mainhand_hand_slots: 1,
6357 defense: None,
6358 worn: BTreeMap::new(),
6359 carry_mass: 0.0,
6360 carry_mass_max: 0.0,
6361 encumbrance: flatland_protocol::EncumbranceState::Light,
6362 inventory_stacks: Vec::new(),
6363 keychain_stacks: Vec::new(),
6364 whisper_pouch_stacks: Vec::new(),
6365 combat_target_detail: None,
6366 statuses: Vec::new(),
6367 cast_progress: None,
6368 timed_channel: None,
6369 plot_build_offer: None,
6370 ability_cooldowns: Vec::new(),
6371 blocking_active: false,
6372 max_target_slots: 1,
6373 combat_slots: Vec::new(),
6374 rotation_presets: Vec::new(),
6375 known_abilities: Vec::new(),
6376 ability_meta: std::collections::HashMap::new(),
6377 ability_mastery: std::collections::HashMap::new(),
6378 hotbar: vec![None; 9],
6379 max_abilities_per_rotation: 0,
6380 show_loadout_menu: false,
6381 show_keychain_menu: false,
6382 keychain_menu_index: 0,
6383 show_rotation_editor: false,
6384 loadout_menu_index: 0,
6385 loadout_hotbar_slot: 1,
6386 loadout_ability_index: 0,
6387 loadout_focus_presets: false,
6388 rotation_editor: RotationEditorState::default(),
6389 harvest_in_progress: false,
6390 harvest_started_at: None,
6391 pending_craft_ack: None,
6392 pending_worker_job_ack: None,
6393 attending_worker_instance_id: None,
6394 quest_log: Vec::new(),
6395 interactables: Vec::new(),
6396 ledger: None,
6397 career: None,
6398 character_sheet_tab: CharacterSheetTab::Character,
6399 ledger_period: LedgerPeriod::Day,
6400 show_quest_offer: false,
6401 pending_quest_offer: None,
6402 show_quest_menu: false,
6403 quest_menu_index: 0,
6404 quest_withdraw_confirm: false,
6405 hired_workers: Vec::new(),
6406 show_workers_menu: false,
6407 workers_menu_index: 0,
6408 workers_menu_compact: false,
6409 worker_step_display: BTreeMap::new(),
6410 worker_error_display: BTreeMap::new(),
6411 show_worker_give_picker: false,
6412 worker_give_picker_index: 0,
6413 worker_give_picker: None,
6414 show_worker_give_target_picker: false,
6415 worker_give_target_picker_index: 0,
6416 worker_give_target_picker: None,
6417 show_worker_take_picker: false,
6418 worker_take_picker_index: 0,
6419 worker_take_picker: None,
6420 show_worker_teach_picker: false,
6421 worker_teach_picker_index: 0,
6422 worker_teach_picker: None,
6423 worker_route_editor: None,
6424 progression_curve: None,
6425 },
6426 };
6427 client.state.apply_client_ui_prefs();
6428 client
6429 }
6430
6431 pub fn entity_id(&self) -> EntityId {
6432 self.state.entity_id
6433 }
6434
6435 pub async fn wait_until_ready(&mut self) -> anyhow::Result<()> {
6436 if self.state.connected {
6437 return Ok(());
6438 }
6439
6440 loop {
6441 match self.session.next_event().await {
6442 Some(SessionEvent::Welcome {
6443 session_id,
6444 entity_id,
6445 snapshot,
6446 }) => {
6447 self.state
6448 .restore_from_welcome(session_id, entity_id, &snapshot);
6449 self.state.apply_client_ui_prefs();
6450 self.state.push_log(format!(
6451 "Connected — session {session_id}, entity {entity_id}"
6452 ));
6453 return Ok(());
6454 }
6455 Some(SessionEvent::Disconnected { .. }) => {
6456 anyhow::bail!("disconnected before welcome");
6457 }
6458 Some(_) => continue,
6459 None => anyhow::bail!("session closed before welcome"),
6460 }
6461 }
6462 }
6463
6464 pub fn drain_events(&mut self) {
6466 while let Some(event) = self.session.try_next_event() {
6467 if self.handle_event_sync(event).is_err() {
6468 break;
6469 }
6470 }
6471 }
6472
6473 pub async fn next_event(&mut self) -> Option<SessionEvent> {
6475 self.session.next_event().await
6476 }
6477
6478 pub async fn handle_event(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6479 self.handle_event_sync(event)
6480 }
6481
6482 fn handle_event_sync(&mut self, event: SessionEvent) -> anyhow::Result<()> {
6483 match event {
6484 SessionEvent::Welcome {
6485 session_id,
6486 entity_id,
6487 snapshot,
6488 } => {
6489 let resumed = self.state.connected;
6490 self.state
6491 .restore_from_welcome(session_id, entity_id, &snapshot);
6492 if resumed {
6493 self.state.push_log(format!(
6494 "Session restored — session {session_id}, entity {entity_id}"
6495 ));
6496 }
6497 }
6498 SessionEvent::ContentUpdated { snapshot } => {
6499 self.state
6500 .apply_snapshot_fields(&snapshot, self.state.entity_id);
6501 self.state.push_log(format!(
6502 "World updated (content rev {})",
6503 snapshot.content_rev
6504 ));
6505 }
6506 SessionEvent::Tick(delta) => {
6507 self.state.apply_tick_fields(&delta, self.state.entity_id);
6508 self.state.ticks_received += 1;
6509 }
6510 SessionEvent::IntentAck {
6511 entity_id,
6512 seq,
6513 tick,
6514 } => {
6515 crate::harvest_trace!(entity_id, seq, tick, "client received intent ack");
6516 if let Some((craft_seq, _, _)) = &self.state.pending_craft_ack {
6517 if *craft_seq == seq {
6518 let (_, label, batches) = self.state.pending_craft_ack.take().unwrap();
6519 if batches > 1 {
6520 self.state.push_log(format!("Crafting {label} ×{batches}…"));
6521 } else {
6522 self.state.push_log(format!("Crafting {label}…"));
6523 }
6524 }
6525 }
6526 if self
6527 .state
6528 .pending_worker_job_ack
6529 .as_ref()
6530 .is_some_and(|p| p.seq == seq)
6531 {
6532 let pending = self.state.pending_worker_job_ack.take().unwrap();
6533 if pending.idle {
6534 self.state.push_log(format!(
6535 "Route cleared for {} — worker idle",
6536 pending.worker_label
6537 ));
6538 } else {
6539 self.state.push_log(format!(
6540 "Route saved for {} — {} stop(s), job loop active",
6541 pending.worker_label, pending.stop_count
6542 ));
6543 }
6544 if self
6545 .state
6546 .worker_route_editor
6547 .as_ref()
6548 .is_some_and(|ed| ed.worker_instance_id == pending.worker_instance_id)
6549 {
6550 self.close_worker_route_editor();
6551 }
6552 }
6553 }
6554 SessionEvent::Chat(msg) => {
6555 let label = match msg.channel {
6556 flatland_protocol::ChatChannel::Nearby => "nearby",
6557 flatland_protocol::ChatChannel::Direct => "speak",
6558 flatland_protocol::ChatChannel::Whisper => "whisper",
6559 flatland_protocol::ChatChannel::WhisperStone => "stone",
6560 };
6561 let clarity = match msg.clarity {
6562 flatland_protocol::ChatClarity::Clear => "",
6563 flatland_protocol::ChatClarity::Partial => "~",
6564 flatland_protocol::ChatClarity::Heavy => "…",
6565 };
6566 self.state.push_log(format!(
6567 "[{label}{clarity}] {}: {}",
6568 msg.from_name, msg.text
6569 ));
6570 let now_ms = std::time::SystemTime::now()
6571 .duration_since(std::time::UNIX_EPOCH)
6572 .map(|d| d.as_millis() as u64)
6573 .unwrap_or(0);
6574 self.state
6575 .social_chat
6576 .note_speech(&msg, self.state.entity_id, now_ms);
6577 self.state
6578 .social_chat
6579 .push(crate::social::ChatLogEntry::from_message(
6580 msg,
6581 self.state.entity_id,
6582 ));
6583 }
6584 SessionEvent::TradeOpened(panel) => {
6585 self.state.social_chat.pending_trade = None;
6586 let peer = panel.peer_name.clone();
6587 self.state.trade_ui.open(panel);
6588 self.state
6589 .social_chat
6590 .push_system(format!("Trade open with {peer} — p present · r ready · Esc cancel"));
6591 self.state
6592 .social_chat
6593 .push_cue(crate::social::AudioCue::TradeOpened);
6594 }
6595 SessionEvent::TradeClosed { reason } => {
6596 self.state.push_log(reason.clone());
6597 self.state.social_chat.push_system(reason);
6598 self.state.trade_ui.close();
6599 }
6600 SessionEvent::HarvestResult(result) => {
6601 self.state.clear_harvest_state();
6602 crate::harvest_trace!(
6603 entity_id = self.state.entity_id,
6604 node_id = %result.node_id,
6605 template = %result.item_template,
6606 quantity = result.quantity,
6607 client_tick = self.state.tick,
6608 "client applied harvest result"
6609 );
6610 let msg = if result.quantity == 0 {
6611 format!(
6612 "Harvested {} x0 — nothing dropped (loot table rolled empty)",
6613 result.item_template
6614 )
6615 } else {
6616 format!(
6617 "Harvested {} x{} (on the ground — press P to pick up)",
6618 result.item_template, result.quantity
6619 )
6620 };
6621 self.state.push_log(msg);
6622 }
6623 SessionEvent::CraftResult(result) => {
6624 for stack in &result.consumed {
6625 if let Some(qty) = self.state.inventory.get_mut(&stack.template_id) {
6626 *qty = qty.saturating_sub(stack.quantity);
6627 if *qty == 0 {
6628 self.state.inventory.remove(&stack.template_id);
6629 }
6630 }
6631 }
6632 for stack in &result.outputs {
6633 *self
6634 .state
6635 .inventory
6636 .entry(stack.template_id.clone())
6637 .or_insert(0) += stack.quantity;
6638 }
6639 if let Some(output) = result.outputs.first() {
6640 if result.batch_total > 1 {
6641 self.state.push_log(format!(
6642 "Crafted {} x{} ({}/{})",
6643 output.template_id,
6644 output.quantity,
6645 result.batch_index,
6646 result.batch_total
6647 ));
6648 } else {
6649 self.state.push_log(format!(
6650 "Crafted {} x{}",
6651 output.template_id, output.quantity
6652 ));
6653 }
6654 } else {
6655 self.state
6656 .push_log(format!("Craft finished: {}", result.blueprint_id));
6657 }
6658 }
6659 SessionEvent::Death(notice) => {
6660 self.state.clear_harvest_state();
6661 self.state.push_log(notice.message.clone());
6662 self.state.push_log(format!(
6663 "Respawned at ({:.1}, {:.1})",
6664 notice.respawn_x, notice.respawn_y
6665 ));
6666 }
6667 SessionEvent::Interaction(notice) => {
6668 if notice.message.starts_with("Harvest failed:") {
6669 self.state.clear_harvest_state();
6670 }
6671 if notice.message.starts_with("Can't do that:") {
6672 self.state.pending_craft_ack = None;
6673 if let Some(pending) = self.state.pending_worker_job_ack.take() {
6674 if let Some(w) = self
6675 .state
6676 .hired_workers
6677 .iter_mut()
6678 .find(|w| w.instance_id == pending.worker_instance_id)
6679 {
6680 w.route = pending.prev_route;
6681 w.mode = pending.prev_mode;
6682 w.step_label = pending.prev_step_label;
6683 w.last_error = pending.prev_last_error;
6684 }
6685 let reason = notice
6686 .message
6687 .strip_prefix("Can't do that:")
6688 .unwrap_or(¬ice.message)
6689 .trim();
6690 self.state.push_log(format!(
6691 "Route save failed for {}: {reason}",
6692 pending.worker_label
6693 ));
6694 }
6695 let reason = notice
6696 .message
6697 .strip_prefix("Can't do that:")
6698 .unwrap_or(¬ice.message)
6699 .trim();
6700 if reason.contains("already tilled") {
6701 if let Some(plot) = self.state.my_plot_under_player() {
6702 self.state.sell_plot_confirm = Some(plot.plot_id);
6703 self.state.sell_plot_armed_at = Some(Instant::now());
6704 }
6705 }
6706 }
6707 if notice.message.starts_with("Cast failed:") {
6708 self.state.cast_progress = None;
6709 }
6710 if notice.message.contains("slain the") {
6711 self.state.combat_target = None;
6712 self.state.combat_target_label = None;
6713 }
6714 if notice.message.contains("wants to trade") {
6716 if let Ok(from_entity) = notice.target_id.parse::<EntityId>() {
6717 let from_name = notice
6718 .message
6719 .split(" wants to trade")
6720 .next()
6721 .unwrap_or("Player")
6722 .to_string();
6723 self.state.social_chat.pending_trade =
6724 Some(crate::social::PendingTradeRequest {
6725 from_entity,
6726 from_name: from_name.clone(),
6727 });
6728 self.state.social_chat.push_system(format!(
6729 "{from_name} wants to trade — [Y] accept · [N] decline"
6730 ));
6731 self.state
6732 .social_chat
6733 .push_cue(crate::social::AudioCue::TradeOffer);
6734 }
6735 }
6736 if notice.message.starts_with("trade request declined") {
6737 self.state
6738 .social_chat
6739 .push_system(notice.message.clone());
6740 self.state
6741 .social_chat
6742 .push_cue(crate::social::AudioCue::TradeDeclined);
6743 }
6744 self.state.apply_interaction_notice(¬ice);
6745 self.state.push_log(notice.message.clone());
6746 }
6747 SessionEvent::ShopOpened(catalog) => {
6748 self.state.apply_shop_catalog(catalog);
6749 }
6750 SessionEvent::BankOpened(panel) => {
6751 self.state.apply_bank_panel(panel);
6752 }
6753 SessionEvent::StorageOpened(panel) => {
6754 self.state.apply_storage_panel(panel);
6755 }
6756 SessionEvent::MarketOpened(panel) => {
6757 self.state.apply_market_panel(panel);
6758 }
6759 SessionEvent::NpcTalkOpened(opened) => {
6760 self.state.show_npc_verb_menu = false;
6761 if self.state.npc_verb_target.is_none() {
6762 self.state.npc_verb_target = Some(opened.npc_id.clone());
6763 }
6764 let label = opened.npc_label.clone();
6765 let banner = if !opened.trade_allowed {
6766 Some("Trade is unavailable right now.".to_string())
6767 } else {
6768 None
6769 };
6770 self.state.show_npc_chat = true;
6771 self.state.npc_chat = Some(NpcChatState {
6772 npc_id: opened.npc_id,
6773 npc_label: opened.npc_label,
6774 lines: if opened.greeting.is_empty() {
6775 vec![]
6776 } else {
6777 vec![format!("{label}: {}", opened.greeting)]
6778 },
6779 input: String::new(),
6780 pending: opened.greeting.is_empty(),
6781 talk_depth: opened.talk_depth,
6782 trade_allowed: opened.trade_allowed,
6783 banner,
6784 });
6785 }
6786 SessionEvent::NpcTalkPending(_) => {
6787 if let Some(chat) = self.state.npc_chat.as_mut() {
6788 chat.pending = true;
6789 }
6790 }
6791 SessionEvent::NpcTalkReply(reply) => {
6792 if let Some(chat) = self.state.npc_chat.as_mut() {
6793 if chat.npc_id == reply.npc_id {
6794 chat.pending = false;
6795 if reply.trade_disabled {
6796 chat.trade_allowed = false;
6797 chat.banner = Some("Trade is unavailable right now.".to_string());
6798 }
6799 if reply.wind_down {
6800 chat.talk_depth = flatland_protocol::NpcTalkDepth::Brief;
6801 if chat.banner.is_none() {
6802 chat.banner =
6803 Some("They're wrapping up — keep it brief.".to_string());
6804 }
6805 }
6806 chat.lines
6807 .push(format!("{}: {}", chat.npc_label, reply.line));
6808 }
6809 }
6810 }
6811 SessionEvent::NpcTalkClosed(closed) => {
6812 if self
6813 .state
6814 .npc_chat
6815 .as_ref()
6816 .is_some_and(|c| c.npc_id == closed.npc_id)
6817 {
6818 self.state.show_npc_chat = false;
6819 self.state.npc_chat = None;
6820 }
6821 }
6822 SessionEvent::NpcTalkError(err) => {
6823 self.state.push_log(format!("Talk failed: {}", err.reason));
6824 if let Some(chat) = self.state.npc_chat.as_mut() {
6825 chat.pending = false;
6826 }
6827 }
6828 SessionEvent::UseResult(result) => {
6829 if let Some(qty) = self.state.inventory.get_mut(&result.template_id) {
6832 *qty = qty.saturating_sub(1);
6833 if *qty == 0 {
6834 self.state.inventory.remove(&result.template_id);
6835 }
6836 }
6837 }
6838 SessionEvent::QuestOffer(offer) => {
6839 self.state.pending_quest_offer = Some(offer.clone());
6840 self.state.show_quest_offer = true;
6841 self.state
6842 .push_log(format!("Quest offered: {}", offer.title));
6843 }
6844 SessionEvent::QuestAccepted(notice) => {
6845 self.state.show_quest_offer = false;
6846 self.state.pending_quest_offer = None;
6847 self.state.push_log(notice.message);
6848 }
6849 SessionEvent::QuestWithdrawn(notice) => {
6850 self.state.show_quest_menu = false;
6851 self.state.quest_withdraw_confirm = false;
6852 self.state.push_log(notice.message);
6853 }
6854 SessionEvent::QuestStepCompleted(notice) => {
6855 self.state.push_log(notice.message);
6856 }
6857 SessionEvent::QuestCompleted(notice) => {
6858 self.state.push_log(notice.message);
6859 }
6860 SessionEvent::Disconnected { reason } => {
6861 self.state.clear_harvest_state();
6862 self.state.connected = false;
6863 self.state.disconnect_reason = reason.clone().filter(|s| !s.is_empty());
6864 if let Some(r) = &self.state.disconnect_reason {
6865 self.state.push_log(format!("Disconnected: {r}"));
6866 } else {
6867 self.state.push_log("Disconnected from server");
6868 }
6869 }
6870 }
6871 Ok(())
6872 }
6873
6874 pub fn is_connected(&self) -> bool {
6875 self.state.connected
6876 }
6877
6878 pub fn close_overlays(&mut self) {
6879 self.state.show_stats = false;
6880 self.state.show_craft_menu = false;
6881 self.state.show_plot_build_menu = false;
6882 self.state.show_shop_menu = false;
6883 self.state.shop_catalog = None;
6884 self.state.show_npc_verb_menu = false;
6885 self.state.npc_verb_target = None;
6886 self.state.show_npc_chat = false;
6887 self.state.npc_chat = None;
6888 self.state.show_inventory_menu = false;
6889 self.state.show_loadout_menu = false;
6890 self.state.show_rotation_editor = false;
6891 self.state.rotation_editor.reset();
6892 self.state.show_rename_prompt = false;
6893 self.state.show_worker_rename = false;
6894 self.state.rename_buffer.clear();
6895 self.state.show_move_picker = false;
6896 self.state.move_picker = None;
6897 self.state.show_destroy_picker = false;
6898 self.state.destroy_confirm_pending = false;
6899 self.state.destroy_picker = None;
6900 self.state.show_quest_offer = false;
6901 self.state.pending_quest_offer = None;
6902 self.state.show_quest_menu = false;
6903 self.state.quest_withdraw_confirm = false;
6904 self.state.show_workers_menu = false;
6905 self.close_worker_give_picker();
6906 self.close_worker_give_target_picker();
6907 self.close_worker_take_picker();
6908 self.close_worker_teach_picker();
6909 self.state.worker_route_editor = None;
6910 self.state.claim_mode = None;
6911 self.state.relocate_mode = None;
6912 self.state.sell_plot_confirm = None;
6913 self.state.sell_plot_armed_at = None;
6914 self.close_farm_access_panel();
6915 if self.state.show_plant_menu {
6916 self.close_plant_menu();
6917 }
6918 }
6919
6920 pub fn back_on_esc(&mut self) -> bool {
6922 if self.state.social_chat.composer_open() {
6923 self.state.social_chat.close_composer();
6924 return true;
6925 }
6926 if self.state.player_verbs.open {
6927 self.state.player_verbs.close();
6928 return true;
6929 }
6930 if self.state.whisper_pouch_ui.open {
6931 self.state.whisper_pouch_ui.open = false;
6932 return true;
6933 }
6934 if self.state.trade_ui.panel.is_some() {
6935 self.state.trade_ui.close();
6937 return true;
6938 }
6939 if self.state.show_rename_prompt {
6940 self.cancel_rename_prompt();
6941 return true;
6942 }
6943 if self.state.show_worker_rename {
6944 self.cancel_worker_rename();
6945 return true;
6946 }
6947 if self.state.show_destroy_picker {
6948 if self.state.destroy_confirm_pending {
6949 self.cancel_destroy_confirm();
6950 } else {
6951 self.close_destroy_picker();
6952 }
6953 return true;
6954 }
6955 if self.state.claim_mode.is_some() {
6956 self.cancel_claim_mode();
6957 return true;
6958 }
6959 if self.state.relocate_mode.is_some() {
6960 self.cancel_relocate_mode();
6961 return true;
6962 }
6963 if self.state.show_plant_menu {
6964 self.close_plant_menu();
6965 return true;
6966 }
6967 if self.state.show_farm_access {
6968 self.close_farm_access_panel();
6969 return true;
6970 }
6971 if self.state.sell_plot_confirm.is_some() {
6972 self.state.sell_plot_confirm = None;
6973 self.state.sell_plot_armed_at = None;
6974 self.state.push_log("Sell cancelled");
6975 return true;
6976 }
6977 if self.state.show_move_picker {
6978 self.close_move_picker();
6979 return true;
6980 }
6981 if self.state.show_rotation_editor {
6982 match self.state.rotation_editor.mode {
6983 RotationEditorMode::List => {
6984 self.state.show_rotation_editor = false;
6985 self.state.rotation_editor.reset();
6986 }
6987 RotationEditorMode::EditLabel => {
6988 self.state.rotation_editor.label_buffer.clear();
6989 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
6990 }
6991 RotationEditorMode::PickAbility => {
6992 self.state.rotation_editor.mode = RotationEditorMode::EditSequence;
6993 }
6994 RotationEditorMode::EditSequence => {
6995 self.state.rotation_editor.draft = None;
6996 self.state.rotation_editor.mode = RotationEditorMode::List;
6997 }
6998 }
6999 return true;
7000 }
7001 if self.state.show_inventory_menu {
7002 self.close_inventory_menu();
7003 return true;
7004 }
7005 if self.state.show_craft_menu {
7006 self.close_craft_menu();
7007 return true;
7008 }
7009 if self.state.show_plot_build_menu {
7010 self.close_plot_build_menu();
7011 return true;
7012 }
7013 if self.state.show_keychain_menu {
7014 self.close_keychain_menu();
7015 return true;
7016 }
7017 if self.state.show_quest_offer {
7018 self.quest_offer_decline();
7019 return true;
7020 }
7021 if self.state.show_shop_menu {
7022 return false;
7024 }
7025 if self.state.bank_panel.is_some() {
7026 return false;
7027 }
7028 if self.state.storage_panel.is_some() {
7029 return false;
7030 }
7031 if self.state.market_panel.is_some() {
7032 return false;
7033 }
7034 if self.state.show_npc_chat {
7035 return false;
7037 }
7038 if self.state.show_npc_verb_menu {
7039 self.state.show_npc_verb_menu = false;
7040 self.state.npc_verb_target = None;
7041 return true;
7042 }
7043 if self.state.show_quest_menu {
7044 if self.state.quest_withdraw_confirm {
7045 self.state.quest_withdraw_confirm = false;
7046 } else {
7047 self.state.show_quest_menu = false;
7048 }
7049 return true;
7050 }
7051 if self.state.worker_route_editor.is_some() {
7052 if self.re_at_root_sheet() {
7054 let reopen = self.state.attending_worker_instance_id.clone();
7055 self.close_worker_route_editor();
7056 if let Some(id) = reopen {
7057 if let Some(idx) = self
7058 .state
7059 .hired_workers
7060 .iter()
7061 .position(|w| w.instance_id == id)
7062 {
7063 self.state.workers_menu_index = idx;
7064 self.state.show_workers_menu = true;
7065 }
7066 }
7067 } else {
7068 self.re_sheet_back();
7069 }
7070 return true;
7071 }
7072 if self.state.show_worker_give_picker {
7073 self.close_worker_give_picker();
7074 return true;
7075 }
7076 if self.state.show_worker_give_target_picker {
7077 self.close_worker_give_target_picker();
7078 return true;
7079 }
7080 if self.state.show_worker_take_picker {
7081 self.close_worker_take_picker();
7082 return true;
7083 }
7084 if self.state.show_worker_teach_picker {
7085 self.close_worker_teach_picker();
7086 return true;
7087 }
7088 if self.state.show_workers_menu {
7089 self.close_workers_menu_ui();
7090 return true;
7091 }
7092 if self.state.show_loadout_menu {
7093 self.state.show_loadout_menu = false;
7094 return true;
7095 }
7096 if self.state.show_stats {
7097 self.state.show_stats = false;
7098 return true;
7099 }
7100 if self.state.show_equip_menu {
7101 self.state.show_equip_menu = false;
7102 return true;
7103 }
7104 false
7105 }
7106
7107 pub fn toggle_stats(&mut self) {
7108 self.state.show_stats = !self.state.show_stats;
7109 if self.state.show_stats {
7110 self.state.character_sheet_tab = CharacterSheetTab::Character;
7111 self.state.show_craft_menu = false;
7112 self.state.show_shop_menu = false;
7113 self.state.shop_catalog = None;
7114 self.state.show_inventory_menu = false;
7115 self.state.show_equip_menu = false;
7116 }
7117 }
7118
7119 pub fn toggle_equip_menu(&mut self) {
7120 self.state.show_equip_menu = !self.state.show_equip_menu;
7121 if self.state.show_equip_menu {
7122 self.state.show_stats = false;
7123 self.state.show_craft_menu = false;
7124 self.state.show_shop_menu = false;
7125 self.state.shop_catalog = None;
7126 self.state.show_inventory_menu = false;
7127 self.state.show_loadout_menu = false;
7128 }
7129 }
7130
7131 pub fn cycle_character_sheet_tab(&mut self) {
7132 if self.state.show_stats {
7133 self.state.character_sheet_tab = self.state.character_sheet_tab.cycle();
7134 }
7135 }
7136
7137 pub fn set_ledger_period_digit(&mut self, c: char) {
7138 if self.state.show_stats {
7139 if let Some(p) = LedgerPeriod::from_digit(c) {
7140 self.state.ledger_period = p;
7141 self.state.character_sheet_tab = CharacterSheetTab::Ledger;
7142 }
7143 }
7144 }
7145
7146 pub fn cycle_ledger_period(&mut self) {
7147 if self.state.show_stats
7148 && self.state.character_sheet_tab == CharacterSheetTab::Ledger
7149 {
7150 self.state.ledger_period = self.state.ledger_period.cycle();
7151 }
7152 }
7153
7154 pub fn open_inventory_menu(&mut self) {
7155 self.state.show_inventory_menu = true;
7156 self.state.show_craft_menu = false;
7157 self.state.show_shop_menu = false;
7158 self.state.shop_catalog = None;
7159 self.state.show_stats = false;
7160 self.state.show_move_picker = false;
7161 self.state.move_picker = None;
7162 self.state.show_destroy_picker = false;
7163 self.state.destroy_confirm_pending = false;
7164 self.state.destroy_picker = None;
7165 self.state.show_rename_prompt = false;
7166 self.state.rename_buffer.clear();
7167 self.state.inventory_filter_focused = false;
7168 self.state.clamp_inventory_indices();
7169 }
7170
7171 pub fn close_inventory_menu(&mut self) {
7172 self.state.show_inventory_menu = false;
7173 self.state.show_move_picker = false;
7174 self.state.move_picker = None;
7175 self.close_grant_picker();
7176 self.state.show_destroy_picker = false;
7177 self.state.destroy_confirm_pending = false;
7178 self.state.destroy_picker = None;
7179 self.state.show_rename_prompt = false;
7180 self.state.rename_buffer.clear();
7181 self.state.inventory_filter_focused = false;
7182 }
7183
7184 pub fn open_rename_prompt(&mut self) -> anyhow::Result<()> {
7185 let Some(row) = self.state.inventory_selected_row() else {
7186 anyhow::bail!("inventory empty");
7187 };
7188 if !self.state.row_is_renameable_container(&row) {
7189 anyhow::bail!("only storage containers can be renamed");
7190 }
7191 let current = row
7192 .stack
7193 .display_name
7194 .clone()
7195 .unwrap_or_else(|| row.stack.template_id.clone());
7196 self.state.rename_buffer = current;
7197 self.state.show_rename_prompt = true;
7198 self.state.show_worker_rename = false;
7199 self.state.show_move_picker = false;
7200 self.state.show_destroy_picker = false;
7201 self.state.destroy_confirm_pending = false;
7202 Ok(())
7203 }
7204
7205 pub fn cancel_rename_prompt(&mut self) {
7206 self.state.show_rename_prompt = false;
7207 self.state.rename_buffer.clear();
7208 }
7209
7210 pub async fn confirm_rename_prompt(&mut self) -> anyhow::Result<()> {
7211 let name = self.state.rename_buffer.trim().to_string();
7212 if name.is_empty() {
7213 anyhow::bail!("name cannot be empty");
7214 }
7215 let Some(row) = self.state.inventory_selected_row() else {
7216 anyhow::bail!("inventory empty");
7217 };
7218 let Some(instance_id) = row.stack.item_instance_id else {
7219 anyhow::bail!("item has no instance id");
7220 };
7221 self.seq += 1;
7222 self.session
7223 .submit_intent(Intent::RenameContainer {
7224 entity_id: self.state.entity_id,
7225 item_instance_id: instance_id,
7226 location: row.from.clone(),
7227 name,
7228 seq: self.seq,
7229 })
7230 .await?;
7231 self.state.intents_sent += 1;
7232 self.state.show_rename_prompt = false;
7233 self.state.rename_buffer.clear();
7234 Ok(())
7235 }
7236
7237 pub fn open_worker_rename(&mut self) -> anyhow::Result<()> {
7238 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
7239 anyhow::bail!("no worker selected");
7240 };
7241 self.state.rename_buffer = worker.label.clone();
7242 self.state.show_worker_rename = true;
7243 self.state.show_rename_prompt = false;
7244 Ok(())
7245 }
7246
7247 pub fn cancel_worker_rename(&mut self) {
7248 self.state.show_worker_rename = false;
7249 self.state.rename_buffer.clear();
7250 }
7251
7252 pub async fn confirm_worker_rename(&mut self) -> anyhow::Result<()> {
7253 let name = self.state.rename_buffer.trim().to_string();
7254 if name.is_empty() {
7255 anyhow::bail!("name cannot be empty");
7256 }
7257 if name.chars().count() > 32 {
7258 anyhow::bail!("name must be 1–32 characters");
7259 }
7260 let Some(worker) = self.state.hired_workers.get(self.state.workers_menu_index) else {
7261 anyhow::bail!("no worker selected");
7262 };
7263 let worker_instance_id = worker.instance_id.clone();
7264 self.seq += 1;
7265 self.session
7266 .submit_intent(Intent::RenameHiredWorker {
7267 entity_id: self.state.entity_id,
7268 worker_instance_id: worker_instance_id.clone(),
7269 name: name.clone(),
7270 seq: self.seq,
7271 })
7272 .await?;
7273 self.state.intents_sent += 1;
7274 if let Some(w) = self
7275 .state
7276 .hired_workers
7277 .iter_mut()
7278 .find(|w| w.instance_id == worker_instance_id)
7279 {
7280 w.label = name.clone();
7281 }
7282 if let Some(ed) = self.state.worker_route_editor.as_mut() {
7283 if ed.worker_instance_id == worker_instance_id {
7284 ed.worker_label = name.clone();
7285 }
7286 }
7287 self.state.show_worker_rename = false;
7288 self.state.rename_buffer.clear();
7289 self.state.push_log(format!("Renamed worker to \"{name}\""));
7290 Ok(())
7291 }
7292
7293 pub fn toggle_inventory_menu(&mut self) {
7294 if self.state.show_inventory_menu {
7295 self.close_inventory_menu();
7296 } else {
7297 self.open_inventory_menu();
7298 }
7299 }
7300
7301 pub fn inventory_menu_move(&mut self, delta: i32) {
7303 if self.state.show_grant_picker {
7304 let Some(picker) = self.state.grant_picker.as_ref() else {
7305 return;
7306 };
7307 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7308 let filter = picker.filter.clone();
7309 let n = labels.len();
7310 if n == 0 {
7311 return;
7312 }
7313 self.state.grant_picker_index = step_filtered_index(
7314 self.state.grant_picker_index,
7315 delta,
7316 n,
7317 |i| list_label_matches(&labels[i], &filter),
7318 );
7319 return;
7320 }
7321 if self.state.show_move_picker {
7322 let Some(picker) = self.state.move_picker.as_ref() else {
7323 return;
7324 };
7325 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7326 let filter = picker.filter.clone();
7327 let n = labels.len();
7328 if n == 0 {
7329 return;
7330 }
7331 self.state.move_picker_index = step_filtered_index(
7332 self.state.move_picker_index,
7333 delta,
7334 n,
7335 |i| list_label_matches(&labels[i], &filter),
7336 );
7337 self.state.clamp_move_picker_quantity();
7338 return;
7339 }
7340 let n = self.state.inventory_selectable_rows().len();
7341 if n == 0 {
7342 return;
7343 }
7344 let idx = self.state.inventory_menu_index as i32;
7345 self.state.inventory_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
7346 }
7347
7348 pub fn inventory_menu_page(&mut self, pages: i32) {
7350 if self.state.show_grant_picker {
7351 let Some(picker) = self.state.grant_picker.as_ref() else {
7352 return;
7353 };
7354 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7355 let filter = picker.filter.clone();
7356 let n = labels.len();
7357 self.state.grant_picker_index = page_filtered_index(
7358 self.state.grant_picker_index,
7359 pages,
7360 n,
7361 |i| list_label_matches(&labels[i], &filter),
7362 );
7363 return;
7364 }
7365 if self.state.show_move_picker {
7366 let Some(picker) = self.state.move_picker.as_ref() else {
7367 return;
7368 };
7369 let labels: Vec<String> = picker.options.iter().map(|o| o.label.clone()).collect();
7370 let filter = picker.filter.clone();
7371 let n = labels.len();
7372 self.state.move_picker_index = page_filtered_index(
7373 self.state.move_picker_index,
7374 pages,
7375 n,
7376 |i| list_label_matches(&labels[i], &filter),
7377 );
7378 self.state.clamp_move_picker_quantity();
7379 return;
7380 }
7381 let n = self.state.inventory_selectable_rows().len();
7382 self.state.inventory_menu_index =
7383 page_list_index(self.state.inventory_menu_index, pages, n);
7384 }
7385
7386 pub fn cycle_inventory_tab(&mut self, forward: bool) {
7387 if self.state.show_move_picker
7388 || self.state.show_grant_picker
7389 || self.state.show_destroy_picker
7390 || self.state.show_rename_prompt
7391 || self.state.inventory_filter_focused
7392 {
7393 return;
7394 }
7395 self.state.inventory_tab = self.state.inventory_tab.cycle(forward);
7396 self.state.inventory_menu_index = 0;
7397 self.state.clamp_inventory_indices();
7398 }
7399
7400 pub fn focus_inventory_filter(&mut self) {
7401 if self.state.show_grant_picker {
7402 if let Some(p) = self.state.grant_picker.as_mut() {
7403 p.filter_focused = true;
7404 }
7405 return;
7406 }
7407 if self.state.show_move_picker {
7408 if let Some(p) = self.state.move_picker.as_mut() {
7409 p.filter_focused = true;
7410 }
7411 return;
7412 }
7413 self.state.inventory_filter_focused = true;
7414 }
7415
7416 pub fn set_inventory_filter(&mut self, filter: String) {
7417 self.state.inventory_filter = filter;
7418 self.state.inventory_menu_index = 0;
7419 self.state.clamp_inventory_indices();
7420 }
7421
7422 pub fn append_inventory_filter_char(&mut self, ch: char) {
7423 if ch.is_control() {
7424 return;
7425 }
7426 if self.state.show_grant_picker {
7427 if let Some(p) = self.state.grant_picker.as_mut() {
7428 if p.filter_focused {
7429 p.filter.push(ch);
7430 self.state.grant_picker_index = 0;
7431 }
7432 }
7433 return;
7434 }
7435 if self.state.show_move_picker {
7436 if let Some(p) = self.state.move_picker.as_mut() {
7437 if p.filter_focused {
7438 p.filter.push(ch);
7439 self.state.move_picker_index = 0;
7440 self.state.clamp_move_picker_quantity();
7441 }
7442 }
7443 return;
7444 }
7445 if !self.state.inventory_filter_focused {
7446 return;
7447 }
7448 self.state.inventory_filter.push(ch);
7449 self.state.inventory_menu_index = 0;
7450 self.state.clamp_inventory_indices();
7451 }
7452
7453 pub fn inventory_filter_backspace(&mut self) {
7454 if self.state.show_grant_picker {
7455 if let Some(p) = self.state.grant_picker.as_mut() {
7456 if p.filter_focused {
7457 p.filter.pop();
7458 self.state.grant_picker_index = 0;
7459 }
7460 }
7461 return;
7462 }
7463 if self.state.show_move_picker {
7464 if let Some(p) = self.state.move_picker.as_mut() {
7465 if p.filter_focused {
7466 p.filter.pop();
7467 self.state.move_picker_index = 0;
7468 self.state.clamp_move_picker_quantity();
7469 }
7470 }
7471 return;
7472 }
7473 if !self.state.inventory_filter_focused {
7474 return;
7475 }
7476 self.state.inventory_filter.pop();
7477 self.state.inventory_menu_index = 0;
7478 self.state.clamp_inventory_indices();
7479 }
7480
7481 pub fn clear_or_blur_inventory_filter(&mut self) -> bool {
7483 if self.state.show_grant_picker {
7484 if let Some(p) = self.state.grant_picker.as_mut() {
7485 if p.filter_focused {
7486 if !p.filter.is_empty() {
7487 p.filter.clear();
7488 self.state.grant_picker_index = 0;
7489 } else {
7490 p.filter_focused = false;
7491 }
7492 return true;
7493 }
7494 if !p.filter.is_empty() {
7495 p.filter.clear();
7496 self.state.grant_picker_index = 0;
7497 return true;
7498 }
7499 }
7500 return false;
7501 }
7502 if self.state.show_move_picker {
7503 if let Some(p) = self.state.move_picker.as_mut() {
7504 if p.filter_focused {
7505 if !p.filter.is_empty() {
7506 p.filter.clear();
7507 self.state.move_picker_index = 0;
7508 self.state.clamp_move_picker_quantity();
7509 } else {
7510 p.filter_focused = false;
7511 }
7512 return true;
7513 }
7514 if !p.filter.is_empty() {
7515 p.filter.clear();
7516 self.state.move_picker_index = 0;
7517 self.state.clamp_move_picker_quantity();
7518 return true;
7519 }
7520 }
7521 return false;
7522 }
7523 if self.state.inventory_filter_focused {
7524 if !self.state.inventory_filter.is_empty() {
7525 self.state.inventory_filter.clear();
7526 self.state.inventory_menu_index = 0;
7527 self.state.clamp_inventory_indices();
7528 } else {
7529 self.state.inventory_filter_focused = false;
7530 }
7531 return true;
7532 }
7533 if !self.state.inventory_filter.is_empty() {
7534 self.state.inventory_filter.clear();
7535 self.state.inventory_menu_index = 0;
7536 self.state.clamp_inventory_indices();
7537 return true;
7538 }
7539 false
7540 }
7541
7542 pub fn craft_menu_page(&mut self, pages: i32) {
7543 let n = self.state.blueprints.len();
7544 self.state.craft_menu_index = page_list_index(self.state.craft_menu_index, pages, n);
7545 self.state.clamp_craft_batch_quantity();
7546 }
7547
7548 pub fn shop_menu_page(&mut self, pages: i32) {
7549 let n = self.state.shop_list_len();
7550 self.state.shop_menu_index = page_list_index(self.state.shop_menu_index, pages, n);
7551 self.state.clamp_shop_quantity();
7552 }
7553
7554 pub fn workers_menu_page(&mut self, pages: i32) {
7555 let n = self.state.hired_workers.len();
7556 self.state.workers_menu_index =
7557 page_list_index(self.state.workers_menu_index, pages, n);
7558 }
7559
7560 pub async fn activate_inventory_selection(&mut self) -> anyhow::Result<()> {
7565 if self.state.show_destroy_picker {
7566 if self.state.destroy_confirm_pending {
7567 return self.confirm_destroy_item().await;
7568 }
7569 return self.request_destroy_confirm();
7570 }
7571 if self.state.show_grant_picker {
7572 return self.confirm_grant_picker().await;
7573 }
7574 if self.state.show_move_picker {
7575 return self.confirm_move_picker().await;
7576 }
7577 let Some(row) = self.state.inventory_selected_row() else {
7578 anyhow::bail!("inventory empty");
7579 };
7580 if row.is_equip_shell {
7581 let flatland_protocol::InventoryLocation::Worn { slot } = row.from else {
7582 anyhow::bail!("not a worn item");
7583 };
7584 return self.equip_worn(slot, None).await;
7585 }
7586 if row.is_chest_shell {
7587 return self.open_chest_pickup_picker();
7588 }
7589 let template_id = row.stack.template_id.clone();
7590 let instance_id = row.stack.item_instance_id;
7591 let category = self.state.inventory_item_category(&template_id);
7592 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7593
7594 if category == Some("weapon") {
7595 return self.equip_mainhand(Some(template_id)).await;
7596 }
7597 if category == Some("lodging") && on_person {
7598 if let Some(inst) = instance_id {
7599 return self.place_container(inst).await;
7600 }
7601 }
7602 if (category == Some("container") || category == Some("armor")) && on_person {
7603 if let Some(inst) = instance_id {
7604 let world_placeable = row.stack.world_placeable == Some(true)
7605 || template_id.contains("chest");
7606 if world_placeable {
7607 return self.place_container(inst).await;
7608 }
7609 if let Some(slot) = guess_body_slot(&template_id) {
7613 return self.equip_worn(slot, Some(inst)).await;
7614 }
7615 }
7616 }
7617 self.open_move_picker()
7621 }
7622
7623 pub async fn use_selected_consumable(&mut self) -> anyhow::Result<()> {
7625 let Some(row) = self.state.inventory_selected_row() else {
7626 anyhow::bail!("inventory empty");
7627 };
7628 if row.from != flatland_protocol::InventoryLocation::Root {
7629 anyhow::bail!("select a consumable on your person");
7630 }
7631 if GameState::stack_is_item_grant(&row.stack) {
7632 return self.open_grant_target_picker();
7633 }
7634 if GameState::is_property_deed_template(&row.stack.template_id) {
7635 return self.open_move_picker();
7636 }
7637 let category = self
7638 .state
7639 .inventory_item_category(&row.stack.template_id);
7640 if category != Some("consumable") {
7641 anyhow::bail!("selected item is not consumable");
7642 }
7643 self.use_item(&row.stack.template_id).await
7644 }
7645
7646 pub fn open_grant_target_picker(&mut self) -> anyhow::Result<()> {
7648 let Some(row) = self.state.inventory_selected_row() else {
7649 anyhow::bail!("inventory empty");
7650 };
7651 if row.from != flatland_protocol::InventoryLocation::Root {
7652 anyhow::bail!("select a grant item on your person");
7653 }
7654 if !GameState::stack_is_item_grant(&row.stack) {
7655 anyhow::bail!("selected item does not grant onto gear");
7656 }
7657 let Some(grant_instance_id) = row.stack.item_instance_id else {
7658 anyhow::bail!("grant has no instance id");
7659 };
7660 let effect_id = GameState::grant_effect_id(&row.stack)
7661 .unwrap_or("?")
7662 .to_string();
7663 let mode = GameState::grant_mode(&row.stack).to_string();
7664 let options = self.state.grant_target_options(&row.stack);
7665 if options.is_empty() {
7666 anyhow::bail!("no valid gear to apply {effect_id} to");
7667 }
7668 let grant_label = row
7669 .stack
7670 .display_name
7671 .clone()
7672 .unwrap_or_else(|| row.stack.template_id.clone());
7673 self.state.show_grant_picker = true;
7674 self.state.grant_picker_index = 0;
7675 self.state.grant_picker = Some(GrantTargetPicker {
7676 grant_instance_id,
7677 grant_label,
7678 effect_id,
7679 mode,
7680 options,
7681 filter: String::new(),
7682 filter_focused: false,
7683 });
7684 Ok(())
7685 }
7686
7687 pub fn close_grant_picker(&mut self) {
7688 self.state.show_grant_picker = false;
7689 self.state.grant_picker = None;
7690 self.state.grant_picker_index = 0;
7691 }
7692
7693 pub async fn confirm_grant_picker(&mut self) -> anyhow::Result<()> {
7694 let Some(picker) = self.state.grant_picker.clone() else {
7695 self.close_grant_picker();
7696 return Ok(());
7697 };
7698 let Some(opt) = picker.options.get(self.state.grant_picker_index).cloned() else {
7699 self.close_grant_picker();
7700 return Ok(());
7701 };
7702 self.close_grant_picker();
7703 self.use_grant(picker.grant_instance_id, opt.target_instance_id)
7704 .await?;
7705 self.state.push_log(format!(
7706 "Applying {} onto {}…",
7707 picker.effect_id, opt.label
7708 ));
7709 Ok(())
7710 }
7711
7712 pub fn open_move_picker(&mut self) -> anyhow::Result<()> {
7716 let Some(row) = self.state.inventory_selected_row() else {
7717 anyhow::bail!("inventory empty");
7718 };
7719 if row.is_equip_shell {
7720 anyhow::bail!("this is a worn bag — press Enter to unequip it");
7721 }
7722 if row.is_chest_shell {
7723 return self.open_chest_pickup_picker();
7724 }
7725 let Some(instance_id) = row.stack.item_instance_id else {
7726 anyhow::bail!("item has no instance id");
7727 };
7728 let mut options = self.state.move_destinations_for(
7729 &row.from,
7730 row.from_parent_instance_id,
7731 row.stack.item_instance_id,
7732 &row.stack.template_id,
7733 );
7734 let on_person = row.from == flatland_protocol::InventoryLocation::Root;
7735 let category = self.state.inventory_item_category(&row.stack.template_id);
7736 if on_person && GameState::is_property_deed_template(&row.stack.template_id) {
7737 if let Some(plot_id) = GameState::deed_plot_id(&row.stack) {
7738 options.insert(
7739 0,
7740 MoveOption {
7741 label: "Sell plot to crown…".into(),
7742 kind: MoveOptionKind::SellPlotToCrown { plot_id },
7743 },
7744 );
7745 }
7746 }
7747 if on_person && category == Some("consumable") {
7748 if GameState::stack_is_item_grant(&row.stack) {
7749 options.insert(
7750 0,
7751 MoveOption {
7752 label: "Apply onto gear…".into(),
7753 kind: MoveOptionKind::GrantApply,
7754 },
7755 );
7756 } else {
7757 options.insert(
7758 0,
7759 MoveOption {
7760 label: "Use (eat / drink)".into(),
7761 kind: MoveOptionKind::Use,
7762 },
7763 );
7764 }
7765 }
7766 let item_label = row
7767 .stack
7768 .display_name
7769 .clone()
7770 .unwrap_or_else(|| row.stack.template_id.clone());
7771 let initial_qty = if row.stack.quantity > 1 { 1 } else { row.stack.quantity };
7774 self.state.move_picker = Some(MovePicker {
7775 item_instance_id: instance_id,
7776 from: row.from,
7777 item_label,
7778 template_id: row.stack.template_id.clone(),
7779 stack_quantity: row.stack.quantity,
7780 quantity: initial_qty.max(1),
7781 options,
7782 filter: String::new(),
7783 filter_focused: false,
7784 });
7785 self.state.move_picker_index = 0;
7786 self.state.show_move_picker = true;
7787 self.state.show_destroy_picker = false;
7788 self.state.destroy_confirm_pending = false;
7789 self.state.destroy_picker = None;
7790 self.state.clamp_move_picker_quantity();
7791 Ok(())
7792 }
7793
7794 pub fn open_chest_pickup_picker(&mut self) -> anyhow::Result<()> {
7796 let Some(row) = self.state.inventory_selected_row() else {
7797 anyhow::bail!("inventory empty");
7798 };
7799 if !row.is_chest_shell {
7800 anyhow::bail!("not a placed chest");
7801 }
7802 let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from else {
7803 anyhow::bail!("not a placed chest");
7804 };
7805 let Some(instance_id) = row.stack.item_instance_id else {
7806 anyhow::bail!("chest has no instance id");
7807 };
7808 let chest = self
7809 .state
7810 .placed_containers
7811 .iter()
7812 .find(|c| c.id == *container_id)
7813 .cloned()
7814 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
7815 let (px, py) = self.state.player_position();
7816 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
7817 anyhow::bail!("too far from {}", chest.display_name);
7818 }
7819 if chest.locked && !chest.accessible {
7820 anyhow::bail!(
7821 "need the matching key for {} before picking it up",
7822 chest.display_name
7823 );
7824 }
7825 let options = self.state.chest_pickup_destinations(container_id);
7826 let item_label = row
7827 .stack
7828 .display_name
7829 .clone()
7830 .unwrap_or_else(|| row.stack.template_id.clone());
7831 self.state.move_picker = Some(MovePicker {
7832 item_instance_id: instance_id,
7833 from: row.from.clone(),
7834 item_label,
7835 template_id: row.stack.template_id.clone(),
7836 stack_quantity: 1,
7837 quantity: 1,
7838 options,
7839 filter: String::new(),
7840 filter_focused: false,
7841 });
7842 self.state.move_picker_index = 0;
7843 self.state.show_move_picker = true;
7844 self.state.show_destroy_picker = false;
7845 self.state.destroy_confirm_pending = false;
7846 self.state.destroy_picker = None;
7847 Ok(())
7848 }
7849
7850 pub fn close_move_picker(&mut self) {
7851 self.state.show_move_picker = false;
7852 self.state.move_picker = None;
7853 }
7854
7855 pub fn move_picker_adjust_quantity(&mut self, delta: i32) {
7856 self.state.move_picker_adjust_quantity(delta);
7857 }
7858
7859 pub fn move_picker_set_quantity_max(&mut self) {
7860 self.state.move_picker_set_quantity_max();
7861 }
7862
7863 pub fn move_picker_set_quantity_min(&mut self) {
7864 self.state.move_picker_set_quantity_min();
7865 }
7866
7867 pub fn destroy_picker_adjust_quantity(&mut self, delta: i32) {
7868 self.state.destroy_picker_adjust_quantity(delta);
7869 }
7870
7871 pub fn destroy_picker_set_quantity_max(&mut self) {
7872 self.state.destroy_picker_set_quantity_max();
7873 }
7874
7875 pub fn destroy_picker_set_quantity_min(&mut self) {
7876 self.state.destroy_picker_set_quantity_min();
7877 }
7878
7879 async fn confirm_move_picker(&mut self) -> anyhow::Result<()> {
7880 let Some(picker) = self.state.move_picker.clone() else {
7881 self.close_move_picker();
7882 return Ok(());
7883 };
7884 let Some(option) = picker.options.get(self.state.move_picker_index).cloned() else {
7885 self.close_move_picker();
7886 return Ok(());
7887 };
7888 match option.kind {
7889 MoveOptionKind::Cancel => {
7890 self.close_move_picker();
7891 }
7892 MoveOptionKind::Use => {
7893 self.close_move_picker();
7894 self.use_item(&picker.template_id).await?;
7895 }
7896 MoveOptionKind::GrantApply => {
7897 self.close_move_picker();
7898 self.open_grant_target_picker()?;
7899 }
7900 MoveOptionKind::SellPlotToCrown { plot_id } => {
7901 self.close_move_picker();
7902 self.confirm_sell_plot_to_crown(plot_id).await?;
7903 }
7904 MoveOptionKind::RelocatePlaced { container_id } => {
7905 self.close_move_picker();
7906 self.state.show_inventory_menu = false;
7907 self.begin_relocate_container(&container_id)?;
7908 }
7909 MoveOptionKind::Drop => {
7910 self.close_move_picker();
7911 if self
7912 .state
7913 .hand_equipped_instance_ids()
7914 .contains(&picker.item_instance_id)
7915 {
7916 anyhow::bail!("unequip that item first");
7917 }
7918 if let Some(stack) = self.state.stack_for_instance(picker.item_instance_id) {
7919 if self.state.deed_bound(&stack) {
7920 anyhow::bail!(
7921 "cannot drop a property deed — store it or trade it to another player"
7922 );
7923 }
7924 if self.state.key_drop_blocked(&stack) {
7925 anyhow::bail!("cannot drop the key while its chest is locked");
7926 }
7927 }
7928 self.drop_item(picker.item_instance_id, picker.from).await?;
7929 self.state
7930 .push_log(format!("Dropped {}", picker.item_label));
7931 }
7932 MoveOptionKind::PickupPlaced {
7933 container_id,
7934 nest_location,
7935 nest_parent_instance_id,
7936 } => {
7937 self.close_move_picker();
7938 self.pickup_container(container_id.clone()).await?;
7939 let nest_into_bag = nest_parent_instance_id.is_some()
7940 || !matches!(
7941 nest_location,
7942 flatland_protocol::InventoryLocation::Root
7943 );
7944 if nest_into_bag {
7945 self.move_item(
7946 picker.item_instance_id,
7947 flatland_protocol::InventoryLocation::Root,
7948 nest_location,
7949 nest_parent_instance_id,
7950 None,
7951 )
7952 .await?;
7953 self.state
7954 .push_log(format!("Picked up {} into bag", picker.item_label));
7955 } else {
7956 self.state
7957 .push_log(format!("Picked up {}", picker.item_label));
7958 }
7959 }
7960 MoveOptionKind::Move {
7961 location,
7962 parent_instance_id,
7963 } => {
7964 self.close_move_picker();
7965 let qty = if picker.quantity >= picker.stack_quantity {
7966 None
7967 } else {
7968 Some(picker.quantity)
7969 };
7970 self.move_item(
7971 picker.item_instance_id,
7972 picker.from,
7973 location,
7974 parent_instance_id,
7975 qty,
7976 )
7977 .await?;
7978 let moved = qty.unwrap_or(picker.stack_quantity);
7979 if moved >= picker.stack_quantity {
7980 self.state.push_log(format!("Moved {}", picker.item_label));
7981 } else {
7982 self.state.push_log(format!(
7983 "Moved {} ×{} of {}",
7984 picker.item_label, moved, picker.stack_quantity
7985 ));
7986 }
7987 }
7988 }
7989 Ok(())
7990 }
7991
7992 pub async fn drop_selected(&mut self) -> anyhow::Result<()> {
7994 let Some(row) = self.state.inventory_selected_row() else {
7995 anyhow::bail!("inventory empty");
7996 };
7997 if row.is_equip_shell {
7998 anyhow::bail!("unequip the bag first (Enter), then drop from your person");
7999 }
8000 if row.is_chest_shell {
8001 anyhow::bail!("can't drop a placed chest from the inventory list — pick it up first");
8002 }
8003 let Some(inst) = row.stack.item_instance_id else {
8004 anyhow::bail!("item has no instance id");
8005 };
8006 if self.state.hand_equipped_instance_ids().contains(&inst) {
8007 anyhow::bail!("unequip that item first");
8008 }
8009 if self.state.deed_bound(&row.stack) {
8010 anyhow::bail!("cannot drop a property deed — store it or trade it to another player");
8011 }
8012 if self.state.key_drop_blocked(&row.stack) {
8013 anyhow::bail!("cannot drop the key while its chest is locked");
8014 }
8015 let label = row
8016 .stack
8017 .display_name
8018 .clone()
8019 .unwrap_or_else(|| row.stack.template_id.clone());
8020 self.drop_item(inst, row.from).await?;
8021 self.state.push_log(format!("Dropped {label}"));
8022 Ok(())
8023 }
8024
8025 pub async fn drop_item(
8026 &mut self,
8027 item_instance_id: uuid::Uuid,
8028 from: flatland_protocol::InventoryLocation,
8029 ) -> anyhow::Result<()> {
8030 self.seq += 1;
8031 self.session
8032 .submit_intent(Intent::DropItem {
8033 entity_id: self.state.entity_id,
8034 item_instance_id,
8035 from,
8036 seq: self.seq,
8037 })
8038 .await?;
8039 self.state.intents_sent += 1;
8040 Ok(())
8041 }
8042
8043 pub fn open_destroy_picker(&mut self) -> anyhow::Result<()> {
8045 let Some(row) = self.state.inventory_selected_row() else {
8046 anyhow::bail!("inventory empty");
8047 };
8048 if row.is_equip_shell {
8049 anyhow::bail!("unequip the bag first (Enter), then destroy from your person");
8050 }
8051 if row.is_chest_shell {
8052 anyhow::bail!("can't destroy a placed chest from the inventory list");
8053 }
8054 let Some(instance_id) = row.stack.item_instance_id else {
8055 anyhow::bail!("item has no instance id");
8056 };
8057 if self.state.hand_equipped_instance_ids().contains(&instance_id) {
8058 anyhow::bail!("unequip that item first");
8059 }
8060 if self.state.deed_bound(&row.stack) {
8061 anyhow::bail!(
8062 "cannot destroy a property deed — store it or trade it to another player"
8063 );
8064 }
8065 if self.state.key_drop_blocked(&row.stack) {
8066 anyhow::bail!("cannot destroy the key while its chest is locked");
8067 }
8068 let item_label = row
8069 .stack
8070 .display_name
8071 .clone()
8072 .unwrap_or_else(|| row.stack.template_id.clone());
8073 self.state.destroy_picker = Some(DestroyPicker {
8074 item_instance_id: instance_id,
8075 from: row.from,
8076 item_label,
8077 stack_quantity: row.stack.quantity,
8078 quantity: row.stack.quantity,
8079 });
8080 self.state.destroy_confirm_pending = false;
8081 self.state.show_destroy_picker = true;
8082 self.state.show_move_picker = false;
8083 self.state.move_picker = None;
8084 Ok(())
8085 }
8086
8087 pub fn close_destroy_picker(&mut self) {
8088 self.state.show_destroy_picker = false;
8089 self.state.destroy_confirm_pending = false;
8090 self.state.destroy_picker = None;
8091 }
8092
8093 pub fn cancel_destroy_confirm(&mut self) {
8094 self.state.destroy_confirm_pending = false;
8095 }
8096
8097 pub fn request_destroy_confirm(&mut self) -> anyhow::Result<()> {
8098 if self.state.destroy_picker.is_none() {
8099 self.close_destroy_picker();
8100 return Ok(());
8101 }
8102 self.state.destroy_confirm_pending = true;
8103 Ok(())
8104 }
8105
8106 pub async fn confirm_destroy_item(&mut self) -> anyhow::Result<()> {
8107 let Some(picker) = self.state.destroy_picker.clone() else {
8108 self.close_destroy_picker();
8109 return Ok(());
8110 };
8111 let qty = if picker.quantity >= picker.stack_quantity {
8112 None
8113 } else {
8114 Some(picker.quantity)
8115 };
8116 self.destroy_item(picker.item_instance_id, picker.from, qty)
8117 .await?;
8118 let destroyed = qty.unwrap_or(picker.stack_quantity);
8119 if destroyed >= picker.stack_quantity {
8120 self.state
8121 .push_log(format!("Destroyed {}", picker.item_label));
8122 } else {
8123 self.state.push_log(format!(
8124 "Destroyed {} ×{} of {}",
8125 picker.item_label, destroyed, picker.stack_quantity
8126 ));
8127 }
8128 self.close_destroy_picker();
8129 Ok(())
8130 }
8131
8132 pub async fn destroy_item(
8133 &mut self,
8134 item_instance_id: uuid::Uuid,
8135 from: flatland_protocol::InventoryLocation,
8136 quantity: Option<u32>,
8137 ) -> anyhow::Result<()> {
8138 self.seq += 1;
8139 self.session
8140 .submit_intent(Intent::DestroyItem {
8141 entity_id: self.state.entity_id,
8142 item_instance_id,
8143 from,
8144 quantity,
8145 seq: self.seq,
8146 })
8147 .await?;
8148 self.state.intents_sent += 1;
8149 Ok(())
8150 }
8151
8152 pub async fn toggle_chest_lock_for_selection(&mut self) -> anyhow::Result<()> {
8154 if let Some(row) = self.state.inventory_selected_row() {
8155 if let flatland_protocol::InventoryLocation::Placed { container_id } = &row.from {
8156 return self.toggle_placed_chest_lock(container_id).await;
8157 }
8158 }
8159 self.toggle_nearby_chest_lock().await
8160 }
8161
8162 pub async fn toggle_placed_chest_lock(&mut self, container_id: &str) -> anyhow::Result<()> {
8163 let chest = self
8164 .state
8165 .placed_containers
8166 .iter()
8167 .find(|c| c.id == container_id)
8168 .cloned()
8169 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
8170 let (px, py) = self.state.player_position();
8171 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
8172 anyhow::bail!("too far from {}", chest.display_name);
8173 }
8174 if !chest.accessible && chest.locked {
8175 anyhow::bail!(
8176 "need the matching key for {} (each crafted chest has its own key)",
8177 chest.display_name
8178 );
8179 }
8180 let lock = !chest.locked;
8181 self.set_container_locked(
8182 flatland_protocol::InventoryLocation::Placed {
8183 container_id: chest.id.clone(),
8184 },
8185 lock,
8186 )
8187 .await?;
8188 self.state.push_log(if lock {
8189 format!("Locked {}", chest.display_name)
8190 } else {
8191 format!("Unlocked {}", chest.display_name)
8192 });
8193 Ok(())
8194 }
8195
8196 pub async fn toggle_nearby_chest_lock(&mut self) -> anyhow::Result<()> {
8198 let chest = self
8199 .state
8200 .nearest_placed_container(CONTAINER_RANGE_M)
8201 .ok_or_else(|| anyhow::anyhow!("no chest nearby"))?;
8202 self.toggle_placed_chest_lock(&chest.id).await
8203 }
8204
8205 pub async fn unequip_mainhand(&mut self) -> anyhow::Result<()> {
8206 self.equip_mainhand(None).await
8207 }
8208
8209 pub async fn equip_offhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
8210 if !self.state.is_alive() {
8211 anyhow::bail!("you are dead");
8212 }
8213 self.seq += 1;
8214 self.session
8215 .submit_intent(Intent::EquipOffhand {
8216 entity_id: self.state.entity_id,
8217 template_id,
8218 instance_id: None,
8219 seq: self.seq,
8220 })
8221 .await?;
8222 self.state.intents_sent += 1;
8223 Ok(())
8224 }
8225
8226 pub async fn unequip_offhand(&mut self) -> anyhow::Result<()> {
8227 self.equip_offhand(None).await
8228 }
8229
8230 pub async fn unequip_all_worn(&mut self) -> anyhow::Result<()> {
8231 let slots: Vec<BodySlot> = self.state.worn.keys().copied().collect();
8232 for slot in slots {
8233 self.equip_worn(slot, None).await?;
8234 }
8235 Ok(())
8236 }
8237
8238 pub async fn pickup_nearest_container(&mut self) -> anyhow::Result<()> {
8239 let (px, py) = self.state.player_position();
8240 let nearest = self
8241 .state
8242 .placed_containers
8243 .iter()
8244 .min_by(|a, b| {
8245 let da = (a.x - px).hypot(a.y - py);
8246 let db = (b.x - px).hypot(b.y - py);
8247 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
8248 })
8249 .cloned();
8250 let Some(chest) = nearest else {
8251 anyhow::bail!("no chest nearby");
8252 };
8253 if (chest.x - px).hypot(chest.y - py) > 2.0 {
8254 anyhow::bail!("too far from chest");
8255 }
8256 self.pickup_container(chest.id).await
8257 }
8258
8259 pub async fn equip_worn(
8260 &mut self,
8261 slot: BodySlot,
8262 instance_id: Option<uuid::Uuid>,
8263 ) -> anyhow::Result<()> {
8264 self.seq += 1;
8265 self.session
8266 .submit_intent(Intent::EquipWorn {
8267 entity_id: self.state.entity_id,
8268 slot,
8269 instance_id,
8270 seq: self.seq,
8271 })
8272 .await?;
8273 self.state.intents_sent += 1;
8274 Ok(())
8275 }
8276
8277 pub async fn place_container(&mut self, item_instance_id: uuid::Uuid) -> anyhow::Result<()> {
8278 self.seq += 1;
8279 self.session
8280 .submit_intent(Intent::PlaceContainer {
8281 entity_id: self.state.entity_id,
8282 item_instance_id,
8283 seq: self.seq,
8284 })
8285 .await?;
8286 self.state.intents_sent += 1;
8287 Ok(())
8288 }
8289
8290 pub async fn pickup_container(&mut self, container_id: String) -> anyhow::Result<()> {
8291 self.seq += 1;
8292 self.session
8293 .submit_intent(Intent::PickupContainer {
8294 entity_id: self.state.entity_id,
8295 container_id,
8296 seq: self.seq,
8297 })
8298 .await?;
8299 self.state.intents_sent += 1;
8300 Ok(())
8301 }
8302
8303 pub async fn move_item(
8304 &mut self,
8305 item_instance_id: uuid::Uuid,
8306 from: flatland_protocol::InventoryLocation,
8307 to: flatland_protocol::InventoryLocation,
8308 to_parent_instance_id: Option<uuid::Uuid>,
8309 quantity: Option<u32>,
8310 ) -> anyhow::Result<()> {
8311 self.seq += 1;
8312 self.session
8313 .submit_intent(Intent::MoveItem {
8314 entity_id: self.state.entity_id,
8315 item_instance_id,
8316 from,
8317 to,
8318 to_parent_instance_id,
8319 quantity,
8320 seq: self.seq,
8321 })
8322 .await?;
8323 self.state.intents_sent += 1;
8324 Ok(())
8325 }
8326
8327 pub async fn set_container_locked(
8328 &mut self,
8329 location: flatland_protocol::InventoryLocation,
8330 locked: bool,
8331 ) -> anyhow::Result<()> {
8332 self.seq += 1;
8333 self.session
8334 .submit_intent(Intent::SetContainerLocked {
8335 entity_id: self.state.entity_id,
8336 location,
8337 locked,
8338 seq: self.seq,
8339 })
8340 .await?;
8341 self.state.intents_sent += 1;
8342 Ok(())
8343 }
8344
8345 pub async fn use_item(&mut self, template_id: &str) -> anyhow::Result<()> {
8346 if !self.state.is_alive() {
8347 anyhow::bail!("you are dead");
8348 }
8349 self.seq += 1;
8350 self.session
8351 .submit_intent(Intent::Use {
8352 entity_id: self.state.entity_id,
8353 template_id: template_id.to_string(),
8354 seq: self.seq,
8355 })
8356 .await?;
8357 self.state.intents_sent += 1;
8358 Ok(())
8359 }
8360
8361 pub async fn use_grant(
8363 &mut self,
8364 grant_instance_id: uuid::Uuid,
8365 target_instance_id: uuid::Uuid,
8366 ) -> anyhow::Result<()> {
8367 if !self.state.is_alive() {
8368 anyhow::bail!("you are dead");
8369 }
8370 self.seq += 1;
8371 self.session
8372 .submit_intent(Intent::UseGrant {
8373 entity_id: self.state.entity_id,
8374 grant_instance_id,
8375 target_instance_id,
8376 seq: self.seq,
8377 })
8378 .await?;
8379 self.state.intents_sent += 1;
8380 Ok(())
8381 }
8382
8383 pub fn open_craft_menu(&mut self) {
8384 self.state.show_craft_menu = true;
8385 self.state.show_shop_menu = false;
8386 self.state.shop_catalog = None;
8387 self.state.show_stats = false;
8388 self.state.show_inventory_menu = false;
8389 if self.state.blueprints.is_empty() {
8390 self.state.craft_menu_index = 0;
8391 self.state.craft_batch_quantity = 1;
8392 return;
8393 }
8394 self.state.craft_menu_index = self
8395 .state
8396 .craft_menu_index
8397 .min(self.state.blueprints.len() - 1);
8398 if let Some(idx) = self
8399 .state
8400 .blueprints
8401 .iter()
8402 .position(|bp| self.state.can_craft_blueprint(bp))
8403 {
8404 self.state.craft_menu_index = idx;
8405 }
8406 self.state.clamp_craft_batch_quantity();
8407 }
8408
8409 pub fn close_craft_menu(&mut self) {
8410 self.state.show_craft_menu = false;
8411 }
8412
8413 pub fn toggle_keychain_menu(&mut self) {
8414 if self.state.show_keychain_menu {
8415 self.close_keychain_menu();
8416 } else {
8417 self.state.show_keychain_menu = true;
8418 self.state.show_craft_menu = false;
8419 self.state.show_shop_menu = false;
8420 self.state.show_inventory_menu = false;
8421 let n = self.state.keychain_entries().len();
8422 if n == 0 {
8423 self.state.keychain_menu_index = 0;
8424 } else {
8425 self.state.keychain_menu_index = self.state.keychain_menu_index.min(n - 1);
8426 }
8427 }
8428 }
8429
8430 pub fn close_keychain_menu(&mut self) {
8431 self.state.show_keychain_menu = false;
8432 }
8433
8434 pub fn keychain_menu_move(&mut self, delta: i32) {
8435 let n = self.state.keychain_entries().len();
8436 if n == 0 {
8437 self.state.keychain_menu_index = 0;
8438 return;
8439 }
8440 let idx = self.state.keychain_menu_index as i32 + delta;
8441 self.state.keychain_menu_index = idx.rem_euclid(n as i32) as usize;
8442 }
8443
8444 pub fn keychain_menu_page(&mut self, pages: i32) {
8445 let n = self.state.keychain_entries().len();
8446 self.state.keychain_menu_index =
8447 page_list_index(self.state.keychain_menu_index, pages, n);
8448 }
8449
8450 pub async fn activate_keychain_selection(&mut self) -> anyhow::Result<()> {
8451 if !self.state.is_alive() {
8452 anyhow::bail!("you are dead");
8453 }
8454 let entries = self.state.keychain_entries();
8455 let Some(entry) = entries.get(self.state.keychain_menu_index) else {
8456 anyhow::bail!("nothing selected");
8457 };
8458 let Some(instance_id) = entry.stack.item_instance_id else {
8459 anyhow::bail!("key has no instance id");
8460 };
8461 if entry.stowed {
8462 self.move_item(
8463 instance_id,
8464 flatland_protocol::InventoryLocation::Keychain,
8465 flatland_protocol::InventoryLocation::Root,
8466 None,
8467 Some(1),
8468 )
8469 .await
8470 } else {
8471 self.move_item(
8472 instance_id,
8473 flatland_protocol::InventoryLocation::Root,
8474 flatland_protocol::InventoryLocation::Keychain,
8475 None,
8476 Some(1),
8477 )
8478 .await
8479 }
8480 }
8481
8482 pub async fn close_shop_menu(&mut self) -> anyhow::Result<()> {
8483 let npc_id = self
8484 .state
8485 .shop_catalog
8486 .as_ref()
8487 .map(|c| c.npc_id.clone());
8488 self.state.show_shop_menu = false;
8489 self.state.shop_catalog = None;
8490 self.state.clear_shop_trade_log();
8491 if let Some(npc_id) = npc_id {
8492 self.seq += 1;
8493 self.session
8494 .submit_intent(Intent::ShopClose {
8495 entity_id: self.state.entity_id,
8496 npc_id,
8497 seq: self.seq,
8498 })
8499 .await?;
8500 self.state.intents_sent += 1;
8501 }
8502 Ok(())
8503 }
8504
8505 pub async fn bank_deposit(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8506 let Some(panel) = self.state.bank_panel.clone() else {
8507 return Ok(());
8508 };
8509 self.seq += 1;
8510 self.session
8511 .submit_intent(Intent::BankDeposit {
8512 entity_id: self.state.entity_id,
8513 npc_id: panel.npc_id,
8514 amount_copper,
8515 seq: self.seq,
8516 })
8517 .await?;
8518 self.state.intents_sent += 1;
8519 Ok(())
8520 }
8521
8522 pub async fn bank_withdraw(&mut self, amount_copper: u64) -> anyhow::Result<()> {
8523 let Some(panel) = self.state.bank_panel.clone() else {
8524 return Ok(());
8525 };
8526 self.seq += 1;
8527 self.session
8528 .submit_intent(Intent::BankWithdraw {
8529 entity_id: self.state.entity_id,
8530 npc_id: panel.npc_id,
8531 amount_copper,
8532 seq: self.seq,
8533 })
8534 .await?;
8535 self.state.intents_sent += 1;
8536 Ok(())
8537 }
8538
8539 pub async fn bank_transfer(
8540 &mut self,
8541 to_character_id: Option<uuid::Uuid>,
8542 to_name: String,
8543 amount_copper: u64,
8544 ) -> anyhow::Result<()> {
8545 let Some(panel) = self.state.bank_panel.clone() else {
8546 return Ok(());
8547 };
8548 self.seq += 1;
8549 self.session
8550 .submit_intent(Intent::BankTransfer {
8551 entity_id: self.state.entity_id,
8552 npc_id: panel.npc_id,
8553 to_character_id,
8554 to_name,
8555 amount_copper,
8556 seq: self.seq,
8557 })
8558 .await?;
8559 self.state.intents_sent += 1;
8560 Ok(())
8561 }
8562
8563 pub fn bank_menu_move(&mut self, delta: i32) {
8564 let n = self.state.bank_menu_options().len();
8565 if n == 0 || !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
8566 return;
8567 }
8568 let idx = self.state.bank_menu_index as i32;
8569 self.state.bank_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8570 }
8571
8572 pub fn storage_menu_move(&mut self, delta: i32) {
8573 let n = self.state.storage_menu_options().len();
8574 if n == 0 || !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
8575 return;
8576 }
8577 let idx = self.state.storage_menu_index as i32;
8578 self.state.storage_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
8579 }
8580
8581 pub fn storage_pick_move(&mut self, delta: i32) {
8582 let n = match &self.state.storage_ui_mode {
8583 StorageUiMode::StorePick { .. } => self.state.storage_store_options().len(),
8584 StorageUiMode::TakePick { .. } | StorageUiMode::ShipPick { .. } => {
8585 self.state.storage_vault_options().len()
8586 }
8587 StorageUiMode::Menu
8588 | StorageUiMode::StoreAmount { .. }
8589 | StorageUiMode::TakeAmount { .. }
8590 | StorageUiMode::ShipAmount { .. } => 0,
8591 };
8592 if n == 0 {
8593 return;
8594 }
8595 match &mut self.state.storage_ui_mode {
8596 StorageUiMode::StorePick { index }
8597 | StorageUiMode::TakePick { index }
8598 | StorageUiMode::ShipPick { index, .. } => {
8599 *index = (*index as i32 + delta).rem_euclid(n as i32) as usize;
8600 }
8601 StorageUiMode::Menu
8602 | StorageUiMode::StoreAmount { .. }
8603 | StorageUiMode::TakeAmount { .. }
8604 | StorageUiMode::ShipAmount { .. } => {}
8605 }
8606 }
8607
8608 pub fn storage_ui_back(&mut self) {
8609 self.state.storage_ui_mode = match &self.state.storage_ui_mode {
8610 StorageUiMode::StoreAmount { pick_index, .. } => StorageUiMode::StorePick {
8611 index: *pick_index,
8612 },
8613 StorageUiMode::TakeAmount { pick_index, .. } => StorageUiMode::TakePick {
8614 index: *pick_index,
8615 },
8616 StorageUiMode::ShipAmount {
8617 dest_building_id,
8618 dest_label,
8619 pick_index,
8620 ..
8621 } => StorageUiMode::ShipPick {
8622 dest_building_id: dest_building_id.clone(),
8623 dest_label: dest_label.clone(),
8624 index: *pick_index,
8625 },
8626 StorageUiMode::StorePick { .. }
8627 | StorageUiMode::TakePick { .. }
8628 | StorageUiMode::ShipPick { .. } => StorageUiMode::Menu,
8629 StorageUiMode::Menu => StorageUiMode::Menu,
8630 };
8631 }
8632
8633 pub fn storage_amount_append_char(&mut self, c: char) {
8634 match &mut self.state.storage_ui_mode {
8635 StorageUiMode::StoreAmount { input, .. }
8636 | StorageUiMode::TakeAmount { input, .. }
8637 | StorageUiMode::ShipAmount { input, .. } => {
8638 if c.is_ascii_digit() && input.len() < 8 {
8639 input.push(c);
8640 }
8641 }
8642 _ => {}
8643 }
8644 }
8645
8646 pub fn storage_amount_backspace(&mut self) {
8647 match &mut self.state.storage_ui_mode {
8648 StorageUiMode::StoreAmount { input, .. }
8649 | StorageUiMode::TakeAmount { input, .. }
8650 | StorageUiMode::ShipAmount { input, .. } => {
8651 input.pop();
8652 }
8653 _ => {}
8654 }
8655 }
8656
8657 pub fn storage_ui_typing(&self) -> bool {
8658 matches!(
8659 self.state.storage_ui_mode,
8660 StorageUiMode::StoreAmount { .. }
8661 | StorageUiMode::TakeAmount { .. }
8662 | StorageUiMode::ShipAmount { .. }
8663 )
8664 }
8665
8666 pub async fn confirm_storage_menu(&mut self) -> anyhow::Result<()> {
8667 match self.state.storage_ui_mode.clone() {
8668 StorageUiMode::Menu => {
8669 let index = self.state.storage_menu_index;
8670 match index {
8671 0 => {
8672 let opts = self.state.storage_store_options();
8673 if opts.is_empty() {
8674 self.state.push_log("Nothing loose to store.");
8675 return Ok(());
8676 }
8677 self.state.storage_ui_mode = StorageUiMode::StorePick { index: 0 };
8678 }
8679 1 => {
8680 let opts = self.state.storage_vault_options();
8681 if opts.is_empty() {
8682 self.state.push_log("Vault is empty.");
8683 return Ok(());
8684 }
8685 self.state.storage_ui_mode = StorageUiMode::TakePick { index: 0 };
8686 }
8687 n => {
8688 let dest = self
8689 .state
8690 .storage_panel
8691 .as_ref()
8692 .and_then(|p| p.ship_destinations.get(n - 2))
8693 .cloned();
8694 let Some(dest) = dest else {
8695 return Ok(());
8696 };
8697 let opts = self.state.storage_vault_options();
8698 if opts.is_empty() {
8699 self.state
8700 .push_log("Vault is empty — nothing to ship.");
8701 return Ok(());
8702 }
8703 self.state.storage_ui_mode = StorageUiMode::ShipPick {
8704 dest_building_id: dest.building_id,
8705 dest_label: dest.label,
8706 index: 0,
8707 };
8708 }
8709 }
8710 }
8711 StorageUiMode::StorePick { index } => {
8712 let opts = self.state.storage_store_options();
8713 let Some(opt) = opts.get(index) else {
8714 self.state.push_log("Nothing loose to store.");
8715 self.state.storage_ui_mode = StorageUiMode::Menu;
8716 return Ok(());
8717 };
8718 self.state.storage_ui_mode = StorageUiMode::StoreAmount {
8719 pick_index: index,
8720 item_instance_id: opt.item_instance_id,
8721 label: opt.label.clone(),
8722 max_qty: opt.quantity.max(1),
8723 input: String::new(),
8724 };
8725 }
8726 StorageUiMode::TakePick { index } => {
8727 let opts = self.state.storage_vault_options();
8728 let Some(opt) = opts.get(index) else {
8729 self.state.push_log("Vault is empty.");
8730 self.state.storage_ui_mode = StorageUiMode::Menu;
8731 return Ok(());
8732 };
8733 self.state.storage_ui_mode = StorageUiMode::TakeAmount {
8734 pick_index: index,
8735 item_instance_id: opt.item_instance_id,
8736 label: opt.label.clone(),
8737 max_qty: opt.quantity.max(1),
8738 input: String::new(),
8739 };
8740 }
8741 StorageUiMode::ShipPick {
8742 dest_building_id,
8743 dest_label,
8744 index,
8745 } => {
8746 let opts = self.state.storage_vault_options();
8747 let Some(opt) = opts.get(index) else {
8748 self.state
8749 .push_log("Vault is empty — nothing to ship.");
8750 self.state.storage_ui_mode = StorageUiMode::Menu;
8751 return Ok(());
8752 };
8753 self.state.storage_ui_mode = StorageUiMode::ShipAmount {
8754 dest_building_id,
8755 dest_label,
8756 pick_index: index,
8757 item_instance_id: opt.item_instance_id,
8758 label: opt.label.clone(),
8759 max_qty: opt.quantity.max(1),
8760 input: String::new(),
8761 };
8762 }
8763 StorageUiMode::StoreAmount {
8764 item_instance_id,
8765 max_qty,
8766 input,
8767 ..
8768 } => {
8769 let Some(qty) = parse_storage_quantity(&input) else {
8770 self.state
8771 .push_log("Enter a quantity (blank or 0 = all).");
8772 return Ok(());
8773 };
8774 let qty = qty.map(|n| n.min(max_qty).max(1));
8775 self.storage_store(item_instance_id, qty).await?;
8776 self.state.storage_ui_mode = StorageUiMode::Menu;
8777 }
8778 StorageUiMode::TakeAmount {
8779 item_instance_id,
8780 max_qty,
8781 input,
8782 ..
8783 } => {
8784 let Some(qty) = parse_storage_quantity(&input) else {
8785 self.state
8786 .push_log("Enter a quantity (blank or 0 = all).");
8787 return Ok(());
8788 };
8789 let qty = qty.map(|n| n.min(max_qty).max(1));
8790 self.storage_take(item_instance_id, qty).await?;
8791 self.state.storage_ui_mode = StorageUiMode::Menu;
8792 }
8793 StorageUiMode::ShipAmount {
8794 dest_building_id,
8795 item_instance_id,
8796 max_qty,
8797 input,
8798 ..
8799 } => {
8800 let Some(qty) = parse_storage_quantity(&input) else {
8801 self.state
8802 .push_log("Enter a quantity (blank or 0 = all).");
8803 return Ok(());
8804 };
8805 let qty = qty.map(|n| n.min(max_qty).max(1));
8806 self.storage_ship(dest_building_id, item_instance_id, qty)
8807 .await?;
8808 self.state.storage_ui_mode = StorageUiMode::Menu;
8809 }
8810 }
8811 Ok(())
8812 }
8813
8814 pub async fn confirm_bank_menu(&mut self) -> anyhow::Result<()> {
8815 match self.state.bank_ui_mode.clone() {
8816 BankUiMode::Menu => {
8817 let choice = self
8818 .state
8819 .bank_menu_options()
8820 .get(self.state.bank_menu_index)
8821 .copied()
8822 .unwrap_or("Deposit…");
8823 match choice {
8824 "Withdraw…" => {
8825 self.state.bank_ui_mode = BankUiMode::WithdrawAmount {
8826 input: String::new(),
8827 };
8828 }
8829 "Deposit all" => self.bank_deposit(0).await?,
8830 "Withdraw all" => self.bank_withdraw(0).await?,
8831 "Transfer…" => {
8832 self.state.bank_ui_mode = BankUiMode::TransferName {
8833 input: String::new(),
8834 };
8835 }
8836 _ => {
8837 self.state.bank_ui_mode = BankUiMode::DepositAmount {
8838 input: String::new(),
8839 };
8840 }
8841 }
8842 }
8843 BankUiMode::DepositAmount { input } => {
8844 let Some(amount) = parse_bank_copper_amount(&input) else {
8845 self.state
8846 .push_log("Enter a copper amount (blank or 0 = everything on person).");
8847 return Ok(());
8848 };
8849 self.bank_deposit(amount).await?;
8850 self.state.bank_ui_mode = BankUiMode::Menu;
8851 }
8852 BankUiMode::WithdrawAmount { input } => {
8853 let Some(amount) = parse_bank_copper_amount(&input) else {
8854 self.state
8855 .push_log("Enter a copper amount (blank or 0 = full ledger).");
8856 return Ok(());
8857 };
8858 self.bank_withdraw(amount).await?;
8859 self.state.bank_ui_mode = BankUiMode::Menu;
8860 }
8861 BankUiMode::TransferName { input } => {
8862 let name = input.trim().to_string();
8863 if name.is_empty() {
8864 self.state.push_log("Enter the recipient character name.");
8865 return Ok(());
8866 }
8867 self.state.bank_ui_mode = BankUiMode::TransferAmount {
8868 to_name: name,
8869 input: String::new(),
8870 };
8871 }
8872 BankUiMode::TransferAmount { to_name, input } => {
8873 let amount: u64 = match input.trim().parse() {
8874 Ok(v) if v > 0 => v,
8875 _ => {
8876 self.state
8877 .push_log("Enter a positive copper amount to transfer.");
8878 return Ok(());
8879 }
8880 };
8881 self.bank_transfer(None, to_name, amount).await?;
8882 self.state.bank_ui_mode = BankUiMode::Menu;
8883 }
8884 }
8885 Ok(())
8886 }
8887
8888 pub fn bank_transfer_back(&mut self) {
8889 match &self.state.bank_ui_mode {
8890 BankUiMode::TransferAmount { to_name, .. } => {
8891 self.state.bank_ui_mode = BankUiMode::TransferName {
8892 input: to_name.clone(),
8893 };
8894 }
8895 BankUiMode::TransferName { .. }
8896 | BankUiMode::DepositAmount { .. }
8897 | BankUiMode::WithdrawAmount { .. } => {
8898 self.state.bank_ui_mode = BankUiMode::Menu;
8899 }
8900 BankUiMode::Menu => {}
8901 }
8902 }
8903
8904 pub fn bank_transfer_append_char(&mut self, c: char) {
8905 match &mut self.state.bank_ui_mode {
8906 BankUiMode::TransferName { input } => {
8907 if input.len() < 32 && !c.is_control() {
8908 input.push(c);
8909 }
8910 }
8911 BankUiMode::DepositAmount { input }
8912 | BankUiMode::WithdrawAmount { input }
8913 | BankUiMode::TransferAmount { input, .. } => {
8914 if c.is_ascii_digit() && input.len() < 12 {
8915 input.push(c);
8916 }
8917 }
8918 BankUiMode::Menu => {}
8919 }
8920 }
8921
8922 pub fn bank_transfer_backspace(&mut self) {
8923 match &mut self.state.bank_ui_mode {
8924 BankUiMode::TransferName { input }
8925 | BankUiMode::DepositAmount { input }
8926 | BankUiMode::WithdrawAmount { input }
8927 | BankUiMode::TransferAmount { input, .. } => {
8928 input.pop();
8929 }
8930 BankUiMode::Menu => {}
8931 }
8932 }
8933
8934 pub async fn close_bank_panel(&mut self) -> anyhow::Result<()> {
8935 let npc_id = self
8936 .state
8937 .bank_panel
8938 .as_ref()
8939 .map(|p| p.npc_id.clone());
8940 self.state.clear_bank_panel();
8941 if let Some(npc_id) = npc_id {
8942 self.seq += 1;
8943 self.session
8944 .submit_intent(Intent::BankClose {
8945 entity_id: self.state.entity_id,
8946 npc_id,
8947 seq: self.seq,
8948 })
8949 .await?;
8950 self.state.intents_sent += 1;
8951 }
8952 Ok(())
8953 }
8954
8955 pub async fn storage_store(
8956 &mut self,
8957 item_instance_id: uuid::Uuid,
8958 quantity: Option<u32>,
8959 ) -> anyhow::Result<()> {
8960 let Some(panel) = self.state.storage_panel.clone() else {
8961 return Ok(());
8962 };
8963 self.seq += 1;
8964 self.session
8965 .submit_intent(Intent::StorageStore {
8966 entity_id: self.state.entity_id,
8967 npc_id: panel.npc_id,
8968 item_instance_id,
8969 quantity,
8970 seq: self.seq,
8971 })
8972 .await?;
8973 self.state.intents_sent += 1;
8974 Ok(())
8975 }
8976
8977 pub async fn storage_take(
8978 &mut self,
8979 item_instance_id: uuid::Uuid,
8980 quantity: Option<u32>,
8981 ) -> anyhow::Result<()> {
8982 let Some(panel) = self.state.storage_panel.clone() else {
8983 return Ok(());
8984 };
8985 self.seq += 1;
8986 self.session
8987 .submit_intent(Intent::StorageTake {
8988 entity_id: self.state.entity_id,
8989 npc_id: panel.npc_id,
8990 item_instance_id,
8991 quantity,
8992 seq: self.seq,
8993 })
8994 .await?;
8995 self.state.intents_sent += 1;
8996 Ok(())
8997 }
8998
8999 pub async fn storage_ship(
9000 &mut self,
9001 dest_building_id: String,
9002 item_instance_id: uuid::Uuid,
9003 quantity: Option<u32>,
9004 ) -> anyhow::Result<()> {
9005 let Some(panel) = self.state.storage_panel.clone() else {
9006 return Ok(());
9007 };
9008 self.seq += 1;
9009 self.session
9010 .submit_intent(Intent::StorageShip {
9011 entity_id: self.state.entity_id,
9012 npc_id: panel.npc_id,
9013 dest_building_id,
9014 item_instance_id,
9015 quantity,
9016 seq: self.seq,
9017 })
9018 .await?;
9019 self.state.intents_sent += 1;
9020 Ok(())
9021 }
9022
9023 pub async fn close_storage_panel(&mut self) -> anyhow::Result<()> {
9024 let npc_id = self
9025 .state
9026 .storage_panel
9027 .as_ref()
9028 .map(|p| p.npc_id.clone());
9029 self.state.clear_storage_panel();
9030 if let Some(npc_id) = npc_id {
9031 self.seq += 1;
9032 self.session
9033 .submit_intent(Intent::StorageClose {
9034 entity_id: self.state.entity_id,
9035 npc_id,
9036 seq: self.seq,
9037 })
9038 .await?;
9039 self.state.intents_sent += 1;
9040 }
9041 Ok(())
9042 }
9043
9044 pub async fn close_market_panel(&mut self) -> anyhow::Result<()> {
9045 let npc_id = self
9046 .state
9047 .market_panel
9048 .as_ref()
9049 .map(|p| p.npc_id.clone());
9050 self.state.clear_market_panel();
9051 if let Some(npc_id) = npc_id {
9052 self.seq += 1;
9053 self.session
9054 .submit_intent(Intent::MarketClose {
9055 entity_id: self.state.entity_id,
9056 npc_id,
9057 seq: self.seq,
9058 })
9059 .await?;
9060 self.state.intents_sent += 1;
9061 }
9062 Ok(())
9063 }
9064
9065 pub fn market_move_selection(&mut self, delta: i32) {
9066 let indices = self.state.market_filtered_listing_indices();
9067 let n = indices.len();
9068 if n == 0 {
9069 self.state.market_menu_index = 0;
9070 return;
9071 }
9072 let cur = self.state.market_menu_index as i32;
9073 self.state.market_menu_index = (cur + delta).rem_euclid(n as i32) as usize;
9074 }
9075
9076 pub fn market_page_selection(&mut self, pages: i32) {
9077 let indices = self.state.market_filtered_listing_indices();
9078 let n = indices.len();
9079 if n == 0 {
9080 self.state.market_menu_index = 0;
9081 return;
9082 }
9083 self.state.market_menu_index = page_list_index(self.state.market_menu_index, pages, n);
9084 }
9085
9086 pub fn market_list_page(&mut self, pages: i32) {
9087 match &self.state.market_ui_mode {
9088 MarketUiMode::ListSource { index } => {
9089 let n = self.state.market_list_source_options().len();
9090 if n == 0 {
9091 return;
9092 }
9093 let next = page_list_index(*index, pages, n);
9094 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
9095 }
9096 MarketUiMode::ListPricingMode { index, .. } => {
9097 let next = page_list_index(*index, pages, 2);
9098 if let MarketUiMode::ListPricingMode { index, .. } =
9099 &mut self.state.market_ui_mode
9100 {
9101 *index = next;
9102 }
9103 }
9104 MarketUiMode::ListPick { source, index } => {
9105 let opts = self.state.market_list_item_options(source);
9106 let n = opts.len();
9107 if n == 0 {
9108 return;
9109 }
9110 let next = page_list_index(*index, pages, n);
9111 self.state.market_ui_mode = MarketUiMode::ListPick {
9112 source: source.clone(),
9113 index: next,
9114 };
9115 }
9116 _ => {}
9117 }
9118 }
9119
9120 pub fn market_cycle_category(&mut self, delta: i32) {
9121 let groups = self.state.market_available_category_groups();
9122 let mut labels: Vec<Option<&'static str>> = vec![None];
9124 labels.extend(groups.into_iter().map(Some));
9125 let n = labels.len() as i32;
9126 let cur = labels
9127 .iter()
9128 .position(|g| *g == self.state.market_category_filter)
9129 .unwrap_or(0) as i32;
9130 let next = (cur + delta).rem_euclid(n) as usize;
9131 self.state.market_category_filter = labels[next];
9132 self.state.market_menu_index = 0;
9133 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9134 let source = source.clone();
9135 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9136 }
9137 }
9138
9139 pub fn focus_market_filter(&mut self) {
9140 self.state.market_filter_focused = true;
9141 }
9142
9143 pub fn append_market_filter_char(&mut self, ch: char) {
9144 if !self.state.market_filter_focused {
9145 return;
9146 }
9147 if ch.is_control() {
9148 return;
9149 }
9150 if self.state.market_filter.len() < 48 {
9151 self.state.market_filter.push(ch);
9152 self.state.market_menu_index = 0;
9153 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9154 let source = source.clone();
9155 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9156 }
9157 }
9158 }
9159
9160 pub fn market_filter_backspace(&mut self) {
9161 if !self.state.market_filter_focused {
9162 return;
9163 }
9164 self.state.market_filter.pop();
9165 self.state.market_menu_index = 0;
9166 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9167 let source = source.clone();
9168 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9169 }
9170 }
9171
9172 pub fn clear_or_blur_market_filter(&mut self) -> bool {
9174 if self.state.market_filter_focused {
9175 if !self.state.market_filter.is_empty() {
9176 self.state.market_filter.clear();
9177 self.state.market_menu_index = 0;
9178 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9179 let source = source.clone();
9180 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9181 }
9182 return true;
9183 }
9184 self.state.market_filter_focused = false;
9185 return true;
9186 }
9187 if !self.state.market_filter.is_empty() {
9188 self.state.market_filter.clear();
9189 self.state.market_menu_index = 0;
9190 if let MarketUiMode::ListPick { source, .. } = &self.state.market_ui_mode {
9191 let source = source.clone();
9192 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9193 }
9194 return true;
9195 }
9196 false
9197 }
9198
9199 pub async fn market_activate_selection(&mut self) -> anyhow::Result<()> {
9200 if let Some((listing_id, qty, _unit, _total, _)) = self.state.market_buy_confirm.clone() {
9201 return self.market_confirm_buy(listing_id, qty).await;
9202 }
9203 let Some(panel) = self.state.market_panel.clone() else {
9204 return Ok(());
9205 };
9206 let indices = self.state.market_filtered_listing_indices();
9207 let Some(&raw_idx) = indices.get(self.state.market_menu_index) else {
9208 return Ok(());
9209 };
9210 let Some(listing) = panel.listings.get(raw_idx) else {
9211 return Ok(());
9212 };
9213 if listing.mine {
9214 self.seq += 1;
9215 self.session
9216 .submit_intent(Intent::MarketDelist {
9217 entity_id: self.state.entity_id,
9218 npc_id: panel.npc_id.clone(),
9219 listing_id: listing.listing_id,
9220 dest: flatland_protocol::GoodsLocation::Person,
9221 seq: self.seq,
9222 })
9223 .await?;
9224 self.state.intents_sent += 1;
9225 return Ok(());
9226 }
9227 if listing.npc_price {
9228 self.state
9229 .push_log("NPC-price listings are bought by merchants only.");
9230 return Ok(());
9231 }
9232 let qty = 1u32.min(listing.quantity).max(1);
9233 let line = listing.unit_price_copper.saturating_mul(qty as u64);
9234 self.state.market_buy_confirm = Some((
9235 listing.listing_id,
9236 qty,
9237 listing.unit_price_copper,
9238 line,
9239 listing.display_name.clone(),
9240 ));
9241 Ok(())
9242 }
9243
9244 pub async fn market_confirm_buy(
9245 &mut self,
9246 listing_id: uuid::Uuid,
9247 quantity: u32,
9248 ) -> anyhow::Result<()> {
9249 let Some(panel) = self.state.market_panel.clone() else {
9250 self.state.market_buy_confirm = None;
9251 return Ok(());
9252 };
9253 self.state.market_buy_confirm = None;
9254 self.seq += 1;
9255 self.session
9256 .submit_intent(Intent::MarketBuy {
9257 entity_id: self.state.entity_id,
9258 npc_id: panel.npc_id,
9259 listing_id,
9260 quantity,
9261 dest: flatland_protocol::GoodsLocation::Person,
9262 seq: self.seq,
9263 })
9264 .await?;
9265 self.state.intents_sent += 1;
9266 Ok(())
9267 }
9268
9269 pub fn market_begin_list(&mut self) {
9271 if self.state.market_panel.is_none() {
9272 return;
9273 }
9274 let sources = self.state.market_list_source_options();
9275 if sources.is_empty() {
9276 self.state.push_log("Nothing to list from.");
9277 return;
9278 }
9279 if sources.len() == 1 {
9281 let (source, _) = sources[0].clone();
9282 let opts = self.state.market_list_item_options(&source);
9283 if opts.is_empty() {
9284 self.state.push_log("Nothing loose to list.");
9285 return;
9286 }
9287 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9288 self.state.market_buy_confirm = None;
9289 return;
9290 }
9291 self.state.market_buy_confirm = None;
9292 self.state.market_ui_mode = MarketUiMode::ListSource { index: 0 };
9293 }
9294
9295 pub fn market_ui_back(&mut self) {
9296 self.state.market_ui_mode = match self.state.market_ui_mode.clone() {
9297 MarketUiMode::Browse => MarketUiMode::Browse,
9298 MarketUiMode::ListSource { .. } => MarketUiMode::Browse,
9299 MarketUiMode::ListPick { .. } => {
9300 if self.state.market_list_source_options().len() <= 1 {
9301 MarketUiMode::Browse
9302 } else {
9303 MarketUiMode::ListSource { index: 0 }
9304 }
9305 }
9306 MarketUiMode::ListAmount {
9307 source,
9308 pick_index,
9309 ..
9310 } => MarketUiMode::ListPick {
9311 source,
9312 index: pick_index,
9313 },
9314 MarketUiMode::ListPricingMode {
9315 source,
9316 item_instance_id,
9317 label,
9318 max_qty,
9319 quantity,
9320 ..
9321 } => {
9322 let input = quantity.map(|q| q.to_string()).unwrap_or_default();
9323 MarketUiMode::ListAmount {
9324 source,
9325 pick_index: 0,
9326 item_instance_id,
9327 label,
9328 max_qty,
9329 input,
9330 }
9331 }
9332 MarketUiMode::ListPrice {
9333 source,
9334 item_instance_id,
9335 label,
9336 max_qty,
9337 quantity,
9338 ..
9339 } => MarketUiMode::ListPricingMode {
9340 source,
9341 item_instance_id,
9342 label,
9343 quantity,
9344 max_qty,
9345 index: 1,
9346 },
9347 };
9348 }
9349
9350 pub fn market_list_move(&mut self, delta: i32) {
9351 match &self.state.market_ui_mode {
9352 MarketUiMode::ListSource { index } => {
9353 let n = self.state.market_list_source_options().len();
9354 if n == 0 {
9355 return;
9356 }
9357 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9358 self.state.market_ui_mode = MarketUiMode::ListSource { index: next };
9359 }
9360 MarketUiMode::ListPricingMode { index, .. } => {
9361 let next = (*index as i32 + delta).rem_euclid(2) as usize;
9362 if let MarketUiMode::ListPricingMode { index, .. } =
9363 &mut self.state.market_ui_mode
9364 {
9365 *index = next;
9366 }
9367 }
9368 MarketUiMode::ListPick { source, index } => {
9369 let opts = self.state.market_list_item_options(source);
9370 let n = opts.len();
9371 if n == 0 {
9372 return;
9373 }
9374 let next = (*index as i32 + delta).rem_euclid(n as i32) as usize;
9375 self.state.market_ui_mode = MarketUiMode::ListPick {
9376 source: source.clone(),
9377 index: next,
9378 };
9379 }
9380 _ => {}
9381 }
9382 }
9383
9384 pub fn market_list_amount_append_char(&mut self, c: char) {
9385 if !c.is_ascii_digit() {
9386 return;
9387 }
9388 match &mut self.state.market_ui_mode {
9389 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
9390 if input.len() < 12 {
9391 input.push(c);
9392 }
9393 }
9394 _ => {}
9395 }
9396 }
9397
9398 pub fn market_list_amount_backspace(&mut self) {
9399 match &mut self.state.market_ui_mode {
9400 MarketUiMode::ListAmount { input, .. } | MarketUiMode::ListPrice { input, .. } => {
9401 input.pop();
9402 }
9403 _ => {}
9404 }
9405 }
9406
9407 pub async fn confirm_market_list_step(&mut self) -> anyhow::Result<()> {
9408 match self.state.market_ui_mode.clone() {
9409 MarketUiMode::Browse => Ok(()),
9410 MarketUiMode::ListSource { index } => {
9411 let sources = self.state.market_list_source_options();
9412 let Some((source, _)) = sources.get(index).cloned() else {
9413 return Ok(());
9414 };
9415 let opts = self.state.market_list_item_options(&source);
9416 if opts.is_empty() {
9417 self.state.push_log("Nothing to list from that source.");
9418 return Ok(());
9419 }
9420 self.state.market_ui_mode = MarketUiMode::ListPick { source, index: 0 };
9421 Ok(())
9422 }
9423 MarketUiMode::ListPick { source, index } => {
9424 let opts = self.state.market_list_item_options(&source);
9425 let Some(opt) = opts.get(index) else {
9426 self.state.push_log("Nothing to list.");
9427 self.state.market_ui_mode = MarketUiMode::Browse;
9428 return Ok(());
9429 };
9430 self.state.market_ui_mode = MarketUiMode::ListAmount {
9431 source,
9432 pick_index: index,
9433 item_instance_id: opt.item_instance_id,
9434 label: opt.label.clone(),
9435 max_qty: opt.quantity.max(1),
9436 input: String::new(),
9437 };
9438 Ok(())
9439 }
9440 MarketUiMode::ListAmount {
9441 source,
9442 item_instance_id,
9443 label,
9444 max_qty,
9445 input,
9446 ..
9447 } => {
9448 let Some(qty_opt) = parse_storage_quantity(&input) else {
9449 self.state.push_log("Enter a quantity (blank = all).");
9450 return Ok(());
9451 };
9452 if let Some(q) = qty_opt {
9453 if q > max_qty {
9454 self.state
9455 .push_log(format!("Only {max_qty} available."));
9456 return Ok(());
9457 }
9458 }
9459 self.state.market_ui_mode = MarketUiMode::ListPricingMode {
9460 source,
9461 item_instance_id,
9462 label,
9463 quantity: qty_opt,
9464 max_qty,
9465 index: 0,
9466 };
9467 Ok(())
9468 }
9469 MarketUiMode::ListPricingMode {
9470 source,
9471 item_instance_id,
9472 label,
9473 quantity,
9474 max_qty,
9475 index,
9476 } => {
9477 if index == 0 {
9478 return self
9479 .submit_market_list_intent(
9480 source,
9481 item_instance_id,
9482 quantity,
9483 0,
9484 true,
9485 &label,
9486 )
9487 .await;
9488 }
9489 self.state.market_ui_mode = MarketUiMode::ListPrice {
9490 source,
9491 item_instance_id,
9492 label,
9493 quantity,
9494 max_qty,
9495 input: String::new(),
9496 };
9497 Ok(())
9498 }
9499 MarketUiMode::ListPrice {
9500 source,
9501 item_instance_id,
9502 label,
9503 quantity,
9504 input,
9505 ..
9506 } => {
9507 let price = input.trim().parse::<u64>().unwrap_or(0);
9508 if price == 0 {
9509 self.state.push_log("Enter a unit price of at least 1 copper.");
9510 return Ok(());
9511 }
9512 self.submit_market_list_intent(
9513 source,
9514 item_instance_id,
9515 quantity,
9516 price,
9517 false,
9518 &label,
9519 )
9520 .await
9521 }
9522 }
9523 }
9524
9525 async fn submit_market_list_intent(
9526 &mut self,
9527 source: MarketListSourceKind,
9528 item_instance_id: uuid::Uuid,
9529 quantity: Option<u32>,
9530 unit_price_copper: u64,
9531 npc_price: bool,
9532 label: &str,
9533 ) -> anyhow::Result<()> {
9534 let Some(panel) = self.state.market_panel.clone() else {
9535 self.state.market_ui_mode = MarketUiMode::Browse;
9536 return Ok(());
9537 };
9538 let goods = match source {
9539 MarketListSourceKind::Person => flatland_protocol::GoodsLocation::Person,
9540 MarketListSourceKind::TownStorage { building_id } => {
9541 flatland_protocol::GoodsLocation::TownStorage { building_id }
9542 }
9543 };
9544 self.seq += 1;
9545 self.session
9546 .submit_intent(Intent::MarketList {
9547 entity_id: self.state.entity_id,
9548 npc_id: panel.npc_id,
9549 source: goods,
9550 item_instance_id,
9551 quantity,
9552 unit_price_copper,
9553 npc_price,
9554 seq: self.seq,
9555 })
9556 .await?;
9557 self.state.intents_sent += 1;
9558 if npc_price {
9559 self.state.push_log(format!("Listing {label} at NPC price…"));
9560 } else {
9561 self.state
9562 .push_log(format!("Listing {label} @ {unit_price_copper} cp…"));
9563 }
9564 self.state.market_ui_mode = MarketUiMode::Browse;
9565 Ok(())
9566 }
9567
9568 pub async fn back_from_shop_menu(&mut self) -> anyhow::Result<()> {
9570 let return_to_verbs = self.state.npc_verb_target.is_some();
9571 self.close_shop_menu().await?;
9572 if return_to_verbs {
9573 self.state.show_npc_verb_menu = true;
9574 }
9575 Ok(())
9576 }
9577
9578 pub fn shop_tab_toggle(&mut self) {
9579 self.state.shop_tab = match self.state.shop_tab {
9580 ShopTab::Buy => ShopTab::Sell,
9581 ShopTab::Sell => ShopTab::Buy,
9582 };
9583 self.state.shop_menu_index = 0;
9584 if self.state.shop_tab == ShopTab::Sell {
9585 self.state.shop_quantity_set_max();
9586 }
9587 self.state.clamp_shop_selection();
9588 }
9589
9590 pub fn shop_menu_move(&mut self, delta: i32) {
9591 self.state.shop_menu_move(delta);
9592 }
9593
9594 pub fn shop_quantity_adjust(&mut self, delta: i32) {
9595 self.state.shop_quantity_adjust(delta);
9596 }
9597
9598 pub fn shop_quantity_set_max(&mut self) {
9599 self.state.shop_quantity_set_max();
9600 }
9601
9602 pub fn shop_quantity_set_min(&mut self) {
9603 self.state.shop_quantity_set_min();
9604 }
9605
9606 pub fn toggle_quest_menu(&mut self) {
9607 self.state.show_quest_menu = !self.state.show_quest_menu;
9608 if self.state.show_quest_menu {
9609 self.state.quest_menu_index = 0;
9610 self.state.quest_withdraw_confirm = false;
9611 self.state.show_workers_menu = false;
9612 }
9613 }
9614
9615 pub fn toggle_workers_menu(&mut self) {
9616 if self.state.show_workers_menu {
9617 self.close_workers_menu_ui();
9618 } else {
9619 self.state.show_workers_menu = true;
9620 self.state.workers_menu_index = 0;
9621 self.state.show_quest_menu = false;
9622 self.close_worker_give_picker();
9623 self.close_worker_give_target_picker();
9624 self.close_worker_take_picker();
9625 self.close_worker_teach_picker();
9626 self.cancel_worker_rename();
9627 }
9628 }
9629
9630 pub fn close_workers_menu_ui(&mut self) {
9632 self.state.show_workers_menu = false;
9633 self.close_worker_give_picker();
9634 self.close_worker_give_target_picker();
9635 self.close_worker_take_picker();
9636 self.close_worker_teach_picker();
9637 self.cancel_worker_rename();
9638 }
9639
9640 pub async fn open_workers_menu_for(&mut self, instance_id: &str) -> anyhow::Result<()> {
9642 let Some(idx) = self
9643 .state
9644 .hired_workers
9645 .iter()
9646 .position(|w| w.instance_id == instance_id)
9647 else {
9648 anyhow::bail!("worker not found");
9649 };
9650 let label = self.state.hired_workers[idx].label.clone();
9651 self.state.show_workers_menu = true;
9652 self.state.workers_menu_index = idx;
9653 self.state.show_quest_menu = false;
9654 self.close_worker_give_picker();
9655 self.close_worker_give_target_picker();
9656 self.close_worker_take_picker();
9657 self.close_worker_teach_picker();
9658 self.cancel_worker_rename();
9659 self.set_worker_attending(instance_id, true).await?;
9660 self.state
9661 .push_log(format!("Managing {label} — job paused while menu is open"));
9662 Ok(())
9663 }
9664
9665 pub async fn close_workers_menu(&mut self) -> anyhow::Result<()> {
9667 self.close_workers_menu_ui();
9668 self.release_worker_attend().await
9669 }
9670
9671 async fn set_worker_attending(
9672 &mut self,
9673 instance_id: &str,
9674 attending: bool,
9675 ) -> anyhow::Result<()> {
9676 if attending {
9677 if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9678 return Ok(());
9679 }
9680 if let Some(prev) = self.state.attending_worker_instance_id.clone() {
9682 if prev != instance_id {
9683 self.send_attend_hired_worker(&prev, false).await?;
9684 }
9685 }
9686 self.send_attend_hired_worker(instance_id, true).await?;
9687 self.state.attending_worker_instance_id = Some(instance_id.to_string());
9688 } else if self.state.attending_worker_instance_id.as_deref() == Some(instance_id) {
9689 self.send_attend_hired_worker(instance_id, false).await?;
9690 self.state.attending_worker_instance_id = None;
9691 }
9692 Ok(())
9693 }
9694
9695 pub async fn release_worker_attend(&mut self) -> anyhow::Result<()> {
9696 let Some(id) = self.state.attending_worker_instance_id.take() else {
9697 return Ok(());
9698 };
9699 self.send_attend_hired_worker(&id, false).await
9700 }
9701
9702 async fn send_attend_hired_worker(
9703 &mut self,
9704 worker_instance_id: &str,
9705 attending: bool,
9706 ) -> anyhow::Result<()> {
9707 self.seq += 1;
9708 self.session
9709 .submit_intent(Intent::AttendHiredWorker {
9710 entity_id: self.state.entity_id,
9711 worker_instance_id: worker_instance_id.to_string(),
9712 attending,
9713 seq: self.seq,
9714 })
9715 .await?;
9716 self.state.intents_sent += 1;
9717 Ok(())
9718 }
9719
9720 pub fn workers_menu_move(&mut self, delta: i32) {
9721 let n = self.state.hired_workers.len();
9722 if n == 0 {
9723 return;
9724 }
9725 let idx = self.state.workers_menu_index as i32;
9726 self.state.workers_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
9727 }
9728
9729 pub fn toggle_workers_menu_compact(&mut self) {
9730 self.state.workers_menu_compact = !self.state.workers_menu_compact;
9731 let mut cfg = crate::client_config::ClientConfig::load();
9732 let _ = cfg.save_workers_menu_compact(self.state.workers_menu_compact);
9733 }
9734
9735 pub async fn workers_dismiss_selected(&mut self) -> anyhow::Result<()> {
9736 let Some(worker) = self
9737 .state
9738 .hired_workers
9739 .get(self.state.workers_menu_index)
9740 .cloned()
9741 else {
9742 anyhow::bail!("no worker selected");
9743 };
9744 self.seq += 1;
9745 self.session
9746 .submit_intent(Intent::DismissWorker {
9747 entity_id: self.state.entity_id,
9748 worker_instance_id: worker.instance_id.clone(),
9749 seq: self.seq,
9750 })
9751 .await?;
9752 self.state.intents_sent += 1;
9753 self.state
9754 .hired_workers
9755 .retain(|w| w.instance_id != worker.instance_id);
9756 if self.state.workers_menu_index >= self.state.hired_workers.len() {
9757 self.state.workers_menu_index = self
9758 .state
9759 .hired_workers
9760 .len()
9761 .saturating_sub(1);
9762 }
9763 self.state.push_log(format!("Dismissed {}", worker.label));
9764 Ok(())
9765 }
9766
9767 pub async fn workers_toggle_mode_selected(&mut self) -> anyhow::Result<()> {
9768 let Some(worker) = self
9769 .state
9770 .hired_workers
9771 .get(self.state.workers_menu_index)
9772 .cloned()
9773 else {
9774 anyhow::bail!("no worker selected");
9775 };
9776 let mode = match worker.mode {
9777 flatland_protocol::WorkerModeView::Companion => "job_loop",
9778 flatland_protocol::WorkerModeView::JobLoop => "idle",
9779 flatland_protocol::WorkerModeView::Idle => "companion",
9780 };
9781 self.seq += 1;
9782 self.session
9783 .submit_intent(Intent::SetWorkerMode {
9784 entity_id: self.state.entity_id,
9785 worker_instance_id: worker.instance_id,
9786 mode: mode.into(),
9787 seq: self.seq,
9788 })
9789 .await?;
9790 self.state.intents_sent += 1;
9791 Ok(())
9792 }
9793
9794 pub async fn workers_confirm_action(&mut self) -> anyhow::Result<()> {
9795 if self.state.hired_workers.is_empty() {
9796 return self.hire_worker_laborer().await;
9797 }
9798 self.workers_toggle_mode_selected().await
9799 }
9800
9801 pub fn open_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
9804 let row = self
9805 .state
9806 .inventory_selected_row()
9807 .ok_or_else(|| anyhow::anyhow!("inventory empty"))?
9808 .clone();
9809 if row.from != flatland_protocol::InventoryLocation::Root {
9810 anyhow::bail!("select a carried item to give");
9811 }
9812 let Some(instance_id) = row.stack.item_instance_id else {
9813 anyhow::bail!("that stack can't be given");
9814 };
9815 let options = self.nearby_worker_give_targets();
9816 if options.is_empty() {
9817 anyhow::bail!(
9818 "no hired workers within {WORKER_GIVE_RANGE_M:.0} m — stand next to them"
9819 );
9820 }
9821 let item_label = row
9822 .stack
9823 .display_name
9824 .as_deref()
9825 .unwrap_or(&row.stack.template_id)
9826 .to_string();
9827 self.state.worker_give_target_picker = Some(WorkerGiveTargetPicker {
9828 item_instance_id: instance_id,
9829 item_label,
9830 quantity: None,
9831 options,
9832 });
9833 self.state.worker_give_target_picker_index = 0;
9834 self.state.show_worker_give_target_picker = true;
9835 self.state.show_inventory_menu = false;
9837 Ok(())
9838 }
9839
9840 pub fn nearby_worker_give_targets(&self) -> Vec<WorkerGiveTargetOption> {
9842 let (px, py, _) = self.state.player_position_with_z();
9843 let mut options: Vec<WorkerGiveTargetOption> = self
9844 .state
9845 .hired_workers
9846 .iter()
9847 .filter_map(|w| {
9848 let dist = ((w.x - px).powi(2) + (w.y - py).powi(2)).sqrt();
9849 if dist > WORKER_GIVE_RANGE_M {
9850 return None;
9851 }
9852 Some(WorkerGiveTargetOption {
9853 instance_id: w.instance_id.clone(),
9854 label: w.label.clone(),
9855 distance_m: dist,
9856 })
9857 })
9858 .collect();
9859 options.sort_by(|a, b| {
9860 a.distance_m
9861 .partial_cmp(&b.distance_m)
9862 .unwrap_or(std::cmp::Ordering::Equal)
9863 });
9864 options
9865 }
9866
9867 pub fn close_worker_give_target_picker(&mut self) {
9868 self.state.show_worker_give_target_picker = false;
9869 self.state.worker_give_target_picker = None;
9870 self.state.worker_give_target_picker_index = 0;
9871 }
9872
9873 pub fn worker_give_target_picker_move(&mut self, delta: i32) {
9874 let Some(picker) = &self.state.worker_give_target_picker else {
9875 return;
9876 };
9877 let n = picker.options.len();
9878 if n == 0 {
9879 return;
9880 }
9881 let idx = self.state.worker_give_target_picker_index as i32;
9882 self.state.worker_give_target_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9883 }
9884
9885 pub async fn confirm_worker_give_target_picker(&mut self) -> anyhow::Result<()> {
9886 let Some(picker) = self.state.worker_give_target_picker.clone() else {
9887 anyhow::bail!("give target picker not open");
9888 };
9889 let Some(opt) = picker
9890 .options
9891 .get(self.state.worker_give_target_picker_index)
9892 .cloned()
9893 else {
9894 anyhow::bail!("no worker selected");
9895 };
9896 let Some(worker) = self
9897 .state
9898 .hired_workers
9899 .iter()
9900 .find(|w| w.instance_id == opt.instance_id)
9901 .cloned()
9902 else {
9903 self.close_worker_give_target_picker();
9904 anyhow::bail!("worker no longer hired");
9905 };
9906 self.give_item_to_worker(
9907 &worker.instance_id,
9908 &worker.label,
9909 worker.x,
9910 worker.y,
9911 picker.item_instance_id,
9912 &picker.item_label,
9913 picker.quantity,
9914 )
9915 .await?;
9916 self.close_worker_give_target_picker();
9917 Ok(())
9918 }
9919
9920 pub async fn give_selected_inventory_to_worker(&mut self) -> anyhow::Result<()> {
9922 self.open_worker_give_target_picker()
9923 }
9924
9925 pub fn open_worker_give_picker(&mut self) -> anyhow::Result<()> {
9927 let Some(worker) = self
9928 .state
9929 .hired_workers
9930 .get(self.state.workers_menu_index)
9931 .cloned()
9932 else {
9933 anyhow::bail!("select a hired worker first");
9934 };
9935 let (px, py, _) = self.state.player_position_with_z();
9936 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
9937 if dist > WORKER_GIVE_RANGE_M {
9938 anyhow::bail!(
9939 "stand next to {} to give items (within {WORKER_GIVE_RANGE_M:.0} m)",
9940 worker.label
9941 );
9942 }
9943 let options = self.state.giveable_inventory_options();
9944 if options.is_empty() {
9945 anyhow::bail!("nothing in inventory to give");
9946 }
9947 self.state.worker_give_picker = Some(WorkerGivePicker {
9948 worker_instance_id: worker.instance_id,
9949 worker_label: worker.label,
9950 options,
9951 });
9952 self.state.worker_give_picker_index = 0;
9953 self.state.show_worker_give_picker = true;
9954 Ok(())
9955 }
9956
9957 pub fn close_worker_give_picker(&mut self) {
9958 self.state.show_worker_give_picker = false;
9959 self.state.worker_give_picker = None;
9960 self.state.worker_give_picker_index = 0;
9961 }
9962
9963 pub fn worker_give_picker_move(&mut self, delta: i32) {
9964 let Some(picker) = &self.state.worker_give_picker else {
9965 return;
9966 };
9967 let n = picker.options.len();
9968 if n == 0 {
9969 return;
9970 }
9971 let idx = self.state.worker_give_picker_index as i32;
9972 self.state.worker_give_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
9973 }
9974
9975 pub async fn confirm_worker_give_picker(&mut self) -> anyhow::Result<()> {
9977 let Some(picker) = self.state.worker_give_picker.clone() else {
9978 anyhow::bail!("give picker not open");
9979 };
9980 let Some(opt) = picker.options.get(self.state.worker_give_picker_index).cloned() else {
9981 anyhow::bail!("no item selected");
9982 };
9983 let Some(worker) = self
9984 .state
9985 .hired_workers
9986 .iter()
9987 .find(|w| w.instance_id == picker.worker_instance_id)
9988 .cloned()
9989 else {
9990 self.close_worker_give_picker();
9991 anyhow::bail!("worker no longer hired");
9992 };
9993 self.give_item_to_worker(
9994 &worker.instance_id,
9995 &worker.label,
9996 worker.x,
9997 worker.y,
9998 opt.item_instance_id,
9999 &opt.label,
10000 None,
10001 )
10002 .await?;
10003 let options = self.state.giveable_inventory_options();
10005 if options.is_empty() {
10006 self.close_worker_give_picker();
10007 } else {
10008 self.state.worker_give_picker = Some(WorkerGivePicker {
10009 worker_instance_id: picker.worker_instance_id,
10010 worker_label: picker.worker_label,
10011 options,
10012 });
10013 if self.state.worker_give_picker_index
10014 >= self
10015 .state
10016 .worker_give_picker
10017 .as_ref()
10018 .map(|p| p.options.len())
10019 .unwrap_or(0)
10020 {
10021 self.state.worker_give_picker_index = self
10022 .state
10023 .worker_give_picker
10024 .as_ref()
10025 .map(|p| p.options.len().saturating_sub(1))
10026 .unwrap_or(0);
10027 }
10028 }
10029 Ok(())
10030 }
10031
10032 pub fn open_worker_teach_picker(&mut self) -> anyhow::Result<()> {
10034 let Some(worker) = self
10035 .state
10036 .hired_workers
10037 .get(self.state.workers_menu_index)
10038 .cloned()
10039 else {
10040 anyhow::bail!("select a hired worker first");
10041 };
10042 let (px, py, _) = self.state.player_position_with_z();
10043 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10044 if dist > WORKER_GIVE_RANGE_M {
10045 anyhow::bail!(
10046 "stand next to {} to teach recipes (within {WORKER_GIVE_RANGE_M:.0} m)",
10047 worker.label
10048 );
10049 }
10050 let options = self.state.teachable_blueprint_options(&worker);
10051 if options.is_empty() {
10052 anyhow::bail!("no recipes you know that {} still needs", worker.label);
10053 }
10054 self.state.worker_teach_picker = Some(WorkerTeachPicker {
10055 worker_instance_id: worker.instance_id,
10056 worker_label: worker.label,
10057 worker_level: worker.level,
10058 options,
10059 });
10060 self.state.worker_teach_picker_index = 0;
10061 self.state.show_worker_teach_picker = true;
10062 Ok(())
10063 }
10064
10065 pub fn close_worker_teach_picker(&mut self) {
10066 self.state.show_worker_teach_picker = false;
10067 self.state.worker_teach_picker = None;
10068 self.state.worker_teach_picker_index = 0;
10069 }
10070
10071 pub fn worker_teach_picker_move(&mut self, delta: i32) {
10072 let Some(picker) = &self.state.worker_teach_picker else {
10073 return;
10074 };
10075 let n = picker.options.len();
10076 if n == 0 {
10077 return;
10078 }
10079 let idx = self.state.worker_teach_picker_index as i32;
10080 self.state.worker_teach_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10081 }
10082
10083 pub async fn confirm_worker_teach_picker(&mut self) -> anyhow::Result<()> {
10084 let Some(picker) = self.state.worker_teach_picker.clone() else {
10085 anyhow::bail!("teach picker not open");
10086 };
10087 let Some(opt) = picker.options.get(self.state.worker_teach_picker_index).cloned() else {
10088 anyhow::bail!("nothing selected");
10089 };
10090 if !opt.level_ok {
10091 anyhow::bail!(
10092 "{} needs level {} (is level {})",
10093 picker.worker_label,
10094 opt.min_level,
10095 opt.worker_level
10096 );
10097 }
10098 if !opt.can_afford {
10099 anyhow::bail!("need {} copper to teach {}", opt.cost_copper, opt.label);
10100 }
10101 let Some(worker) = self
10102 .state
10103 .hired_workers
10104 .iter()
10105 .find(|w| w.instance_id == picker.worker_instance_id)
10106 .cloned()
10107 else {
10108 anyhow::bail!("worker gone");
10109 };
10110 let (px, py, _) = self.state.player_position_with_z();
10111 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10112 if dist > WORKER_GIVE_RANGE_M {
10113 anyhow::bail!("worker {} too far — stand next to them", worker.label);
10114 }
10115 self.seq += 1;
10116 self.session
10117 .submit_intent(Intent::TeachWorkerBlueprint {
10118 entity_id: self.state.entity_id,
10119 worker_instance_id: picker.worker_instance_id.clone(),
10120 blueprint_id: opt.blueprint_id.clone(),
10121 seq: self.seq,
10122 })
10123 .await?;
10124 self.state.intents_sent += 1;
10125 self.state.push_log(format!(
10126 "Teaching {} to {} ({} cp)",
10127 opt.label, picker.worker_label, opt.cost_copper
10128 ));
10129 self.close_worker_teach_picker();
10130 Ok(())
10131 }
10132
10133 async fn give_item_to_worker(
10134 &mut self,
10135 worker_instance_id: &str,
10136 worker_label: &str,
10137 worker_x: f32,
10138 worker_y: f32,
10139 item_instance_id: uuid::Uuid,
10140 item_label: &str,
10141 quantity: Option<u32>,
10142 ) -> anyhow::Result<()> {
10143 let (px, py, _) = self.state.player_position_with_z();
10144 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
10145 if dist > WORKER_GIVE_RANGE_M {
10146 anyhow::bail!("worker {worker_label} too far — stand next to them");
10147 }
10148 self.seq += 1;
10149 self.session
10150 .submit_intent(Intent::GiveWorkerItem {
10151 entity_id: self.state.entity_id,
10152 worker_instance_id: worker_instance_id.to_string(),
10153 item_instance_id,
10154 quantity,
10155 seq: self.seq,
10156 })
10157 .await?;
10158 self.state.intents_sent += 1;
10159 self.state
10160 .push_log(format!("Gave {item_label} to {worker_label}"));
10161 Ok(())
10162 }
10163
10164 pub fn open_worker_take_picker(&mut self) -> anyhow::Result<()> {
10166 let Some(worker) = self
10167 .state
10168 .hired_workers
10169 .get(self.state.workers_menu_index)
10170 .cloned()
10171 else {
10172 anyhow::bail!("select a hired worker first");
10173 };
10174 let (px, py, _) = self.state.player_position_with_z();
10175 let dist = ((worker.x - px).powi(2) + (worker.y - py).powi(2)).sqrt();
10176 if dist > WORKER_GIVE_RANGE_M {
10177 anyhow::bail!(
10178 "stand next to {} to take items (within {WORKER_GIVE_RANGE_M:.0} m)",
10179 worker.label
10180 );
10181 }
10182 let options = Self::worker_inventory_options(&worker);
10183 if options.is_empty() {
10184 anyhow::bail!("{} isn't carrying anything", worker.label);
10185 }
10186 let initial_qty = options
10187 .first()
10188 .map(|o| if o.quantity > 1 { 1 } else { o.quantity.max(1) })
10189 .unwrap_or(1);
10190 self.state.worker_take_picker = Some(WorkerTakePicker {
10191 worker_instance_id: worker.instance_id,
10192 worker_label: worker.label,
10193 options,
10194 quantity: initial_qty,
10195 });
10196 self.state.worker_take_picker_index = 0;
10197 self.state.show_worker_take_picker = true;
10198 Ok(())
10199 }
10200
10201 fn worker_inventory_options(
10202 worker: &flatland_protocol::HiredWorkerView,
10203 ) -> Vec<WorkerGiveOption> {
10204 worker
10205 .inventory
10206 .iter()
10207 .filter_map(|stack| {
10208 let item_instance_id = stack.item_instance_id?;
10209 let label = stack
10210 .display_name
10211 .clone()
10212 .unwrap_or_else(|| stack.template_id.clone());
10213 let label = if stack.quantity > 1 {
10214 format!("{label} ×{}", stack.quantity)
10215 } else {
10216 label
10217 };
10218 Some(WorkerGiveOption {
10219 item_instance_id,
10220 label,
10221 quantity: stack.quantity,
10222 template_id: stack.template_id.clone(),
10223 })
10224 })
10225 .collect()
10226 }
10227
10228 pub fn close_worker_take_picker(&mut self) {
10229 self.state.show_worker_take_picker = false;
10230 self.state.worker_take_picker = None;
10231 self.state.worker_take_picker_index = 0;
10232 }
10233
10234 pub fn worker_take_picker_move(&mut self, delta: i32) {
10235 let Some(picker) = &self.state.worker_take_picker else {
10236 return;
10237 };
10238 let n = picker.options.len();
10239 if n == 0 {
10240 return;
10241 }
10242 let idx = self.state.worker_take_picker_index as i32;
10243 self.state.worker_take_picker_index = (idx + delta).rem_euclid(n as i32) as usize;
10244 self.clamp_worker_take_quantity();
10245 }
10246
10247 pub fn worker_take_picker_adjust_quantity(&mut self, delta: i32) {
10248 let Some(picker) = &mut self.state.worker_take_picker else {
10249 return;
10250 };
10251 let max = picker
10252 .options
10253 .get(self.state.worker_take_picker_index)
10254 .map(|o| o.quantity.max(1))
10255 .unwrap_or(1);
10256 let next = (picker.quantity as i32 + delta).clamp(1, max as i32);
10257 picker.quantity = next as u32;
10258 }
10259
10260 pub fn worker_take_picker_set_quantity_max(&mut self) {
10261 let Some(picker) = &mut self.state.worker_take_picker else {
10262 return;
10263 };
10264 let max = picker
10265 .options
10266 .get(self.state.worker_take_picker_index)
10267 .map(|o| o.quantity.max(1))
10268 .unwrap_or(1);
10269 picker.quantity = max;
10270 }
10271
10272 pub fn worker_take_picker_set_quantity_min(&mut self) {
10273 let Some(picker) = &mut self.state.worker_take_picker else {
10274 return;
10275 };
10276 picker.quantity = 1;
10277 self.clamp_worker_take_quantity();
10278 }
10279
10280 fn clamp_worker_take_quantity(&mut self) {
10281 let Some(picker) = &mut self.state.worker_take_picker else {
10282 return;
10283 };
10284 let max = picker
10285 .options
10286 .get(self.state.worker_take_picker_index)
10287 .map(|o| o.quantity.max(1))
10288 .unwrap_or(1);
10289 if picker.quantity == 0 || picker.quantity > max {
10290 picker.quantity = if max > 1 { 1 } else { max };
10291 }
10292 }
10293
10294 pub async fn confirm_worker_take_picker(&mut self) -> anyhow::Result<()> {
10295 let Some(picker) = self.state.worker_take_picker.clone() else {
10296 anyhow::bail!("take picker not open");
10297 };
10298 let Some(opt) = picker.options.get(self.state.worker_take_picker_index).cloned() else {
10299 anyhow::bail!("no item selected");
10300 };
10301 let Some(worker) = self
10302 .state
10303 .hired_workers
10304 .iter()
10305 .find(|w| w.instance_id == picker.worker_instance_id)
10306 .cloned()
10307 else {
10308 self.close_worker_take_picker();
10309 anyhow::bail!("worker no longer hired");
10310 };
10311 let qty = picker.quantity.clamp(1, opt.quantity.max(1));
10312 let intent_qty = if qty >= opt.quantity {
10313 None
10314 } else {
10315 Some(qty)
10316 };
10317 self.take_item_from_worker(
10318 &worker.instance_id,
10319 &worker.label,
10320 worker.x,
10321 worker.y,
10322 opt.item_instance_id,
10323 &opt.label,
10324 intent_qty,
10325 )
10326 .await?;
10327 Ok(())
10330 }
10331
10332 async fn take_item_from_worker(
10333 &mut self,
10334 worker_instance_id: &str,
10335 worker_label: &str,
10336 worker_x: f32,
10337 worker_y: f32,
10338 item_instance_id: uuid::Uuid,
10339 item_label: &str,
10340 quantity: Option<u32>,
10341 ) -> anyhow::Result<()> {
10342 let (px, py, _) = self.state.player_position_with_z();
10343 let dist = ((worker_x - px).powi(2) + (worker_y - py).powi(2)).sqrt();
10344 if dist > WORKER_GIVE_RANGE_M {
10345 anyhow::bail!("worker {worker_label} too far — stand next to them");
10346 }
10347 self.seq += 1;
10348 self.session
10349 .submit_intent(Intent::TakeWorkerItem {
10350 entity_id: self.state.entity_id,
10351 worker_instance_id: worker_instance_id.to_string(),
10352 item_instance_id,
10353 quantity,
10354 seq: self.seq,
10355 })
10356 .await?;
10357 self.state.intents_sent += 1;
10358 let qty_note = quantity
10359 .map(|q| format!(" ×{q}"))
10360 .unwrap_or_default();
10361 self.state
10362 .push_log(format!("Taking {item_label}{qty_note} from {worker_label}…"));
10363 Ok(())
10364 }
10365
10366 pub async fn hire_worker_laborer(&mut self) -> anyhow::Result<()> {
10367 if !self.state.has_worker_lodging() {
10368 anyhow::bail!("no free lodging slots — place another camp bed (or bunk)");
10369 }
10370 self.seq += 1;
10371 self.session
10372 .submit_intent(Intent::HireWorker {
10373 entity_id: self.state.entity_id,
10374 def_id: "worker_laborer".into(),
10375 wage_copper_per_interval: 8,
10376 lodging_container_id: None,
10377 job_yaml: None,
10378 seq: self.seq,
10379 })
10380 .await?;
10381 self.state.intents_sent += 1;
10382 Ok(())
10383 }
10384
10385 pub fn open_worker_route_editor_for_selected(&mut self) -> anyhow::Result<()> {
10386 let Some(worker) = self
10387 .state
10388 .hired_workers
10389 .get(self.state.workers_menu_index)
10390 .cloned()
10391 else {
10392 anyhow::bail!("select a hired worker first");
10393 };
10394 let lodging = worker.lodging_container_id.clone().or_else(|| {
10395 crate::worker_route_editor::owned_lodging_container_ids(
10396 &self.state.placed_containers,
10397 self.state.character_id,
10398 )
10399 .into_iter()
10400 .next()
10401 .map(|(id, _)| id)
10402 });
10403 let label = worker.label.clone();
10404 let editor = if let Some(route) = &worker.route {
10405 crate::worker_route_editor::WorkerRouteEditorState::from_saved_route(
10406 worker.instance_id,
10407 worker.label,
10408 route,
10409 lodging,
10410 )
10411 } else {
10412 crate::worker_route_editor::WorkerRouteEditorState::new(
10413 worker.instance_id,
10414 worker.label,
10415 lodging,
10416 )
10417 };
10418 self.state.worker_route_editor = Some(editor);
10419 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10420 if let Some(collapsed) =
10421 crate::client_config::ClientConfig::load().worker_route_panel_collapsed
10422 {
10423 ed.panel_collapsed = collapsed;
10424 }
10425 }
10426 self.state.show_workers_menu = false;
10427 self.state.push_log(format!(
10428 "Route editor: {label} — a add stop · Enter edit stop · click rows · s save · Esc back/close",
10429 ));
10430 Ok(())
10431 }
10432
10433 pub fn close_worker_route_editor(&mut self) {
10434 self.state.worker_route_editor = None;
10435 }
10436
10437 pub fn worker_route_editor_toggle_panel(&mut self) {
10438 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10439 ed.toggle_panel_collapsed();
10440 let collapsed = ed.panel_collapsed;
10441 let mut cfg = crate::client_config::ClientConfig::load();
10442 let _ = cfg.save_worker_route_panel_collapsed(collapsed);
10443 }
10444 }
10445
10446 pub fn worker_route_editor_add_waypoint(&mut self, x: f32, y: f32, z: f32) {
10447 let n = {
10448 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10449 return;
10450 };
10451 ed.append_waypoint(x, y, z);
10452 ed.stop_count()
10453 };
10454 self.state
10455 .push_log(format!("Route: waypoint #{n} at ({x:.0}, {y:.0})"));
10456 }
10457
10458 fn re_container_candidates(&self) -> Vec<crate::worker_route_editor::ContainerCandidate> {
10461 let (px, py, _) = self.state.player_position_with_z();
10462 crate::worker_route_editor::owned_container_candidates_with_occupants_and_buildings(
10463 &self.state.placed_containers,
10464 &self.state.buildings,
10465 self.state.character_id,
10466 px,
10467 py,
10468 &self.state.hired_workers,
10469 )
10470 }
10471
10472 fn re_node_candidates(&self) -> Vec<crate::worker_route_editor::NodeCandidate> {
10473 self.state.route_editor_node_candidates()
10474 }
10475
10476 fn re_open_harvest_picker(
10477 &mut self,
10478 index: usize,
10479 picked: std::collections::BTreeSet<String>,
10480 ) {
10481 use crate::worker_route_editor::{RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW};
10482 let nodes = self.state.route_editor_node_candidates();
10483 let index = if nodes.is_empty() {
10484 ROUTE_PICKER_DONE_ROW
10485 } else {
10486 index.max(1).min(nodes.len())
10487 };
10488 self.re_open_sheet(S::HarvestPicker {
10489 index,
10490 picked,
10491 nodes,
10492 });
10493 }
10494
10495 fn re_npc_candidates(&self) -> Vec<crate::worker_route_editor::TradeNpcCandidate> {
10496 let (px, py, _) = self.state.player_position_with_z();
10497 crate::worker_route_editor::trade_npc_candidates(&self.state.npcs, px, py)
10498 }
10499
10500 fn re_template_candidates(&self) -> Vec<String> {
10501 let mut extra = Vec::new();
10502 if let Some(ed) = self.state.worker_route_editor.as_ref() {
10503 for stop in &ed.stops {
10504 match stop {
10505 crate::worker_route_editor::WorkerRouteStop::DepositAt {
10506 filter: Some(filter),
10507 ..
10508 } => extra.extend(filter.iter().cloned()),
10509 crate::worker_route_editor::WorkerRouteStop::TradeWith { template, .. } => {
10510 extra.push(template.clone());
10511 }
10512 crate::worker_route_editor::WorkerRouteStop::CraftAt { blueprint, .. } => {
10513 if let Some(bp) = self.state.blueprints.iter().find(|b| b.id == *blueprint) {
10514 extra.push(bp.output.clone());
10515 for input in &bp.inputs {
10516 extra.push(input.template_id.clone());
10517 }
10518 }
10519 }
10520 crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } => {
10521 for it in items {
10522 extra.push(it.template.clone());
10523 }
10524 }
10525 _ => {}
10526 }
10527 }
10528 if let Some(worker) = self
10530 .state
10531 .hired_workers
10532 .iter()
10533 .find(|w| w.instance_id == ed.worker_instance_id)
10534 {
10535 for recipe in &worker.known_blueprint_ids {
10536 if let Some(bp) = self.state.blueprints.iter().find(|b| &b.id == recipe) {
10537 extra.push(bp.output.clone());
10538 }
10539 }
10540 }
10541 }
10542 crate::worker_route_editor::route_item_template_candidates(
10543 &self.state.placed_containers,
10544 self.state.character_id,
10545 &self.state.inventory,
10546 &self.state.blueprints,
10547 &self.state.resource_nodes,
10548 &extra,
10549 )
10550 }
10551
10552 fn re_blueprint_ids(&self) -> Vec<String> {
10553 let worker_known: Option<&[String]> = self
10554 .state
10555 .worker_route_editor
10556 .as_ref()
10557 .and_then(|ed| {
10558 self.state
10559 .hired_workers
10560 .iter()
10561 .find(|w| w.instance_id == ed.worker_instance_id)
10562 })
10563 .map(|w| w.known_blueprint_ids.as_slice());
10564 crate::worker_route_editor::worker_craft_blueprint_ids(
10565 &self.state.blueprints,
10566 worker_known,
10567 )
10568 }
10569
10570 fn re_bed_candidates(&self) -> Vec<(String, String)> {
10571 crate::worker_route_editor::owned_lodging_container_ids(
10572 &self.state.placed_containers,
10573 self.state.character_id,
10574 )
10575 }
10576
10577 fn re_container_contents(&self, container_id: &str) -> Vec<flatland_protocol::ItemStack> {
10578 self.state
10579 .placed_containers
10580 .iter()
10581 .find(|c| c.id == container_id)
10582 .map(|c| c.contents.clone())
10583 .unwrap_or_default()
10584 }
10585
10586 fn re_sheet_supports_filter(&self) -> bool {
10589 use crate::worker_route_editor::RouteEditorSheet as S;
10590 self.state
10591 .worker_route_editor
10592 .as_ref()
10593 .is_some_and(|ed| {
10594 matches!(
10595 ed.sheet,
10596 S::HarvestPicker { .. }
10597 | S::SellItem { .. }
10598 | S::DepositFilter { .. }
10599 | S::WithdrawItems { .. }
10600 | S::WithdrawContainers { .. }
10601 | S::DepositContainers { .. }
10602 | S::SellNpcs { .. }
10603 | S::CraftBlueprint { .. }
10604 | S::BedPicker { .. }
10605 )
10606 })
10607 }
10608
10609 pub fn re_sheet_row_visible(&self, row: usize) -> bool {
10611 use crate::worker_route_editor::{
10612 harvest_picker_row_matches, list_filter_row_matches, RouteEditorSheet as S,
10613 ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
10614 };
10615 let Some(ed) = self.state.worker_route_editor.as_ref() else {
10616 return false;
10617 };
10618 let filter = &ed.sheet_filter;
10619 match &ed.sheet {
10620 S::HarvestPicker { nodes, .. } => {
10621 harvest_picker_row_matches(nodes, row, filter)
10622 }
10623 S::SellItem { templates, .. } => {
10624 if row == ROUTE_PICKER_DONE_ROW || row == SELL_ITEM_TOGGLE_ROW {
10625 return true;
10626 }
10627 let slot = row.saturating_sub(2);
10628 templates.get(slot).is_some_and(|t| {
10629 let label = self.state.template_display_name(t);
10630 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10631 })
10632 }
10633 S::DepositFilter { rows, .. } => {
10634 if row >= rows.len() {
10635 return true;
10636 }
10637 rows.get(row).is_some_and(|(t, _)| {
10638 let label = self.state.template_display_name(t);
10639 list_filter_row_matches(filter, None, &[t.as_str(), label.as_str()])
10640 })
10641 }
10642 S::WithdrawItems { lines, .. } => {
10643 if row >= lines.len() {
10644 return true;
10645 }
10646 lines.get(row).is_some_and(|l| {
10647 let label = self.state.template_display_name(&l.template);
10648 list_filter_row_matches(filter, None, &[l.template.as_str(), label.as_str()])
10649 })
10650 }
10651 S::WithdrawContainers { .. } | S::DepositContainers { .. } => self
10652 .re_container_candidates()
10653 .get(row)
10654 .is_some_and(|c| {
10655 list_filter_row_matches(
10656 filter,
10657 Some(c.dist),
10658 &[c.name.as_str(), c.summary.as_str(), c.id.as_str()],
10659 )
10660 }),
10661 S::SellNpcs { .. } => {
10662 if row == 0 {
10663 return true;
10664 }
10665 self.re_npc_candidates().get(row - 1).is_some_and(|n| {
10666 list_filter_row_matches(filter, Some(n.dist), &[n.label.as_str(), n.id.as_str()])
10667 })
10668 }
10669 S::CraftBlueprint { .. } => self.re_blueprint_ids().get(row).is_some_and(|id| {
10670 let label = self
10671 .state
10672 .blueprints
10673 .iter()
10674 .find(|b| &b.id == id)
10675 .map(|b| {
10676 if b.label.is_empty() {
10677 id.as_str()
10678 } else {
10679 b.label.as_str()
10680 }
10681 })
10682 .unwrap_or(id.as_str());
10683 list_filter_row_matches(filter, None, &[id.as_str(), label])
10684 }),
10685 S::BedPicker { .. } => self.re_bed_candidates().get(row).is_some_and(|(id, name)| {
10686 list_filter_row_matches(filter, None, &[name.as_str(), id.as_str()])
10687 }),
10688 _ => true,
10689 }
10690 }
10691
10692 fn re_sheet_clamp_index(&mut self) {
10693 let count = self.re_sheet_row_count();
10694 if count == 0 {
10695 return;
10696 }
10697 let cur = self.re_sheet_index();
10698 if self.re_sheet_row_visible(cur) {
10699 return;
10700 }
10701 for offset in 1..count {
10702 if cur + offset < count && self.re_sheet_row_visible(cur + offset) {
10703 self.re_sheet_set_index(cur + offset);
10704 return;
10705 }
10706 if cur >= offset && self.re_sheet_row_visible(cur - offset) {
10707 self.re_sheet_set_index(cur - offset);
10708 return;
10709 }
10710 }
10711 }
10712
10713 fn re_sheet_set_index(&mut self, index: usize) {
10714 use crate::worker_route_editor::RouteEditorSheet as S;
10715 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10716 return;
10717 };
10718 match &mut ed.sheet {
10719 S::AddMenu { index: slot }
10720 | S::WaypointMenu { index: slot }
10721 | S::HarvestPicker { index: slot, .. }
10722 | S::WithdrawContainers { index: slot }
10723 | S::DepositContainers { index: slot }
10724 | S::SellNpcs { index: slot }
10725 | S::CraftBlueprint { index: slot }
10726 | S::BedPicker { index: slot }
10727 | S::FarmPlotPicker { index: slot, .. }
10728 | S::FarmPlantSeed { index: slot, .. }
10729 | S::WithdrawItems { index: slot, .. }
10730 | S::DepositFilter { index: slot, .. }
10731 | S::SellItem { index: slot, .. } => *slot = index,
10732 _ => {}
10733 }
10734 }
10735
10736 pub fn re_focus_sheet_filter(&mut self) {
10737 if !self.re_sheet_supports_filter() {
10738 return;
10739 }
10740 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10741 ed.sheet_filter_focused = true;
10742 }
10743 }
10744
10745 pub fn re_blur_sheet_filter_keep_text(&mut self) {
10746 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10747 return;
10748 };
10749 if !ed.sheet_filter_focused {
10750 return;
10751 }
10752 ed.sheet_filter_focused = false;
10753 self.re_sheet_clamp_index();
10754 }
10755
10756 pub fn clear_or_blur_re_sheet_filter(&mut self) -> bool {
10757 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10758 return false;
10759 };
10760 if ed.sheet_filter_focused {
10761 ed.sheet_filter_focused = false;
10762 self.re_sheet_clamp_index();
10763 return true;
10764 }
10765 if !ed.sheet_filter.is_empty() {
10766 ed.sheet_filter.clear();
10767 self.re_sheet_clamp_index();
10768 return true;
10769 }
10770 false
10771 }
10772
10773 pub fn re_append_sheet_filter_char(&mut self, ch: char) {
10774 if ch.is_control() {
10775 return;
10776 }
10777 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10778 return;
10779 };
10780 if !ed.sheet_filter_focused {
10781 return;
10782 }
10783 ed.sheet_filter.push(ch);
10784 self.re_sheet_set_index(0);
10785 self.re_sheet_clamp_index();
10786 }
10787
10788 pub fn re_sheet_filter_backspace(&mut self) {
10789 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10790 return;
10791 };
10792 if !ed.sheet_filter_focused {
10793 return;
10794 }
10795 ed.sheet_filter.pop();
10796 self.re_sheet_set_index(0);
10797 self.re_sheet_clamp_index();
10798 }
10799
10800 pub fn re_sheet_row_count(&self) -> usize {
10802 use crate::worker_route_editor::{
10803 harvest_picker_row_count, sell_item_picker_row_count, RouteEditorSheet as S,
10804 };
10805 let Some(ed) = self.state.worker_route_editor.as_ref() else {
10806 return 0;
10807 };
10808 match &ed.sheet {
10809 S::Stops => ed.stops.len(),
10810 S::AddMenu { .. } => crate::worker_route_editor::ADD_MENU.len(),
10811 S::WaypointMenu { .. } => crate::worker_route_editor::WAYPOINT_MENU.len(),
10812 S::WaypointMapPick => 0,
10813 S::HarvestPicker { nodes, .. } => harvest_picker_row_count(nodes.len()),
10814 S::WithdrawContainers { .. } | S::DepositContainers { .. } => {
10815 self.re_container_candidates().len()
10816 }
10817 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()),
10821 S::CraftBlueprint { .. } => self.re_blueprint_ids().len(),
10822 S::WaitEntry { .. } => 1,
10823 S::BedPicker { .. } => self.re_bed_candidates().len(),
10824 S::FarmPlotPicker { .. } => self.re_farm_plot_candidates().len(),
10825 S::FarmPlantSeed { seeds, .. } => seeds.len(),
10826 }
10827 }
10828
10829 pub fn re_sheet_index(&self) -> usize {
10831 use crate::worker_route_editor::RouteEditorSheet as S;
10832 let Some(ed) = self.state.worker_route_editor.as_ref() else {
10833 return 0;
10834 };
10835 match &ed.sheet {
10836 S::AddMenu { index }
10837 | S::WaypointMenu { index }
10838 | S::HarvestPicker { index, .. }
10839 | S::WithdrawContainers { index }
10840 | S::DepositContainers { index }
10841 | S::SellNpcs { index }
10842 | S::CraftBlueprint { index }
10843 | S::BedPicker { index }
10844 | S::FarmPlotPicker { index, .. }
10845 | S::FarmPlantSeed { index, .. }
10846 | S::WithdrawItems { index, .. }
10847 | S::DepositFilter { index, .. }
10848 | S::SellItem { index, .. } => *index,
10849 _ => 0,
10850 }
10851 }
10852
10853 pub fn re_sheet_move(&mut self, delta: i32) {
10855 let count = self.re_sheet_row_count();
10856 if count == 0 {
10857 return;
10858 }
10859 let cur = self.re_sheet_index();
10860 let next = step_filtered_index(cur, delta, count, |i| self.re_sheet_row_visible(i));
10861 self.re_sheet_set_index(next);
10862 }
10863
10864 pub fn re_sheet_page(&mut self, pages: i32) {
10865 let count = self.re_sheet_row_count();
10866 if count == 0 {
10867 return;
10868 }
10869 let cur = self.re_sheet_index();
10870 let next = page_filtered_index(cur, pages, count, |i| self.re_sheet_row_visible(i));
10871 self.re_sheet_set_index(next);
10872 }
10873
10874 pub fn re_sheet_adjust(&mut self, delta: i32) {
10876 use crate::worker_route_editor::RouteEditorSheet as S;
10877 let index = self.re_sheet_index();
10878 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10879 return;
10880 };
10881 match &mut ed.sheet {
10882 S::WithdrawItems { lines, .. } => {
10883 if let Some(line) = lines.get_mut(index) {
10884 line.adjust_qty(delta);
10885 }
10886 }
10887 S::WaitEntry { ticks } => {
10888 *ticks = (*ticks as i64 + delta as i64 * 10).clamp(10, 10_000) as u64;
10889 }
10890 _ => {}
10891 }
10892 }
10893
10894 pub fn re_sheet_back(&mut self) {
10895 let Some(ed) = self.state.worker_route_editor.as_mut() else {
10896 return;
10897 };
10898 use crate::worker_route_editor::RouteEditorSheet as S;
10899 let was_editing = ed.editing_index.is_some();
10900 let from_top_picker = matches!(
10901 ed.sheet,
10902 S::WithdrawContainers { .. } | S::DepositContainers { .. } | S::SellNpcs { .. }
10903 );
10904 ed.sheet_back();
10905 if was_editing && from_top_picker && matches!(ed.sheet, S::Stops) {
10906 self.state
10908 .push_log("Route: left edit sheet — press s to save current stops".to_string());
10909 }
10910 }
10911
10912 pub fn re_at_root_sheet(&self) -> bool {
10914 self.state
10915 .worker_route_editor
10916 .as_ref()
10917 .is_some_and(|ed| matches!(ed.sheet, crate::worker_route_editor::RouteEditorSheet::Stops))
10918 }
10919
10920 pub fn re_open_add_menu(&mut self) {
10921 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10922 ed.open_add_menu();
10923 }
10924 }
10925
10926 pub fn re_open_bed_picker(&mut self) {
10927 let beds = self.re_bed_candidates();
10928 if beds.is_empty() {
10929 self.state
10930 .push_log("Route: place a camp bed first".to_string());
10931 return;
10932 }
10933 let current = self
10934 .state
10935 .worker_route_editor
10936 .as_ref()
10937 .and_then(|ed| ed.lodging_container_id.clone());
10938 let index = current
10939 .and_then(|id| beds.iter().position(|(bid, _)| bid == &id))
10940 .unwrap_or(0);
10941 self.re_open_sheet(crate::worker_route_editor::RouteEditorSheet::BedPicker { index });
10942 }
10943
10944 fn re_open_sheet(&mut self, sheet: crate::worker_route_editor::RouteEditorSheet) {
10945 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10946 ed.open_sheet(sheet);
10947 }
10948 }
10949
10950 fn re_confirm_stop(
10952 &mut self,
10953 stop: crate::worker_route_editor::WorkerRouteStop,
10954 what: String,
10955 ) {
10956 let appended = self
10957 .state
10958 .worker_route_editor
10959 .as_mut()
10960 .is_some_and(|ed| ed.confirm_stop(stop));
10961 if appended {
10962 self.state.push_log(format!("Route: + {what}"));
10963 } else {
10964 self.state
10965 .push_log(format!("Route: {what} already in route — selected it"));
10966 }
10967 }
10968
10969 fn re_open_withdraw_items(&mut self, container_id: String) {
10970 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
10971 let contents = self.re_container_contents(&container_id);
10972 let existing = self
10976 .state
10977 .worker_route_editor
10978 .as_ref()
10979 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
10980 .and_then(|stop| match stop {
10981 WorkerRouteStop::WithdrawFrom { items, .. } => Some(items.clone()),
10982 _ => None,
10983 })
10984 .unwrap_or_default();
10985 let lines = WorkerRouteEditorState::withdraw_line_drafts(&contents, &existing);
10986 if let Some(ed) = self.state.worker_route_editor.as_mut() {
10989 let _ = ed.retarget_withdraw_container(container_id.clone());
10990 }
10991 self.re_open_sheet(S::WithdrawItems {
10992 container_id,
10993 lines,
10994 index: 0,
10995 });
10996 }
10997
10998 fn re_withdraw_items_activate(&mut self, index: usize) {
10999 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteEditorState, WorkerRouteStop};
11000 enum Outcome {
11001 Cycled,
11002 Confirmed(String),
11003 Empty,
11004 }
11005 let outcome = {
11006 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11007 return;
11008 };
11009 let S::WithdrawItems {
11010 container_id,
11011 lines,
11012 index: sheet_index,
11013 } = &mut ed.sheet
11014 else {
11015 return;
11016 };
11017 *sheet_index = index;
11018 if index < lines.len() {
11019 lines[index].cycle();
11020 Outcome::Cycled
11021 } else {
11022 let items = WorkerRouteEditorState::withdraw_items_from_lines(lines);
11023 if items.is_empty() {
11024 Outcome::Empty
11025 } else {
11026 let stop = WorkerRouteStop::WithdrawFrom {
11027 container_id: container_id.clone(),
11028 items,
11029 };
11030 let summary = stop.summary();
11031 ed.confirm_stop(stop);
11032 Outcome::Confirmed(summary)
11033 }
11034 }
11035 };
11036 match outcome {
11037 Outcome::Cycled => {}
11038 Outcome::Confirmed(what) => self.state.push_log(format!("Route: + {what}")),
11039 Outcome::Empty => self
11040 .state
11041 .push_log("Route: pick at least one item (Space/Enter toggles All/qty)".to_string()),
11042 }
11043 }
11044
11045 fn re_open_deposit_filter(&mut self, container_id: String) {
11046 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11047 let existing_filter = self
11049 .state
11050 .worker_route_editor
11051 .as_ref()
11052 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11053 .and_then(|stop| match stop {
11054 WorkerRouteStop::DepositAt { filter, .. } => {
11055 Some(filter.clone().unwrap_or_default())
11056 }
11057 _ => None,
11058 });
11059 let mut candidates = self.re_template_candidates();
11060 if let Some(ref chosen) = existing_filter {
11061 for t in chosen {
11062 if !candidates.iter().any(|c| c == t) {
11063 candidates.push(t.clone());
11064 }
11065 }
11066 candidates.sort();
11067 candidates.dedup();
11068 }
11069 let rows: Vec<(String, bool)> = match existing_filter {
11070 Some(chosen) => candidates
11071 .iter()
11072 .map(|t| (t.clone(), chosen.contains(t)))
11073 .collect(),
11074 None => candidates.into_iter().map(|t| (t, false)).collect(),
11075 };
11076 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11077 let _ = ed.retarget_deposit_container(container_id.clone());
11078 }
11079 self.re_open_sheet(S::DepositFilter {
11080 container_id,
11081 rows,
11082 index: 0,
11083 });
11084 }
11085
11086 fn re_deposit_filter_activate(&mut self, index: usize) {
11087 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11088 let mut confirmed: Option<String> = None;
11089 {
11090 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11091 return;
11092 };
11093 let S::DepositFilter {
11094 container_id,
11095 rows,
11096 index: sheet_index,
11097 } = &mut ed.sheet
11098 else {
11099 return;
11100 };
11101 *sheet_index = index;
11102 if index < rows.len() {
11103 rows[index].1 = !rows[index].1;
11104 } else {
11105 let chosen: Vec<String> = rows
11107 .iter()
11108 .filter(|(_, on)| *on)
11109 .map(|(t, _)| t.clone())
11110 .collect();
11111 let filter = if chosen.is_empty() { None } else { Some(chosen) };
11112 let stop = WorkerRouteStop::DepositAt {
11113 container_id: container_id.clone(),
11114 filter,
11115 };
11116 confirmed = Some(stop.summary());
11117 ed.confirm_stop(stop);
11118 }
11119 }
11120 if let Some(what) = confirmed {
11121 self.state.push_log(format!("Route: + {what}"));
11122 }
11123 }
11124
11125 fn re_open_sell_item(&mut self, npc_id: Option<String>) {
11126 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11127 let templates = self.re_template_candidates();
11128 if templates.is_empty() {
11129 self.state.push_log(
11130 "Route: no item templates available — learn a craft recipe or place a harvest node first"
11131 .to_string(),
11132 );
11133 return;
11134 }
11135 let (pre_npc, pre_template, pre_all) = self
11137 .state
11138 .worker_route_editor
11139 .as_ref()
11140 .and_then(|ed| ed.editing_index.and_then(|i| ed.stops.get(i)))
11141 .and_then(|stop| match stop {
11142 WorkerRouteStop::TradeWith {
11143 npc_id,
11144 template,
11145 sell_all,
11146 } => Some((npc_id.clone(), Some(template.clone()), *sell_all)),
11147 _ => None,
11148 })
11149 .unwrap_or((None, None, true));
11150 let npc_id = npc_id.or(pre_npc);
11151 let mut picked = std::collections::BTreeSet::new();
11152 if let Some(t) = pre_template {
11153 picked.insert(t);
11154 }
11155 self.re_open_sheet(S::SellItem {
11156 npc_id,
11157 templates,
11158 index: if picked.is_empty() {
11159 crate::worker_route_editor::SELL_ITEM_TOGGLE_ROW
11160 } else {
11161 2
11162 },
11163 sell_all: pre_all,
11164 picked,
11165 });
11166 }
11167
11168 fn re_sell_item_activate(&mut self, index: usize) {
11169 use crate::worker_route_editor::{
11170 RouteEditorSheet as S, ROUTE_PICKER_DONE_ROW, SELL_ITEM_TOGGLE_ROW,
11171 };
11172 let mut batch_log: Option<String> = None;
11173 {
11174 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11175 return;
11176 };
11177 let S::SellItem {
11178 npc_id,
11179 templates,
11180 index: sheet_index,
11181 sell_all,
11182 picked,
11183 } = &mut ed.sheet
11184 else {
11185 return;
11186 };
11187 *sheet_index = index;
11188 if index == ROUTE_PICKER_DONE_ROW {
11189 if picked.is_empty() {
11190 batch_log = Some(
11191 "Route: pick at least one item (Space toggles, Done confirms)".into(),
11192 );
11193 } else {
11194 let picks: Vec<String> = picked.iter().cloned().collect();
11195 let npc = npc_id.clone();
11196 let all = *sell_all;
11197 let added = ed.confirm_trade_picks(npc, &picks, all);
11198 batch_log = Some(format!("Route: + {added} sell stop(s)"));
11199 }
11200 } else if index == SELL_ITEM_TOGGLE_ROW {
11201 *sell_all = !*sell_all;
11202 } else if let Some(template) = templates.get(index.saturating_sub(2)) {
11203 if picked.contains(template) {
11204 picked.remove(template);
11205 } else {
11206 picked.insert(template.clone());
11207 }
11208 }
11209 }
11210 if let Some(msg) = batch_log {
11211 self.state.push_log(msg);
11212 }
11213 }
11214
11215 pub fn re_edit_selected_stop(&mut self) {
11217 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11218 let Some(stop) = self
11219 .state
11220 .worker_route_editor
11221 .as_ref()
11222 .and_then(|ed| ed.stops.get(ed.selected_stop_index).cloned())
11223 else {
11224 self.state
11225 .push_log("Route: no stop selected — press a to add one".to_string());
11226 return;
11227 };
11228 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11229 ed.begin_edit_selected();
11230 }
11231 match stop {
11232 WorkerRouteStop::Waypoint { .. } => {
11233 self.re_open_sheet(S::WaypointMenu { index: 0 });
11234 }
11235 WorkerRouteStop::HarvestNode { node_id } => {
11236 let nodes = self.state.route_editor_node_candidates();
11237 if nodes.is_empty() {
11238 self.re_cancel_edit();
11239 self.state
11240 .push_log("Route: no harvestable nodes visible to retarget".to_string());
11241 } else {
11242 let mut picked = std::collections::BTreeSet::new();
11243 picked.insert(node_id.clone());
11244 let index = nodes
11245 .iter()
11246 .position(|n| n.id == node_id)
11247 .map(|i| i + 1)
11248 .unwrap_or(1);
11249 self.re_open_harvest_picker(index, picked);
11250 }
11251 }
11252 WorkerRouteStop::WithdrawFrom { container_id, .. } => {
11253 let containers = self.re_container_candidates();
11256 if containers.is_empty() {
11257 self.re_cancel_edit();
11258 self.state
11259 .push_log("Route: place a storage chest first".to_string());
11260 } else {
11261 let index = containers
11262 .iter()
11263 .position(|c| c.id == container_id)
11264 .unwrap_or(0);
11265 self.re_open_sheet(S::WithdrawContainers { index });
11266 }
11267 }
11268 WorkerRouteStop::DepositAt { container_id, .. } => {
11269 let containers = self.re_container_candidates();
11270 if containers.is_empty() {
11271 self.re_cancel_edit();
11272 self.state
11273 .push_log("Route: place a storage chest first".to_string());
11274 } else {
11275 let index = containers
11276 .iter()
11277 .position(|c| c.id == container_id)
11278 .unwrap_or(0);
11279 self.re_open_sheet(S::DepositContainers { index });
11280 }
11281 }
11282 WorkerRouteStop::TradeWith { npc_id, .. } => {
11283 let npcs = self.re_npc_candidates();
11284 let index = npc_id
11286 .as_ref()
11287 .and_then(|id| npcs.iter().position(|n| &n.id == id).map(|i| i + 1))
11288 .unwrap_or(0);
11289 self.re_open_sheet(S::SellNpcs { index });
11290 }
11291 WorkerRouteStop::CraftAt { blueprint, .. } => {
11292 let bps = self.re_blueprint_ids();
11293 let index = bps.iter().position(|b| b == &blueprint).unwrap_or(0);
11294 if bps.is_empty() {
11295 self.re_cancel_edit();
11296 self.state
11297 .push_log("Route: no known blueprints to retarget".to_string());
11298 } else {
11299 self.re_open_sheet(S::CraftBlueprint { index });
11300 }
11301 }
11302 WorkerRouteStop::CultivatePlot { .. } => {
11303 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate);
11304 }
11305 WorkerRouteStop::PlantPlot { .. } => {
11306 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant);
11307 }
11308 WorkerRouteStop::HarvestPlot { .. } => {
11309 self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest);
11310 }
11311 WorkerRouteStop::RestIfNeeded => {
11312 self.re_cancel_edit();
11313 self.state
11314 .push_log("Route: rest has no settings (change the bed with l)".to_string());
11315 }
11316 WorkerRouteStop::Wait { wait_ticks } => {
11317 self.re_open_sheet(S::WaitEntry { ticks: wait_ticks });
11318 }
11319 }
11320 }
11321
11322 fn re_cancel_edit(&mut self) {
11323 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11324 ed.editing_index = None;
11325 }
11326 }
11327
11328 pub fn worker_route_editor_ui_click(
11331 &mut self,
11332 click: crate::worker_route_editor::RouteEditorClick,
11333 ) {
11334 use crate::worker_route_editor::{RouteEditorClick, RouteEditorSheet as S};
11335 match click {
11336 RouteEditorClick::SelectStop(i) => {
11337 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11338 ed.sheet = S::Stops;
11339 ed.select_stop(i);
11340 }
11341 }
11342 RouteEditorClick::OpenBedPicker => self.re_open_bed_picker(),
11343 RouteEditorClick::SheetRow(i) => self.re_sheet_row_activate(i),
11344 RouteEditorClick::TogglePanel => self.worker_route_editor_toggle_panel(),
11345 }
11346 }
11347
11348 pub fn re_sheet_row_activate(&mut self, row: usize) {
11350 use crate::worker_route_editor::{RouteEditorSheet as S, WorkerRouteStop};
11351 let Some(sheet) = self
11352 .state
11353 .worker_route_editor
11354 .as_ref()
11355 .map(|ed| ed.sheet.clone())
11356 else {
11357 return;
11358 };
11359 match sheet {
11360 S::Stops => {
11361 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11362 ed.select_stop(row);
11363 }
11364 }
11365 S::AddMenu { .. } => match row {
11366 0 => self.re_open_sheet(S::WaypointMenu { index: 0 }),
11367 1 => {
11368 if self.re_node_candidates().is_empty() {
11369 self.state
11370 .push_log("Route: no harvestable nodes visible in this region".to_string());
11371 } else {
11372 self.re_open_harvest_picker(1, std::collections::BTreeSet::new());
11373 }
11374 }
11375 2 | 3 => {
11376 if self.re_container_candidates().is_empty() {
11377 self.state
11378 .push_log("Route: place a storage chest first".to_string());
11379 } else if row == 2 {
11380 self.re_open_sheet(S::WithdrawContainers { index: 0 });
11381 } else {
11382 self.re_open_sheet(S::DepositContainers { index: 0 });
11383 }
11384 }
11385 4 => {
11386 if self.re_template_candidates().is_empty() {
11387 self.state.push_log(
11388 "Route: no item templates available — learn a craft recipe or place a harvest node first"
11389 .to_string(),
11390 );
11391 } else {
11392 self.re_open_sheet(S::SellNpcs { index: 0 });
11393 }
11394 }
11395 5 => {
11396 if self.re_blueprint_ids().is_empty() {
11397 self.state.push_log(
11398 "Route: no craft recipes this worker knows — laborers know oak_to_lumber (needs a handsaw in their inventory)"
11399 .to_string(),
11400 );
11401 } else {
11402 self.re_open_sheet(S::CraftBlueprint { index: 0 });
11403 }
11404 }
11405 6 => self.re_confirm_stop(
11406 WorkerRouteStop::RestIfNeeded,
11407 "rest at lodging (if needed)".into(),
11408 ),
11409 7 => self.re_open_sheet(S::WaitEntry { ticks: 60 }),
11410 8 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Cultivate),
11411 9 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Plant),
11412 10 => self.re_open_farm_plot_picker(crate::worker_route_editor::FarmPlotAction::Harvest),
11413 _ => {}
11414 },
11415 S::WaypointMenu { .. } => match row {
11416 0 => {
11417 let (x, y, z) = self.state.player_position_with_z();
11418 let stop = WorkerRouteStop::Waypoint { x, y, z };
11419 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
11420 }
11421 1 => {
11422 self.re_open_sheet(S::WaypointMapPick);
11423 self.state.push_log("Route: click the map to place the waypoint (Esc to finish)".to_string());
11424 }
11425 _ => {}
11426 },
11427 S::HarvestPicker { .. } => {
11428 let mut log: Option<String> = None;
11429 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11430 let S::HarvestPicker {
11431 index: sheet_index,
11432 picked,
11433 nodes,
11434 } = &mut ed.sheet
11435 else {
11436 return;
11437 };
11438 *sheet_index = row;
11439 if row == crate::worker_route_editor::ROUTE_PICKER_DONE_ROW {
11440 if picked.is_empty() {
11441 log = Some(
11442 "Route: pick at least one node (Space toggles, Done confirms)"
11443 .into(),
11444 );
11445 } else {
11446 let ids: Vec<String> = picked.iter().cloned().collect();
11447 let added = ed.confirm_harvest_picks(&ids);
11448 log = Some(format!("Route: + {added} harvest stop(s)"));
11449 }
11450 } else if let Some(n) = nodes.get(row.saturating_sub(1)) {
11451 if picked.contains(&n.id) {
11452 picked.remove(&n.id);
11453 } else {
11454 picked.insert(n.id.clone());
11455 }
11456 }
11457 }
11458 if let Some(msg) = log {
11459 self.state.push_log(msg);
11460 }
11461 }
11462 S::WithdrawContainers { .. } => {
11463 let containers = self.re_container_candidates();
11464 if let Some(c) = containers.get(row) {
11465 let id = c.id.clone();
11466 self.re_open_withdraw_items(id);
11467 }
11468 }
11469 S::WithdrawItems { .. } => self.re_withdraw_items_activate(row),
11470 S::DepositContainers { .. } => {
11471 let containers = self.re_container_candidates();
11472 if let Some(c) = containers.get(row) {
11473 let id = c.id.clone();
11474 self.re_open_deposit_filter(id);
11475 }
11476 }
11477 S::DepositFilter { .. } => self.re_deposit_filter_activate(row),
11478 S::SellNpcs { .. } => {
11479 let npcs = self.re_npc_candidates();
11480 let npc_id = if row == 0 {
11481 None
11482 } else {
11483 npcs.get(row - 1).map(|n| n.id.clone())
11484 };
11485 if row == 0 || npc_id.is_some() {
11486 self.re_open_sell_item(npc_id);
11487 }
11488 }
11489 S::SellItem { .. } => self.re_sell_item_activate(row),
11490 S::CraftBlueprint { .. } => {
11491 let bps = self.re_blueprint_ids();
11492 if let Some(bp) = bps.get(row) {
11493 let stop = WorkerRouteStop::CraftAt {
11494 device: "hand".into(),
11495 blueprint: bp.clone(),
11496 qty: None,
11497 };
11498 self.re_confirm_stop(stop, format!("craft {bp} (hand)"));
11499 }
11500 }
11501 S::WaitEntry { ticks } => {
11502 let stop = WorkerRouteStop::Wait {
11503 wait_ticks: ticks,
11504 };
11505 self.re_confirm_stop(stop, format!("wait {ticks}t"));
11506 }
11507 S::BedPicker { .. } => {
11508 let beds = self.re_bed_candidates();
11509 if let Some((id, name)) = beds.get(row) {
11510 let (id, name) = (id.clone(), name.clone());
11511 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11512 ed.lodging_container_id = Some(id.clone());
11513 ed.sheet = S::Stops;
11514 }
11515 self.state
11516 .push_log(format!("Route: rest bed set to {name}"));
11517 }
11518 }
11519 S::FarmPlotPicker { action, .. } => {
11520 let plots = self.re_farm_plot_candidates();
11521 let Some(plot) = plots.get(row).cloned() else {
11522 return;
11523 };
11524 match action {
11525 crate::worker_route_editor::FarmPlotAction::Cultivate => {
11526 let label = plot_route_label(&plot);
11527 self.re_confirm_stop(
11528 WorkerRouteStop::CultivatePlot {
11529 plot_id: plot.plot_id,
11530 },
11531 format!("cultivate {label}"),
11532 );
11533 }
11534 crate::worker_route_editor::FarmPlotAction::Harvest => {
11535 let label = plot_route_label(&plot);
11536 self.re_confirm_stop(
11537 WorkerRouteStop::HarvestPlot {
11538 plot_id: plot.plot_id,
11539 },
11540 format!("harvest {label}"),
11541 );
11542 }
11543 crate::worker_route_editor::FarmPlotAction::Plant => {
11544 let seeds = self.re_farm_seed_candidates();
11545 if seeds.is_empty() {
11546 self.state.push_log(
11547 "Route: no seed templates known — check content or add a withdraw of potato_seed / carrot_seed",
11548 );
11549 return;
11550 }
11551 self.re_open_sheet(S::FarmPlantSeed {
11552 plot_id: plot.plot_id,
11553 seeds,
11554 index: 0,
11555 });
11556 }
11557 }
11558 }
11559 S::FarmPlantSeed { plot_id, seeds, .. } => {
11560 if let Some(seed) = seeds.get(row).cloned() {
11561 self.re_confirm_stop(
11562 WorkerRouteStop::PlantPlot {
11563 plot_id,
11564 seed_template: seed.clone(),
11565 },
11566 format!("plant {seed}"),
11567 );
11568 }
11569 }
11570 S::WaypointMapPick => {}
11571 }
11572 }
11573
11574 fn re_open_farm_plot_picker(&mut self, action: crate::worker_route_editor::FarmPlotAction) {
11575 use crate::worker_route_editor::RouteEditorSheet as S;
11576 if self.re_farm_plot_candidates().is_empty() {
11577 self.state.push_log(
11578 "Route: no farmable plots visible — claim land or get farm access first",
11579 );
11580 return;
11581 }
11582 self.re_open_sheet(S::FarmPlotPicker { index: 0, action });
11583 }
11584
11585 fn re_farm_plot_candidates(&self) -> Vec<flatland_protocol::PropertyPlotView> {
11586 self.state
11587 .property_plots
11588 .iter()
11589 .filter(|p| p.is_mine || p.may_farm)
11590 .cloned()
11591 .collect()
11592 }
11593
11594 fn re_farm_seed_candidates(&self) -> Vec<String> {
11598 let mut set = std::collections::BTreeSet::new();
11599 let looks_like_seed = |id: &str| {
11600 id.ends_with("_seed") || id == "potato_seed" || id == "carrot_seed"
11601 };
11602 for (id, _, _) in self.state.farm_seed_entries() {
11603 set.insert(id);
11604 }
11605 for c in &self.state.placed_containers {
11606 let mine = match (self.state.character_id, c.owner_character_id) {
11607 (Some(a), Some(b)) => a == b,
11608 _ => false,
11609 };
11610 if !mine {
11611 continue;
11612 }
11613 for s in &c.contents {
11614 if s.quantity > 0
11615 && (s.props.contains_key("seed_for") || looks_like_seed(&s.template_id))
11616 {
11617 set.insert(s.template_id.clone());
11618 }
11619 }
11620 }
11621 if let Some(ed) = self.state.worker_route_editor.as_ref() {
11622 for stop in &ed.stops {
11623 if let crate::worker_route_editor::WorkerRouteStop::WithdrawFrom { items, .. } = stop
11624 {
11625 for it in items {
11626 if looks_like_seed(&it.template) {
11627 set.insert(it.template.clone());
11628 }
11629 }
11630 }
11631 if let crate::worker_route_editor::WorkerRouteStop::PlantPlot {
11632 seed_template, ..
11633 } = stop
11634 {
11635 if !seed_template.is_empty() {
11636 set.insert(seed_template.clone());
11637 }
11638 }
11639 }
11640 }
11641 for id in self.state.inventory_hints.keys() {
11642 if looks_like_seed(id) {
11643 set.insert(id.clone());
11644 }
11645 }
11646 for id in ["potato_seed", "carrot_seed"] {
11648 set.insert(id.to_string());
11649 }
11650 set.into_iter().collect()
11651 }
11652
11653 pub fn worker_route_editor_map_click(&mut self, x: f32, y: f32) {
11660 use crate::worker_route_editor as wre;
11661 use wre::RouteEditorSheet as S;
11662 if self.state.worker_route_editor.is_none() {
11663 return;
11664 }
11665 let sheet = self
11666 .state
11667 .worker_route_editor
11668 .as_ref()
11669 .map(|ed| ed.sheet.clone())
11670 .unwrap_or(S::Stops);
11671 match sheet {
11672 S::WaypointMapPick => {
11673 let (_, _, z) = self.state.player_position_with_z();
11674 let stop = wre::WorkerRouteStop::Waypoint { x, y, z };
11675 self.re_confirm_stop(stop, format!("waypoint ({x:.0}, {y:.0})"));
11676 let editing = self
11678 .state
11679 .worker_route_editor
11680 .as_ref()
11681 .is_some_and(|ed| ed.editing_index.is_some());
11682 if !editing {
11683 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11684 ed.sheet = S::WaypointMapPick;
11685 }
11686 }
11687 }
11688 S::HarvestPicker { .. } => {
11689 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
11690 let mut log: Option<String> = None;
11691 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11692 let S::HarvestPicker { picked, .. } = &mut ed.sheet else {
11693 return;
11694 };
11695 let selected = if picked.contains(&node.id) {
11696 picked.remove(&node.id);
11697 false
11698 } else {
11699 picked.insert(node.id.clone());
11700 true
11701 };
11702 log = Some(format!(
11703 "Route: {} {}",
11704 if selected { "selected" } else { "deselected" },
11705 node.label
11706 ));
11707 }
11708 if let Some(msg) = log {
11709 self.state.push_log(msg);
11710 }
11711 }
11712 }
11713 S::WithdrawContainers { .. } | S::WithdrawItems { .. } => {
11714 if let Some(cid) = wre::pick_storage_container_at(
11716 &self.state.placed_containers,
11717 self.state.character_id,
11718 x,
11719 y,
11720 ) {
11721 self.re_open_withdraw_items(cid);
11722 }
11723 }
11724 S::DepositContainers { .. } | S::DepositFilter { .. } => {
11725 if let Some(cid) = wre::pick_storage_container_at(
11726 &self.state.placed_containers,
11727 self.state.character_id,
11728 x,
11729 y,
11730 ) {
11731 self.re_open_deposit_filter(cid);
11732 }
11733 }
11734 S::SellNpcs { .. } => {
11735 if let Some((npc_id, _)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11736 self.re_open_sell_item(Some(npc_id));
11737 }
11738 }
11739 S::SellItem { .. } => {
11740 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11741 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11742 if let S::SellItem { npc_id: slot, .. } = &mut ed.sheet {
11743 *slot = Some(npc_id.clone());
11744 }
11745 }
11746 self.state
11747 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
11748 }
11749 }
11750 _ => self.worker_route_editor_quick_add_click(x, y),
11752 }
11753 }
11754
11755 fn worker_route_editor_quick_add_click(&mut self, x: f32, y: f32) {
11759 use crate::worker_route_editor as wre;
11760 let dist = |ax: f32, ay: f32, bx: f32, by: f32| {
11761 let dx = ax - bx;
11762 let dy = ay - by;
11763 (dx * dx + dy * dy).sqrt()
11764 };
11765
11766 let selected_stop_kind = self
11769 .state
11770 .worker_route_editor
11771 .as_ref()
11772 .and_then(|ed| ed.stops.get(ed.selected_stop_index))
11773 .map(|s| match s {
11774 wre::WorkerRouteStop::TradeWith { .. } => 1,
11775 wre::WorkerRouteStop::WithdrawFrom { .. } => 2,
11776 _ => 0,
11777 })
11778 .unwrap_or(0);
11779 if selected_stop_kind == 1 {
11780 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11781 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11782 ed.set_selected_trade_npc(npc_id.clone());
11783 }
11784 self.state
11785 .push_log(format!("Route: sell NPC → {label} ({npc_id})"));
11786 return;
11787 }
11788 }
11789 if selected_stop_kind == 2 {
11790 if let Some(cid) = wre::pick_storage_container_at(
11791 &self.state.placed_containers,
11792 self.state.character_id,
11793 x,
11794 y,
11795 ) {
11796 let name = self
11797 .state
11798 .placed_containers
11799 .iter()
11800 .find(|c| c.id == cid)
11801 .map(|c| c.display_name.clone())
11802 .unwrap_or_else(|| "container".into());
11803 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11804 ed.set_selected_withdraw_container(cid.clone());
11805 }
11806 self.state
11807 .push_log(format!("Route: withdraw source → {name}"));
11808 return;
11809 }
11810 }
11811
11812 enum Target {
11815 Bed(String),
11816 Container(String),
11817 Npc(String, String),
11818 Node(String, String),
11819 }
11820 let mut best: Option<(f32, u8, Target)> = None;
11821 let consider = |d: f32, rank: u8, t: Target, best: &mut Option<(f32, u8, Target)>| {
11822 let better = match best {
11823 None => true,
11824 Some((bd, brank, _)) => d < *bd - f32::EPSILON || ((d - *bd).abs() <= f32::EPSILON && rank < *brank),
11825 };
11826 if better {
11827 *best = Some((d, rank, t));
11828 }
11829 };
11830 if let Some(bed_id) = wre::pick_lodging_container_at(
11831 &self.state.placed_containers,
11832 self.state.character_id,
11833 x,
11834 y,
11835 ) {
11836 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == bed_id) {
11837 let already_bed = self
11840 .state
11841 .worker_route_editor
11842 .as_ref()
11843 .is_some_and(|ed| ed.lodging_container_id.as_deref() == Some(bed_id.as_str()));
11844 if already_bed {
11845 consider(dist(x, y, c.x, c.y), 1, Target::Container(bed_id), &mut best);
11846 } else {
11847 consider(dist(x, y, c.x, c.y), 0, Target::Bed(bed_id), &mut best);
11848 }
11849 }
11850 }
11851 if let Some(cid) = wre::pick_storage_container_at(
11852 &self.state.placed_containers,
11853 self.state.character_id,
11854 x,
11855 y,
11856 ) {
11857 if let Some(c) = self.state.placed_containers.iter().find(|c| c.id == cid) {
11858 consider(dist(x, y, c.x, c.y), 1, Target::Container(cid), &mut best);
11859 }
11860 }
11861 if let Some((npc_id, label)) = wre::pick_trade_npc_at(&self.state.npcs, x, y) {
11862 if let Some(n) = self.state.npcs.iter().find(|n| n.id == npc_id) {
11863 consider(
11864 dist(x, y, n.x, n.y),
11865 2,
11866 Target::Npc(npc_id, label),
11867 &mut best,
11868 );
11869 }
11870 }
11871 if let Some(node) = wre::pick_resource_node_at(&self.state.resource_nodes, x, y) {
11872 let d = dist(x, y, node.x, node.y);
11873 consider(
11874 d,
11875 3,
11876 Target::Node(node.id.clone(), node.label.clone()),
11877 &mut best,
11878 );
11879 }
11880
11881 match best.map(|(_, _, t)| t) {
11882 Some(Target::Bed(bed_id)) => {
11883 let name = self
11884 .state
11885 .placed_containers
11886 .iter()
11887 .find(|c| c.id == bed_id)
11888 .map(|c| c.display_name.clone())
11889 .unwrap_or_else(|| "camp bed".into());
11890 if let Some(ed) = self.state.worker_route_editor.as_mut() {
11891 ed.lodging_container_id = Some(bed_id.clone());
11892 }
11893 self.state
11894 .push_log(format!("Route: rest bed set to {name} ({bed_id})"));
11895 }
11896 Some(Target::Container(cid)) => {
11897 let name = self
11898 .state
11899 .placed_containers
11900 .iter()
11901 .find(|c| c.id == cid)
11902 .map(|c| c.display_name.clone())
11903 .unwrap_or_else(|| "container".into());
11904 let added = self
11905 .state
11906 .worker_route_editor
11907 .as_mut()
11908 .is_some_and(|ed| ed.append_deposit_at(&cid));
11909 if added {
11910 self.state
11911 .push_log(format!("Route: + deposit at {name} ({cid})"));
11912 } else {
11913 self.state.push_log(format!(
11914 "Route: {name} already in route — selected it (d to remove)"
11915 ));
11916 }
11917 }
11918 Some(Target::Npc(npc_id, label)) => {
11919 let template = self.re_template_candidates().into_iter().next();
11922 let Some(template) = template else {
11923 self.state.push_log("Route: no items in your storage to sell — stock a chest first".to_string());
11924 return;
11925 };
11926 let added = self
11927 .state
11928 .worker_route_editor
11929 .as_mut()
11930 .is_some_and(|ed| ed.append_trade_with(template.clone(), Some(npc_id.clone()), true));
11931 if added {
11932 self.state
11933 .push_log(format!("Route: + sell {template} to {label} ({npc_id})"));
11934 } else {
11935 self.state.push_log(format!(
11936 "Route: {label} already sells {template} — selected it (d to remove)"
11937 ));
11938 }
11939 }
11940 Some(Target::Node(id, label)) => {
11941 let added = self
11942 .state
11943 .worker_route_editor
11944 .as_mut()
11945 .is_some_and(|ed| ed.append_harvest_node(&id));
11946 if added {
11947 self.state
11948 .push_log(format!("Route: + harvest node {label} ({id})"));
11949 } else {
11950 self.state.push_log(format!(
11951 "Route: {label} already in route — selected it (d to remove)"
11952 ));
11953 }
11954 }
11955 None => {}
11956 }
11957 }
11958
11959 pub fn worker_route_editor_select(&mut self, delta: i32) {
11960 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11961 return;
11962 };
11963 if ed.stops.is_empty() {
11964 return;
11965 }
11966 let n = ed.stops.len() as i32;
11967 let next = (ed.selected_stop_index as i32 + delta).rem_euclid(n) as usize;
11968 ed.selected_stop_index = next;
11969 }
11970
11971 pub fn worker_route_editor_move_selected(&mut self, delta: i32) {
11972 let Some(ed) = self.state.worker_route_editor.as_mut() else {
11973 return;
11974 };
11975 if delta < 0 {
11976 ed.move_selected_up();
11977 } else if delta > 0 {
11978 ed.move_selected_down();
11979 }
11980 }
11981
11982 pub fn worker_route_editor_delete_selected(&mut self) {
11983 let removed = self
11984 .state
11985 .worker_route_editor
11986 .as_mut()
11987 .is_some_and(|ed| {
11988 let before = ed.stop_count();
11989 ed.remove_selected_stop();
11990 ed.stop_count() < before
11991 });
11992 if removed {
11993 self.state.push_log("Route: removed selected stop");
11994 }
11995 }
11996
11997 pub fn worker_route_editor_clear_stops(&mut self) {
12000 let Some(ed) = self.state.worker_route_editor.as_mut() else {
12001 return;
12002 };
12003 if ed.stops.is_empty() {
12004 self.state.push_log("Route: already empty — s saves an idle worker".to_string());
12005 return;
12006 }
12007 ed.stops.clear();
12008 ed.selected_stop_index = 0;
12009 self.state
12010 .push_log("Route: cleared all stops — s saves (worker goes idle) · Esc cancels".to_string());
12011 }
12012
12013 pub async fn worker_route_editor_save(&mut self) -> anyhow::Result<()> {
12014 if self.state.pending_worker_job_ack.is_some() {
12015 anyhow::bail!("route save still pending — wait for server ack");
12016 }
12017 let Some(ed) = self.state.worker_route_editor.clone() else {
12018 anyhow::bail!("route editor not open");
12019 };
12020 let (job_yaml, idle) = if ed.stops.is_empty() {
12023 (ed.build_idle_job_yaml(), true)
12024 } else {
12025 (ed.build_job_yaml().map_err(|e| anyhow::anyhow!(e))?, false)
12026 };
12027 let worker_id = ed.worker_instance_id.clone();
12028 let route_view = if idle {
12029 None
12030 } else {
12031 Some(ed.to_route_view())
12032 };
12033 let mode = if idle {
12034 flatland_protocol::WorkerModeView::Idle
12035 } else {
12036 flatland_protocol::WorkerModeView::JobLoop
12037 };
12038 let (prev_route, prev_mode, prev_step_label, prev_last_error) = self
12039 .state
12040 .hired_workers
12041 .iter()
12042 .find(|w| w.instance_id == worker_id)
12043 .map(|w| {
12044 (
12045 w.route.clone(),
12046 w.mode,
12047 w.step_label.clone(),
12048 w.last_error.clone(),
12049 )
12050 })
12051 .unwrap_or((
12052 None,
12053 flatland_protocol::WorkerModeView::Idle,
12054 String::new(),
12055 None,
12056 ));
12057 self.seq += 1;
12058 let seq = self.seq;
12059 self.session
12060 .submit_intent(Intent::SetWorkerJob {
12061 entity_id: self.state.entity_id,
12062 worker_instance_id: worker_id.clone(),
12063 job_yaml,
12064 seq,
12065 })
12066 .await?;
12067 self.state.intents_sent += 1;
12068 if let Some(w) = self
12069 .state
12070 .hired_workers
12071 .iter_mut()
12072 .find(|w| w.instance_id == worker_id)
12073 {
12074 w.route = route_view;
12075 w.mode = mode;
12076 w.last_error = None;
12077 if idle {
12078 w.step_label.clear();
12079 w.route_stop_index = None;
12080 }
12081 }
12082 self.state.pending_worker_job_ack = Some(PendingWorkerJobAck {
12083 seq,
12084 worker_instance_id: worker_id,
12085 worker_label: ed.worker_label.clone(),
12086 idle,
12087 stop_count: ed.stops.len(),
12088 prev_route,
12089 prev_mode,
12090 prev_step_label,
12091 prev_last_error,
12092 });
12093 self.state.push_log(format!(
12094 "Route: saving for {}… (waiting for server)",
12095 ed.worker_label
12096 ));
12097 Ok(())
12099 }
12100 pub fn quest_menu_move(&mut self, delta: i32) {
12101 let n = self.state.active_quest_entries().len();
12102 if n == 0 {
12103 return;
12104 }
12105 let idx = self.state.quest_menu_index as i32;
12106 self.state.quest_menu_index = (idx + delta).rem_euclid(n as i32) as usize;
12107 }
12108
12109 pub fn quest_menu_page(&mut self, pages: i32) {
12110 let n = self.state.active_quest_entries().len();
12111 self.state.quest_menu_index = page_list_index(self.state.quest_menu_index, pages, n);
12112 }
12113
12114 pub async fn quest_offer_accept(&mut self) -> anyhow::Result<()> {
12115 let Some(offer) = self.state.pending_quest_offer.clone() else {
12116 anyhow::bail!("no quest offer");
12117 };
12118 self.seq += 1;
12119 let seq = self.seq;
12120 self.session
12121 .submit_intent(Intent::AcceptQuest {
12122 entity_id: self.state.entity_id,
12123 quest_id: offer.quest_id,
12124 seq,
12125 })
12126 .await?;
12127 self.state.intents_sent += 1;
12128 Ok(())
12129 }
12130
12131 pub fn quest_offer_decline(&mut self) {
12132 self.state.show_quest_offer = false;
12133 self.state.pending_quest_offer = None;
12134 if !self.state.show_npc_chat
12135 && !self.state.show_shop_menu
12136 && self.state.npc_verb_target.is_some()
12137 {
12138 self.state.show_npc_verb_menu = true;
12139 }
12140 }
12141
12142 pub async fn quest_confirm_action(&mut self) -> anyhow::Result<()> {
12143 if !self.state.show_quest_menu {
12144 return Ok(());
12145 }
12146 let active: Vec<_> = self
12147 .state
12148 .active_quest_entries()
12149 .into_iter()
12150 .cloned()
12151 .collect();
12152 let Some(entry) = active.get(self.state.quest_menu_index) else {
12153 return Ok(());
12154 };
12155 if self.state.quest_withdraw_confirm {
12156 if !entry.can_withdraw {
12157 anyhow::bail!("quest cannot be withdrawn");
12158 }
12159 self.seq += 1;
12160 let seq = self.seq;
12161 self.session
12162 .submit_intent(Intent::WithdrawQuest {
12163 entity_id: self.state.entity_id,
12164 quest_id: entry.quest_id.clone(),
12165 seq,
12166 })
12167 .await?;
12168 self.state.intents_sent += 1;
12169 self.state.quest_withdraw_confirm = false;
12170 return Ok(());
12171 }
12172 self.seq += 1;
12173 let seq = self.seq;
12174 self.session
12175 .submit_intent(Intent::TrackQuest {
12176 entity_id: self.state.entity_id,
12177 quest_id: entry.quest_id.clone(),
12178 seq,
12179 })
12180 .await?;
12181 self.state.intents_sent += 1;
12182 Ok(())
12183 }
12184
12185 pub fn quest_request_withdraw(&mut self) {
12186 if self.state.show_quest_menu {
12187 self.state.quest_withdraw_confirm = true;
12188 }
12189 }
12190
12191 pub async fn shop_confirm(&mut self) -> anyhow::Result<()> {
12192 if !self.state.is_alive() {
12193 anyhow::bail!("you are dead");
12194 }
12195 let Some(catalog) = self.state.shop_catalog.clone() else {
12196 anyhow::bail!("no shop open");
12197 };
12198 self.seq += 1;
12199 let seq = self.seq;
12200 match self.state.shop_tab {
12201 ShopTab::Buy => {
12202 let Some(offer) = catalog.sells.get(self.state.shop_menu_index) else {
12203 anyhow::bail!("nothing selected");
12204 };
12205 if offer.already_owned {
12206 anyhow::bail!("already owned");
12207 }
12208 self.session
12209 .submit_intent(Intent::ShopBuy {
12210 entity_id: self.state.entity_id,
12211 npc_id: catalog.npc_id.clone(),
12212 offer_id: offer.offer_id.clone(),
12213 quantity: self.state.shop_quantity,
12214 seq,
12215 })
12216 .await?;
12217 }
12218 ShopTab::Sell => {
12219 let Some(line) = catalog.buys.get(self.state.shop_menu_index) else {
12220 anyhow::bail!("nothing to sell");
12221 };
12222 if line.quantity == 0 {
12223 anyhow::bail!("you have no {}", line.label);
12224 }
12225 let quantity = self.state.shop_quantity.min(line.quantity).max(1);
12226 self.session
12227 .submit_intent(Intent::ShopSell {
12228 entity_id: self.state.entity_id,
12229 npc_id: catalog.npc_id.clone(),
12230 template_id: line.template_id.clone(),
12231 quantity,
12232 seq,
12233 })
12234 .await?;
12235 }
12236 }
12237 self.state.intents_sent += 1;
12238 Ok(())
12239 }
12240
12241 pub fn craft_menu_move(&mut self, delta: i32) {
12242 let n = self.state.blueprints.len();
12243 if n == 0 {
12244 return;
12245 }
12246 let idx = self.state.craft_menu_index as i32;
12247 let next = (idx + delta).rem_euclid(n as i32);
12248 self.state.craft_menu_index = next as usize;
12249 self.state.clamp_craft_batch_quantity();
12250 }
12251
12252 pub fn craft_batch_adjust_quantity(&mut self, delta: i32) {
12253 self.state.craft_batch_adjust_quantity(delta);
12254 }
12255
12256 pub fn craft_batch_set_max(&mut self) {
12257 self.state.craft_batch_set_max();
12258 }
12259
12260 pub fn craft_batch_set_min(&mut self) {
12261 self.state.craft_batch_set_min();
12262 }
12263
12264 pub async fn craft_menu_selection(&mut self) -> anyhow::Result<()> {
12265 let Some(blueprint) = self
12266 .state
12267 .blueprints
12268 .get(self.state.craft_menu_index)
12269 .cloned()
12270 else {
12271 anyhow::bail!("no blueprints known");
12272 };
12273 if !self.state.can_craft_blueprint(&blueprint) {
12274 let hint = self
12275 .state
12276 .craft_missing_hint(&blueprint)
12277 .unwrap_or_else(|| "missing materials".into());
12278 anyhow::bail!("cannot craft {}: {hint}", blueprint.label);
12279 }
12280 let count = self.state.craft_batch_quantity;
12281 let max = self.state.max_craft_batches(&blueprint);
12282 if max == 0 {
12283 anyhow::bail!("cannot craft {}", blueprint.label);
12284 }
12285 let batches = count.min(max);
12286 self.craft(&blueprint.id, Some(batches)).await?;
12287 self.state.show_craft_menu = false;
12288 Ok(())
12289 }
12290
12291 pub async fn move_by(
12292 &mut self,
12293 forward: f32,
12294 strafe: f32,
12295 vertical: f32,
12296 sprint: bool,
12297 ) -> anyhow::Result<()> {
12298 if !self.state.is_alive() {
12299 anyhow::bail!("you are dead");
12300 }
12301 if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
12302 self.last_move_forward = forward;
12303 self.last_move_strafe = strafe;
12304 }
12305 self.seq += 1;
12306 self.session
12307 .submit_intent(Intent::Move {
12308 entity_id: self.state.entity_id,
12309 forward,
12310 strafe,
12311 vertical,
12312 sprint,
12313 seq: self.seq,
12314 })
12315 .await?;
12316 self.state.intents_sent += 1;
12317 Ok(())
12318 }
12319
12320 pub async fn harvest_nearest(&mut self) -> anyhow::Result<()> {
12321 if !self.state.connected {
12322 crate::harvest_trace!("harvest_nearest rejected: not connected");
12323 anyhow::bail!("not connected");
12324 }
12325 if !self.state.is_alive() {
12326 crate::harvest_trace!("harvest_nearest rejected: player dead");
12327 anyhow::bail!("you are dead");
12328 }
12329 if self.state.harvest_in_progress {
12330 if self.state.harvest_state_stale() {
12331 self.state.clear_harvest_state();
12332 } else {
12333 anyhow::bail!("already harvesting");
12334 }
12335 }
12336 let (px, py) = self
12337 .state
12338 .player
12339 .as_ref()
12340 .map(|p| (p.transform.position.x, p.transform.position.y))
12341 .unwrap_or((0.0, 0.0));
12342
12343 let available = self
12344 .state
12345 .resource_nodes
12346 .iter()
12347 .filter(|n| !n.harvest_off)
12348 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
12349 .count();
12350 let node_id = self
12351 .state
12352 .resource_nodes
12353 .iter()
12354 .filter(|n| !n.harvest_off)
12355 .filter(|n| n.state == flatland_protocol::ResourceNodeState::Available)
12356 .filter(|n| distance(px, py, n.x, n.y) <= HARVEST_RANGE_M)
12357 .min_by(|a, b| {
12358 let da = distance(px, py, a.x, a.y);
12359 let db = distance(px, py, b.x, b.y);
12360 da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal)
12361 })
12362 .map(|n| n.id.clone());
12363
12364 let Some(node_id) = node_id else {
12365 let has_loot = self
12366 .state
12367 .ground_drops
12368 .iter()
12369 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
12370 if has_loot {
12371 return self.pickup_nearest().await;
12372 }
12373 anyhow::bail!(
12374 "no harvestable nodes within {HARVEST_RANGE_M}m — stand on * loot and press f to pick up"
12375 );
12376 };
12377
12378 self.seq += 1;
12379 let seq = self.seq;
12380 crate::harvest_trace!(
12381 entity_id = self.state.entity_id,
12382 node_id = %node_id,
12383 seq,
12384 px,
12385 py,
12386 available_nodes = available,
12387 "submitting harvest intent"
12388 );
12389 self.session
12390 .submit_intent(Intent::Harvest {
12391 entity_id: self.state.entity_id,
12392 node_id,
12393 seq,
12394 })
12395 .await?;
12396 self.state.intents_sent += 1;
12397 self.state.harvest_in_progress = true;
12398 self.state.harvest_started_at = Some(Instant::now());
12399 self.state.push_log("Harvesting…");
12400 crate::harvest_trace!(
12401 entity_id = self.state.entity_id,
12402 seq,
12403 "harvest intent queued to session"
12404 );
12405 Ok(())
12406 }
12407
12408 pub async fn craft_next_available(&mut self) -> anyhow::Result<()> {
12409 if !self.state.is_alive() {
12410 anyhow::bail!("you are dead");
12411 }
12412 let blueprint_id = self
12413 .state
12414 .blueprints
12415 .iter()
12416 .find(|bp| self.state.can_craft_blueprint(bp))
12417 .map(|bp| bp.id.clone())
12418 .ok_or_else(|| anyhow::anyhow!("no craftable blueprint (need materials)"))?;
12419 self.craft(&blueprint_id, None).await
12420 }
12421
12422 pub async fn craft(&mut self, blueprint_id: &str, count: Option<u32>) -> anyhow::Result<()> {
12423 if !self.state.is_alive() {
12424 anyhow::bail!("you are dead");
12425 }
12426 self.seq += 1;
12427 self.session
12428 .submit_intent(Intent::Craft {
12429 entity_id: self.state.entity_id,
12430 blueprint_id: blueprint_id.to_string(),
12431 count,
12432 seq: self.seq,
12433 })
12434 .await?;
12435 self.state.intents_sent += 1;
12436 let (label, batches) = self
12437 .state
12438 .blueprints
12439 .iter()
12440 .find(|b| b.id == blueprint_id)
12441 .map(|b| {
12442 let n = count.unwrap_or_else(|| self.state.max_craft_batches(b).max(1));
12443 (b.label.as_str(), n)
12444 })
12445 .unwrap_or((blueprint_id, count.unwrap_or(1)));
12446 self.state.pending_craft_ack = Some((self.seq, label.to_string(), batches));
12447 Ok(())
12448 }
12449
12450 pub async fn interact_nearest(&mut self) -> anyhow::Result<()> {
12451 if !self.state.is_alive() {
12452 anyhow::bail!("you are dead");
12453 }
12454 let target_id = match self.state.nearest_interact_target() {
12455 Some(id) => id,
12456 None => {
12457 anyhow::bail!("nothing to interact with nearby");
12458 }
12459 };
12460 if self.state.npcs.iter().any(|n| n.id == target_id) {
12461 self.state.show_npc_verb_menu = true;
12462 self.state.npc_verb_target = Some(target_id);
12463 self.state.npc_verb_index = 0;
12464 return Ok(());
12465 }
12466 if self
12467 .state
12468 .hired_workers
12469 .iter()
12470 .any(|w| w.instance_id == target_id)
12471 {
12472 return self.open_workers_menu_for(&target_id).await;
12473 }
12474 if let Ok(peer_id) = target_id.parse::<EntityId>() {
12475 if self
12476 .state
12477 .hired_workers
12478 .iter()
12479 .any(|w| w.entity_id == peer_id)
12480 {
12481 if let Some(w) = self
12482 .state
12483 .hired_workers
12484 .iter()
12485 .find(|w| w.entity_id == peer_id)
12486 {
12487 let id = w.instance_id.clone();
12488 return self.open_workers_menu_for(&id).await;
12489 }
12490 }
12491 if let Some(entity) = self
12492 .state
12493 .entities
12494 .iter()
12495 .find(|e| e.id == peer_id && e.id != self.state.entity_id)
12496 {
12497 self.state
12498 .player_verbs
12499 .open_for(peer_id, &entity.label);
12500 return Ok(());
12501 }
12502 }
12503 self.seq += 1;
12504 self.session
12505 .submit_intent(Intent::Interact {
12506 entity_id: self.state.entity_id,
12507 target_id: target_id.clone(),
12508 seq: self.seq,
12509 })
12510 .await?;
12511 self.state.intents_sent += 1;
12512 Ok(())
12513 }
12514
12515 pub async fn use_nearest(&mut self) -> anyhow::Result<()> {
12517 if !self.state.is_alive() {
12518 anyhow::bail!("you are dead");
12519 }
12520 let (px, py) = self.state.player_position();
12521 let has_loot = self
12522 .state
12523 .ground_drops
12524 .iter()
12525 .any(|d| distance(px, py, d.x, d.y) <= INTERACTION_RADIUS_M);
12526 if has_loot {
12527 return self.pickup_nearest().await;
12528 }
12529 if self
12530 .state
12531 .placed_containers
12532 .iter()
12533 .any(|c| (c.x - px).hypot(c.y - py) <= 2.0)
12534 {
12535 return self.pickup_nearest_container().await;
12536 }
12537
12538 if let Some(plot) = self.state.my_plot_under_player().cloned() {
12539 const SELL_WINDOW: Duration = Duration::from_millis(1200);
12541 let sell_armed = self.state.sell_plot_confirm == Some(plot.plot_id)
12542 && self
12543 .state
12544 .sell_plot_armed_at
12545 .is_some_and(|t| t.elapsed() <= SELL_WINDOW);
12546 if sell_armed {
12547 return self.confirm_sell_plot_to_crown(plot.plot_id).await;
12548 }
12549 self.state.sell_plot_confirm = None;
12550 self.state.sell_plot_armed_at = None;
12551
12552 let blocking_interact = self.state.nearest_interact_target().is_some_and(|id| {
12555 self.state.npcs.iter().any(|n| n.id == id)
12556 || self.state.hired_workers.iter().any(|w| w.instance_id == id)
12557 || self.state.doors.iter().any(|d| d.id == id)
12558 || self.state.interactables.iter().any(|i| {
12559 i.id == id
12560 && matches!(
12561 i.kind.as_str(),
12562 "quest_board" | "well" | "exit" | "enter"
12563 )
12564 })
12565 || id.parse::<EntityId>().is_ok_and(|eid| {
12566 self.state
12567 .entities
12568 .iter()
12569 .any(|e| e.id == eid && e.id != self.state.entity_id)
12570 })
12571 });
12572 if !blocking_interact {
12573 match self.harvest_nearest().await {
12575 Ok(()) => return Ok(()),
12576 Err(err) => {
12577 let msg = err.to_string();
12578 if !(msg.contains("no harvestable")
12579 || msg.contains("press p")
12580 || msg.contains("press f")
12581 || msg.contains("nothing"))
12582 {
12583 return Err(err);
12584 }
12585 }
12586 }
12587 return Ok(());
12588 }
12589 }
12590 if self.state.nearest_interact_target().is_some() {
12591 return self.interact_nearest().await;
12592 }
12593 if let Some((label, dist)) = self.state.nearest_quest_board() {
12596 if dist > QUEST_BOARD_INTERACTION_RADIUS_M && dist <= NEARBY_SCAN_M {
12597 anyhow::bail!(
12598 "too far from {label} ({dist:.1}m) — move within {QUEST_BOARD_INTERACTION_RADIUS_M}m and press f"
12599 );
12600 }
12601 }
12602
12603 match self.harvest_nearest().await {
12604 Ok(()) => Ok(()),
12605 Err(err) => {
12606 let msg = err.to_string();
12607 if msg.contains("no harvestable")
12608 || msg.contains("press p")
12609 || msg.contains("press f")
12610 {
12611 anyhow::bail!(
12612 "nothing to use nearby — stand by an NPC/door, loot (*), chest, resource, or press k on claimable land"
12613 );
12614 }
12615 Err(err)
12616 }
12617 }
12618 }
12619
12620 pub async fn try_begin_claim_mode(&mut self) -> anyhow::Result<()> {
12622 if !self.state.is_alive() {
12623 anyhow::bail!("you are dead");
12624 }
12625 if self.state.claim_mode.is_some() {
12626 anyhow::bail!("already in claim mode — Enter to buy, Esc to cancel");
12627 }
12628 let zone = self
12629 .state
12630 .free_property_zone_under_player()
12631 .ok_or_else(|| anyhow::anyhow!("stand on unclaimed crown land to buy a plot (k)"))?;
12632 let zone_id = zone.id.clone();
12633 let label = zone
12634 .label
12635 .as_deref()
12636 .filter(|s| !s.trim().is_empty())
12637 .unwrap_or(zone.id.as_str())
12638 .to_string();
12639 self.enter_claim_mode(&zone_id);
12640 self.state
12641 .push_log(format!(
12642 "Claim mode: {label} — WASD move · [ ] size · Enter buy · Esc cancel"
12643 ));
12644 Ok(())
12645 }
12646
12647 pub fn enter_claim_mode(&mut self, zone_id: &str) {
12649 let Some(zone) = self
12650 .state
12651 .property_zones
12652 .iter()
12653 .find(|z| z.id == zone_id)
12654 .cloned()
12655 else {
12656 self.state.push_log("unknown property zone");
12657 return;
12658 };
12659 self.state.sell_plot_confirm = None;
12660 self.state.sell_plot_armed_at = None;
12661 let min_area = self
12662 .state
12663 .property_plot_settings
12664 .as_ref()
12665 .map(|s| s.min_plot_area_m2)
12666 .unwrap_or(4.0)
12667 .max(1.0);
12668 let min_side = min_area.sqrt().ceil().max(1.0) as u32;
12669 let side = 4u32.max(min_side);
12670 let (px, py) = self.state.player_position();
12671 let anchor_x = px.floor();
12672 let anchor_y = py.floor();
12673 self.state.claim_mode = Some(ClaimModeState {
12674 zone_id: zone.id.clone(),
12675 width_m: side,
12676 height_m: side,
12677 anchor_x,
12678 anchor_y,
12679 });
12680 let label = zone
12681 .label
12682 .as_deref()
12683 .filter(|s| !s.trim().is_empty())
12684 .unwrap_or(zone.id.as_str());
12685 self.state.push_log(format!(
12686 "Claiming {label} — {side}×{side}m · WASD move · [ ] size · Enter buy · Esc cancel"
12687 ));
12688 }
12689
12690 pub fn cancel_claim_mode(&mut self) {
12691 if self.state.claim_mode.take().is_some() {
12692 self.state.push_log("Claim cancelled");
12693 }
12694 }
12695
12696 pub fn begin_relocate_container(&mut self, container_id: &str) -> anyhow::Result<()> {
12698 if !self.state.is_alive() {
12699 anyhow::bail!("you are dead");
12700 }
12701 if self.state.relocate_mode.is_some() {
12702 anyhow::bail!("already relocating — Enter confirm, Esc cancel");
12703 }
12704 if self.state.claim_mode.is_some() {
12705 anyhow::bail!("finish or cancel claim mode first");
12706 }
12707 let chest = self
12708 .state
12709 .placed_containers
12710 .iter()
12711 .find(|c| c.id == container_id)
12712 .cloned()
12713 .ok_or_else(|| anyhow::anyhow!("chest not found"))?;
12714 let (px, py) = self.state.player_position();
12715 if (chest.x - px).hypot(chest.y - py) > CONTAINER_RANGE_M {
12716 anyhow::bail!("too far from {}", chest.display_name);
12717 }
12718 if chest.locked && !chest.accessible {
12719 anyhow::bail!(
12720 "need the matching key for {} before moving it",
12721 chest.display_name
12722 );
12723 }
12724 let label = if chest.display_name.trim().is_empty() {
12725 chest.template_id.clone()
12726 } else {
12727 chest.display_name.clone()
12728 };
12729 self.state.relocate_mode = Some(RelocateModeState {
12730 container_id: chest.id.clone(),
12731 label: label.clone(),
12732 cursor_x: chest.x.floor() + 0.5,
12733 cursor_y: chest.y.floor() + 0.5,
12734 });
12735 self.state.push_log(format!(
12736 "Relocate {label} — WASD move square · Enter confirm · Esc cancel"
12737 ));
12738 Ok(())
12739 }
12740
12741 pub fn try_begin_relocate_nearest(&mut self) -> anyhow::Result<()> {
12743 let Some(chest) = self.state.nearest_placed_container(CONTAINER_RANGE_M) else {
12744 anyhow::bail!("no chest nearby to relocate");
12745 };
12746 if chest.locked && !chest.accessible {
12747 anyhow::bail!(
12748 "need the matching key for {} before moving it",
12749 chest.display_name
12750 );
12751 }
12752 self.begin_relocate_container(&chest.id)
12755 }
12756
12757 pub fn cancel_relocate_mode(&mut self) {
12758 if self.state.relocate_mode.take().is_some() {
12759 self.state.push_log("Relocate cancelled");
12760 }
12761 }
12762
12763 pub fn relocate_nudge(&mut self, dx: i32, dy: i32) {
12764 let Some(mode) = self.state.relocate_mode.as_mut() else {
12765 return;
12766 };
12767 let max_x = self.state.world_width_m.max(1.0);
12768 let max_y = self.state.world_height_m.max(1.0);
12769 let nx = (mode.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
12770 let ny = (mode.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
12771 mode.cursor_x = nx.floor() + 0.5;
12772 mode.cursor_y = ny.floor() + 0.5;
12773 }
12774
12775 pub fn relocate_set_cursor(&mut self, x: f32, y: f32) {
12776 let Some(mode) = self.state.relocate_mode.as_mut() else {
12777 return;
12778 };
12779 let max_x = self.state.world_width_m.max(1.0);
12780 let max_y = self.state.world_height_m.max(1.0);
12781 mode.cursor_x = x.floor().clamp(0.0, max_x - 1.0) + 0.5;
12782 mode.cursor_y = y.floor().clamp(0.0, max_y - 1.0) + 0.5;
12783 }
12784
12785 pub async fn confirm_relocate_container(&mut self) -> anyhow::Result<()> {
12786 if !self.state.is_alive() {
12787 anyhow::bail!("you are dead");
12788 }
12789 let Some(mode) = self.state.relocate_mode.clone() else {
12790 anyhow::bail!("not relocating");
12791 };
12792 let (px, py) = self.state.player_position();
12793 let dist = (mode.cursor_x - px).hypot(mode.cursor_y - py);
12794 if dist > 8.0 {
12795 anyhow::bail!("destination too far (max 8 m)");
12796 }
12797 self.seq += 1;
12798 self.session
12799 .submit_intent(Intent::MovePlacedContainer {
12800 entity_id: self.state.entity_id,
12801 container_id: mode.container_id.clone(),
12802 x: mode.cursor_x,
12803 y: mode.cursor_y,
12804 seq: self.seq,
12805 })
12806 .await?;
12807 self.state.intents_sent += 1;
12808 self.state.relocate_mode = None;
12809 self.state
12810 .push_log(format!("Moving {}…", mode.label));
12811 Ok(())
12812 }
12813
12814 pub fn claim_set_preset(&mut self, w: u32, h: u32) {
12815 let Some(mode) = self.state.claim_mode.as_mut() else {
12816 return;
12817 };
12818 mode.width_m = w.max(1);
12819 mode.height_m = h.max(1);
12820 }
12821
12822 pub fn claim_nudge(&mut self, dw: i32, dh: i32) {
12823 let Some(mode) = self.state.claim_mode.as_mut() else {
12824 return;
12825 };
12826 let w = (mode.width_m as i32 + dw).max(1) as u32;
12827 let h = (mode.height_m as i32 + dh).max(1) as u32;
12828 mode.width_m = w;
12829 mode.height_m = h;
12830 }
12831
12832 pub fn claim_move_nudge(&mut self, dx: i32, dy: i32) {
12834 let Some(mode) = self.state.claim_mode.as_mut() else {
12835 return;
12836 };
12837 let max_x = self.state.world_width_m.max(1.0);
12838 let max_y = self.state.world_height_m.max(1.0);
12839 let nx = (mode.anchor_x + dx as f32).clamp(0.0, (max_x - 1.0).max(0.0));
12840 let ny = (mode.anchor_y + dy as f32).clamp(0.0, (max_y - 1.0).max(0.0));
12841 mode.anchor_x = nx.floor();
12842 mode.anchor_y = ny.floor();
12843 }
12844
12845 pub async fn confirm_buy_plot(&mut self) -> anyhow::Result<()> {
12846 if !self.state.is_alive() {
12847 anyhow::bail!("you are dead");
12848 }
12849 let Some(mode) = self.state.claim_mode.clone() else {
12850 anyhow::bail!("not in claim mode");
12851 };
12852 let Some((purchase, _upkeep, _area, _prem, can_afford, valid, reason)) =
12853 self.state.claim_quote()
12854 else {
12855 anyhow::bail!("cannot quote claim");
12856 };
12857 if !valid {
12858 anyhow::bail!(reason);
12859 }
12860 if !can_afford {
12861 anyhow::bail!(
12862 "not enough copper (need {})",
12863 crate::currency::format_copper(purchase)
12864 );
12865 }
12866 let (x0, y0, x1, y1) = self
12867 .state
12868 .claim_footprint_rect()
12869 .ok_or_else(|| anyhow::anyhow!("no claim footprint"))?;
12870 let (x0, y0, x1, y1) = snap_claim_rect_client(x0, y0, x1, y1);
12871 self.seq += 1;
12872 self.session
12873 .submit_intent(Intent::BuyPlot {
12874 entity_id: self.state.entity_id,
12875 zone_id: mode.zone_id,
12876 x0,
12877 y0,
12878 x1,
12879 y1,
12880 seq: self.seq,
12881 })
12882 .await?;
12883 self.state.intents_sent += 1;
12884 self.state.claim_mode = None;
12885 self.state
12886 .push_log(format!("Buying plot for {}", crate::currency::format_copper(purchase)));
12887 Ok(())
12888 }
12889
12890 pub async fn confirm_buy_plot_all_free(&mut self) -> anyhow::Result<()> {
12891 if !self.state.is_alive() {
12892 anyhow::bail!("you are dead");
12893 }
12894 let zone_id = self
12895 .state
12896 .claim_mode
12897 .as_ref()
12898 .map(|m| m.zone_id.clone())
12899 .or_else(|| {
12900 self.state
12901 .free_property_zone_under_player()
12902 .map(|z| z.id.clone())
12903 })
12904 .ok_or_else(|| anyhow::anyhow!("no free property zone"))?;
12905 self.seq += 1;
12906 self.session
12907 .submit_intent(Intent::BuyPlotAllFree {
12908 entity_id: self.state.entity_id,
12909 zone_id,
12910 seq: self.seq,
12911 })
12912 .await?;
12913 self.state.intents_sent += 1;
12914 self.state.claim_mode = None;
12915 self.state.push_log("Claiming largest free plot…");
12916 Ok(())
12917 }
12918
12919 pub async fn confirm_sell_plot_to_crown(
12920 &mut self,
12921 plot_id: uuid::Uuid,
12922 ) -> anyhow::Result<()> {
12923 if !self.state.is_alive() {
12924 anyhow::bail!("you are dead");
12925 }
12926 self.seq += 1;
12927 self.session
12928 .submit_intent(Intent::SellPlotToCrown {
12929 entity_id: self.state.entity_id,
12930 plot_id,
12931 seq: self.seq,
12932 })
12933 .await?;
12934 self.state.intents_sent += 1;
12935 self.state.sell_plot_confirm = None;
12936 self.state.sell_plot_armed_at = None;
12937 self.state.push_log("Selling plot to the crown…");
12938 Ok(())
12939 }
12940
12941 pub async fn set_plot_farm_public(
12942 &mut self,
12943 plot_id: uuid::Uuid,
12944 public: bool,
12945 public_tax_discount_bps: u32,
12946 ) -> anyhow::Result<()> {
12947 self.seq += 1;
12948 self.session
12949 .submit_intent(Intent::SetPlotFarmPublic {
12950 entity_id: self.state.entity_id,
12951 plot_id,
12952 public,
12953 public_tax_discount_bps,
12954 seq: self.seq,
12955 })
12956 .await?;
12957 self.state.intents_sent += 1;
12958 Ok(())
12959 }
12960
12961 pub async fn plot_farm_allow_upsert(
12962 &mut self,
12963 plot_id: uuid::Uuid,
12964 character_id: Option<uuid::Uuid>,
12965 character_name: String,
12966 tax_discount_bps: u32,
12967 ) -> anyhow::Result<()> {
12968 self.seq += 1;
12969 self.session
12970 .submit_intent(Intent::PlotFarmAllowUpsert {
12971 entity_id: self.state.entity_id,
12972 plot_id,
12973 character_id,
12974 character_name,
12975 tax_discount_bps,
12976 seq: self.seq,
12977 })
12978 .await?;
12979 self.state.intents_sent += 1;
12980 Ok(())
12981 }
12982
12983 pub async fn plot_farm_allow_remove(
12984 &mut self,
12985 plot_id: uuid::Uuid,
12986 character_id: uuid::Uuid,
12987 ) -> anyhow::Result<()> {
12988 self.seq += 1;
12989 self.session
12990 .submit_intent(Intent::PlotFarmAllowRemove {
12991 entity_id: self.state.entity_id,
12992 plot_id,
12993 character_id,
12994 seq: self.seq,
12995 })
12996 .await?;
12997 self.state.intents_sent += 1;
12998 Ok(())
12999 }
13000
13001 pub fn open_farm_access_panel(&mut self) {
13002 let Some(plot) = self.state.my_plot_under_player() else {
13003 self.state
13004 .push_log("Stand on your deed plot to manage farm access");
13005 return;
13006 };
13007 self.state.farm_access_discount_bps = plot.public_tax_discount_bps;
13008 self.state.farm_access_index = 0;
13009 self.state.show_farm_access = true;
13010 }
13011
13012 pub fn close_farm_access_panel(&mut self) {
13013 self.state.show_farm_access = false;
13014 self.state.farm_access_name_draft.clear();
13015 self.state.farm_access_index = 0;
13016 }
13017
13018 pub fn farm_access_move(&mut self, delta: i32) {
13019 let n = self.farm_access_row_count().max(1);
13020 let idx = self.state.farm_access_index as i32 + delta;
13021 self.state.farm_access_index = idx.rem_euclid(n as i32) as usize;
13022 }
13023
13024 pub fn farm_access_rows(&self) -> Vec<FarmAccessRow> {
13025 let Some(plot) = self.state.my_plot_under_player() else {
13026 return vec![FarmAccessRow::PublicToggle];
13027 };
13028 let mut rows = vec![FarmAccessRow::PublicToggle, FarmAccessRow::PublicDiscount];
13029 for g in &plot.farm_allow {
13030 rows.push(FarmAccessRow::AllowRemove {
13031 character_id: g.character_id,
13032 label: if g.character_label.trim().is_empty() {
13033 g.character_id.to_string()[..8].to_string()
13034 } else {
13035 g.character_label.clone()
13036 },
13037 tax_discount_bps: g.tax_discount_bps,
13038 });
13039 }
13040 for e in &self.state.entities {
13041 if e.id == self.state.entity_id || e.label.trim().is_empty() {
13042 continue;
13043 }
13044 if self.state.hired_workers.iter().any(|w| w.entity_id == e.id) {
13045 continue;
13046 }
13047 if self
13048 .state
13049 .npcs
13050 .iter()
13051 .any(|n| n.id == e.label || n.label == e.label)
13052 {
13053 continue;
13054 }
13055 if plot
13056 .farm_allow
13057 .iter()
13058 .any(|g| !g.character_label.is_empty() && g.character_label == e.label)
13059 {
13060 continue;
13061 }
13062 rows.push(FarmAccessRow::NearbyAdd {
13063 name: e.label.clone(),
13064 });
13065 }
13066 rows
13067 }
13068
13069 pub fn farm_access_row_count(&self) -> usize {
13070 self.farm_access_rows().len().max(1)
13071 }
13072
13073 pub async fn farm_access_activate(&mut self) -> anyhow::Result<()> {
13074 let Some(plot) = self.state.my_plot_under_player().cloned() else {
13075 self.close_farm_access_panel();
13076 return Ok(());
13077 };
13078 let rows = self.farm_access_rows();
13079 let Some(row) = rows.get(self.state.farm_access_index) else {
13080 return Ok(());
13081 };
13082 match row {
13083 FarmAccessRow::PublicToggle => {
13084 self.set_plot_farm_public(
13085 plot.plot_id,
13086 !plot.farm_public,
13087 plot.public_tax_discount_bps,
13088 )
13089 .await
13090 }
13091 FarmAccessRow::PublicDiscount => Ok(()),
13092 FarmAccessRow::AllowRemove { character_id, .. } => {
13093 self.plot_farm_allow_remove(plot.plot_id, *character_id)
13094 .await
13095 }
13096 FarmAccessRow::NearbyAdd { name } => {
13097 let disc = self
13098 .state
13099 .farm_access_discount_bps
13100 .max(plot.public_tax_discount_bps);
13101 self.plot_farm_allow_upsert(plot.plot_id, None, name.clone(), disc)
13102 .await
13103 }
13104 }
13105 }
13106
13107 pub async fn farm_access_adjust_discount(&mut self, delta_bps: i32) -> anyhow::Result<()> {
13108 let Some(plot) = self.state.my_plot_under_player().cloned() else {
13109 return Ok(());
13110 };
13111 let next = (plot.public_tax_discount_bps as i32 + delta_bps).clamp(0, 10_000) as u32;
13112 self.state.farm_access_discount_bps = next;
13113 self.state.farm_access_index = 1;
13114 self.set_plot_farm_public(plot.plot_id, plot.farm_public, next)
13115 .await
13116 }
13117
13118 pub async fn farm_cultivate_underfoot(&mut self) -> anyhow::Result<()> {
13120 if self.state.farmable_plot_under_player().is_none() {
13121 anyhow::bail!("stand on a farmable plot to cultivate");
13122 }
13123 let Some((tx, ty)) = self.state.cultivate_target_under_player() else {
13124 let (px, py) = self.state.player_position();
13125 if self
13126 .state
13127 .terrain_at(px, py)
13128 .is_some_and(|k| k == TerrainKindView::Tilled)
13129 {
13130 anyhow::bail!("already tilled — stand on bare soil and press c");
13131 }
13132 anyhow::bail!("cannot till this cell — move onto soil on your plot");
13133 };
13134 self.cultivate_at(tx, ty).await
13135 }
13136
13137 pub async fn farm_plant_underfoot(&mut self) -> anyhow::Result<()> {
13139 if self.state.farmable_plot_under_player().is_none() {
13140 anyhow::bail!("stand on a farmable plot to plant");
13141 }
13142 if !self.state.underfoot_free_tilled_plant_slot() {
13143 anyhow::bail!("stand on empty tilled soil and press p");
13144 }
13145 let seeds = self.state.farm_seed_entries();
13146 if seeds.is_empty() {
13147 anyhow::bail!("no seeds in inventory — buy seeds from Eli");
13148 }
13149 if seeds.len() == 1 {
13150 return self.plant_seeds(seeds[0].0.clone(), 1).await;
13151 }
13152 self.open_plant_menu();
13153 Ok(())
13154 }
13155
13156 pub fn open_plot_build_menu(&mut self) -> anyhow::Result<()> {
13158 let Some(plot) = self.state.my_plot_under_player() else {
13159 anyhow::bail!("stand on your plot to build");
13160 };
13161 if plot.building_id.is_some() {
13162 anyhow::bail!("this plot already has a building");
13163 }
13164 let building_now = self
13165 .state
13166 .timed_channel
13167 .as_ref()
13168 .is_some_and(|c| c.channel == flatland_protocol::TimedChannelKind::Build);
13169 if !building_now && self.state.building_materials.is_empty() {
13170 anyhow::bail!("no building materials loaded — wait a moment and try again");
13171 }
13172 self.state.show_plot_build_menu = true;
13173 self.state.show_craft_menu = false;
13174 self.state.show_shop_menu = false;
13175 self.state.shop_catalog = None;
13176 self.state.show_stats = false;
13177 self.state.show_inventory_menu = false;
13178 self.state.plot_build_focus_wall = true;
13179 let walls = self.state.plot_build_wall_options().len();
13180 let roofs = self.state.plot_build_roof_options().len();
13181 if walls > 0 {
13182 self.state.plot_build_wall_index = self.state.plot_build_wall_index.min(walls - 1);
13183 } else {
13184 self.state.plot_build_wall_index = 0;
13185 }
13186 if roofs > 0 {
13187 self.state.plot_build_roof_index = self.state.plot_build_roof_index.min(roofs - 1);
13188 } else {
13189 self.state.plot_build_roof_index = 0;
13190 }
13191 Ok(())
13192 }
13193
13194 pub fn close_plot_build_menu(&mut self) {
13195 self.state.show_plot_build_menu = false;
13196 }
13197
13198 pub fn plot_build_menu_move(&mut self, delta: i32) {
13199 let walls = self.state.plot_build_wall_options();
13200 let roofs = self.state.plot_build_roof_options();
13201 if self.state.plot_build_focus_wall {
13202 if walls.is_empty() {
13203 return;
13204 }
13205 let n = walls.len() as i32;
13206 let cur = self.state.plot_build_wall_index as i32;
13207 self.state.plot_build_wall_index = ((cur + delta).rem_euclid(n)) as usize;
13208 } else {
13209 if roofs.is_empty() {
13210 return;
13211 }
13212 let n = roofs.len() as i32;
13213 let cur = self.state.plot_build_roof_index as i32;
13214 self.state.plot_build_roof_index = ((cur + delta).rem_euclid(n)) as usize;
13215 }
13216 }
13217
13218 pub fn plot_build_menu_toggle_focus(&mut self) {
13219 self.state.plot_build_focus_wall = !self.state.plot_build_focus_wall;
13220 }
13221
13222 pub async fn plot_build_menu_confirm(&mut self) -> anyhow::Result<()> {
13224 let wall = self
13225 .state
13226 .plot_build_selected_wall()
13227 .ok_or_else(|| anyhow::anyhow!("pick a wall material"))?
13228 .id
13229 .clone();
13230 let roof = self
13231 .state
13232 .plot_build_selected_roof()
13233 .ok_or_else(|| anyhow::anyhow!("pick a roof material"))?
13234 .id
13235 .clone();
13236 self.start_plot_build(&wall, &roof).await
13238 }
13239
13240 pub async fn plot_build_menu_cancel_build(&mut self) -> anyhow::Result<()> {
13242 self.seq += 1;
13243 self.session
13244 .submit_intent(Intent::CancelPlotBuild {
13245 entity_id: self.state.entity_id,
13246 seq: self.seq,
13247 })
13248 .await?;
13249 self.state.intents_sent += 1;
13250 Ok(())
13251 }
13252
13253 pub async fn start_plot_build(
13255 &mut self,
13256 wall_material_id: &str,
13257 roof_material_id: &str,
13258 ) -> anyhow::Result<()> {
13259 let Some(plot) = self.state.my_plot_under_player() else {
13260 anyhow::bail!("stand on your plot to build");
13261 };
13262 if plot.building_id.is_some() {
13263 anyhow::bail!("this plot already has a building");
13264 }
13265 let plot_id = plot.plot_id;
13266 self.seq += 1;
13267 self.session
13268 .submit_intent(Intent::StartPlotBuild {
13269 entity_id: self.state.entity_id,
13270 plot_id,
13271 wall_material_id: wall_material_id.to_string(),
13272 roof_material_id: roof_material_id.to_string(),
13273 seq: self.seq,
13274 })
13275 .await?;
13276 self.state.intents_sent += 1;
13277 Ok(())
13278 }
13279
13280 pub async fn toggle_nearby_door_lock(&mut self) -> anyhow::Result<()> {
13282 let (px, py) = self.state.player_position();
13283 let mut best: Option<(f32, String, bool)> = None;
13284 for d in &self.state.doors {
13285 if d.lock_id.is_none() {
13286 continue;
13287 }
13288 let dist = (d.x - px).hypot(d.y - py);
13289 if dist > 3.5 {
13290 continue;
13291 }
13292 if best.as_ref().is_none_or(|(bd, _, _)| dist < *bd) {
13293 best = Some((dist, d.id.clone(), d.locked));
13294 }
13295 }
13296 let Some((_, door_id, locked_now)) = best else {
13297 anyhow::bail!("no lockable door nearby");
13298 };
13299 let locked = !locked_now;
13300 self.seq += 1;
13301 self.session
13302 .submit_intent(Intent::SetDoorLocked {
13303 entity_id: self.state.entity_id,
13304 door_id,
13305 locked,
13306 seq: self.seq,
13307 })
13308 .await?;
13309 self.state.intents_sent += 1;
13310 Ok(())
13311 }
13312
13313 pub async fn enter_nearby_open_door(&mut self) -> anyhow::Result<()> {
13315 if !self.state.is_alive() {
13316 anyhow::bail!("you are dead");
13317 }
13318 if self.state.effective_inside_building().is_some() {
13319 anyhow::bail!("already inside");
13320 }
13321 let (px, py) = self.state.player_position();
13322 let mut best: Option<(f32, String)> = None;
13323 for d in &self.state.doors {
13324 if !d.open || d.locked {
13325 continue;
13326 }
13327 let player_house = self
13328 .state
13329 .buildings
13330 .iter()
13331 .find(|b| b.id == d.building_id)
13332 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
13333 if !player_house {
13334 continue;
13335 }
13336 let dist = (d.x - px).hypot(d.y - py);
13337 if dist > 3.5 {
13338 continue;
13339 }
13340 if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
13341 best = Some((dist, d.id.clone()));
13342 }
13343 }
13344 let Some((_, door_id)) = best else {
13345 anyhow::bail!("no open house door nearby — open with f first");
13346 };
13347 self.seq += 1;
13348 self.session
13349 .submit_intent(Intent::EnterBuildingDoor {
13350 entity_id: self.state.entity_id,
13351 door_id,
13352 seq: self.seq,
13353 })
13354 .await?;
13355 self.state.intents_sent += 1;
13356 Ok(())
13357 }
13358
13359 pub async fn exit_nearby_building_door(&mut self) -> anyhow::Result<()> {
13362 if !self.state.is_alive() {
13363 anyhow::bail!("you are dead");
13364 }
13365 let Some(bid) = self.state.effective_inside_building() else {
13366 anyhow::bail!("not inside a building");
13367 };
13368 let (px, py) = self.state.player_position();
13369 let mut best: Option<(f32, String)> = None;
13370 for d in &self.state.doors {
13371 if d.building_id != bid || d.portal.is_none() {
13372 continue;
13373 }
13374 let player_house = self
13375 .state
13376 .buildings
13377 .iter()
13378 .find(|b| b.id == d.building_id)
13379 .is_some_and(|b| b.tags.iter().any(|t| t == "player_built"));
13380 if !player_house {
13381 continue;
13382 }
13383 let dist = (d.x - px).hypot(d.y - py);
13384 if dist > 1.5 {
13385 continue;
13386 }
13387 if best.as_ref().is_none_or(|(bd, _)| dist < *bd) {
13388 best = Some((dist, d.id.clone()));
13389 }
13390 }
13391 let Some((_, door_id)) = best else {
13392 anyhow::bail!("stand by the door to exit");
13393 };
13394 self.seq += 1;
13395 self.session
13396 .submit_intent(Intent::ExitBuildingDoor {
13397 entity_id: self.state.entity_id,
13398 door_id,
13399 seq: self.seq,
13400 })
13401 .await?;
13402 self.state.intents_sent += 1;
13403 Ok(())
13404 }
13405
13406 pub async fn confirm_interior_edit(
13408 &mut self,
13409 building_id: String,
13410 rooms: Vec<flatland_protocol::InteriorRoomEdit>,
13411 room_doors: Vec<flatland_protocol::InteriorRoomDoorEdit>,
13412 ) -> anyhow::Result<()> {
13413 self.seq += 1;
13414 self.session
13415 .submit_intent(Intent::ConfirmInteriorEdit {
13416 entity_id: self.state.entity_id,
13417 building_id,
13418 rooms,
13419 room_doors,
13420 seq: self.seq,
13421 })
13422 .await?;
13423 self.state.intents_sent += 1;
13424 Ok(())
13425 }
13426
13427 pub async fn cultivate_at(&mut self, x: f32, y: f32) -> anyhow::Result<()> {
13428 if !self.state.is_alive() {
13429 anyhow::bail!("you are dead");
13430 }
13431 self.seq += 1;
13432 self.session
13433 .submit_intent(Intent::Cultivate {
13434 entity_id: self.state.entity_id,
13435 x,
13436 y,
13437 seq: self.seq,
13438 })
13439 .await?;
13440 self.state.intents_sent += 1;
13441 Ok(())
13442 }
13443
13444 pub async fn plant_seeds(
13445 &mut self,
13446 seed_template_id: String,
13447 quantity: u32,
13448 ) -> anyhow::Result<()> {
13449 if !self.state.is_alive() {
13450 anyhow::bail!("you are dead");
13451 }
13452 self.seq += 1;
13453 self.session
13454 .submit_intent(Intent::PlantSeeds {
13455 entity_id: self.state.entity_id,
13456 seed_template_id: seed_template_id.clone(),
13457 quantity,
13458 seq: self.seq,
13459 })
13460 .await?;
13461 self.state.intents_sent += 1;
13462 self.state
13463 .push_log(format!("Planting {quantity}× {seed_template_id}…"));
13464 Ok(())
13465 }
13466
13467 pub fn open_plant_menu(&mut self) {
13468 if self.state.farm_seed_entries().is_empty() {
13469 self.state.push_log("No seeds in inventory to plant");
13470 return;
13471 }
13472 self.state.show_plant_menu = true;
13473 self.state.plant_menu_index = 0;
13474 self.state.plant_quantity = 1;
13475 self.state.clamp_plant_menu();
13476 }
13477
13478 pub fn close_plant_menu(&mut self) {
13479 self.state.show_plant_menu = false;
13480 }
13481
13482 pub fn plant_menu_move(&mut self, delta: i32) {
13483 let n = self.state.farm_seed_entries().len();
13484 if n == 0 {
13485 return;
13486 }
13487 let idx = self.state.plant_menu_index as i32 + delta;
13488 self.state.plant_menu_index = idx.clamp(0, (n - 1) as i32) as usize;
13489 self.state.clamp_plant_menu();
13490 }
13491
13492 pub fn plant_menu_adjust_quantity(&mut self, delta: i32) {
13493 let next = self.state.plant_quantity as i32 + delta;
13494 self.state.plant_quantity = next.max(1) as u32;
13495 self.state.clamp_plant_menu();
13496 }
13497
13498 pub fn plant_menu_set_quantity_max(&mut self) {
13499 if let Some((_, max, _)) = self.state.plant_menu_selection() {
13500 self.state.plant_quantity = max;
13501 }
13502 self.state.clamp_plant_menu();
13503 }
13504
13505 pub fn plant_menu_set_quantity_min(&mut self) {
13506 self.state.plant_quantity = 1;
13507 self.state.clamp_plant_menu();
13508 }
13509
13510 pub async fn confirm_plant_menu(&mut self) -> anyhow::Result<()> {
13511 let Some((seed, qty, label)) = self.state.plant_menu_selection() else {
13512 self.close_plant_menu();
13513 anyhow::bail!("no seeds to plant");
13514 };
13515 self.close_plant_menu();
13516 self.plant_seeds(seed, qty).await?;
13517 self.state.push_log(format!("Planted {qty}× {label}"));
13518 Ok(())
13519 }
13520
13521 pub async fn cast_hotbar_ability(&mut self, slot: u8) -> anyhow::Result<()> {
13524 if !self.state.is_alive() {
13525 anyhow::bail!("you are dead");
13526 }
13527 let binding = self
13528 .state
13529 .hotbar_ability(slot)
13530 .ok_or_else(|| anyhow::anyhow!("hotbar {slot} unbound — open loadout (l)"))?
13531 .to_string();
13532 if let Some(template_id) = flatland_protocol::hotbar_consumable_template(&binding) {
13533 let qty = self.state.inventory.get(template_id).copied().unwrap_or(0);
13534 if qty == 0 {
13535 anyhow::bail!("hotbar {slot}: no {template_id} left — restock or rebind (l)");
13536 }
13537 return self.use_item(template_id).await;
13538 }
13539 let ability_id = binding;
13540 if self.state.ability_allows_ground(&ability_id) && self.state.ground_target.is_some() {
13541 return self
13542 .cast_ability(&ability_id, Some(self.state.entity_id))
13543 .await;
13544 }
13545 let is_heal = ability_id == "heal_touch"
13546 || self
13547 .state
13548 .ability_meta
13549 .get(&ability_id)
13550 .map(|meta| meta.is_heal)
13551 .unwrap_or(false);
13552 let target = if is_heal {
13553 Some(
13554 self.state
13555 .target_for_slot(2)
13556 .unwrap_or(self.state.entity_id),
13557 )
13558 } else {
13559 self.state
13560 .target_for_slot(1)
13561 .or_else(|| self.state.target_for_slot(2))
13562 };
13563 let Some(target_id) = target else {
13564 anyhow::bail!("no target — Tab to select, then press the hotbar key");
13565 };
13566 self.cast_ability(&ability_id, Some(target_id)).await
13567 }
13568
13569 pub async fn set_hotbar_slot(
13572 &mut self,
13573 slot: u8,
13574 ability_id: Option<&str>,
13575 ) -> anyhow::Result<()> {
13576 if !self.state.is_alive() {
13577 anyhow::bail!("you are dead");
13578 }
13579 if !(1..=9).contains(&slot) {
13580 anyhow::bail!("hotbar slot must be 1–9");
13581 }
13582 let ability_id = ability_id
13583 .map(str::trim)
13584 .filter(|id| !id.is_empty())
13585 .map(str::to_string);
13586 self.seq += 1;
13587 self.session
13588 .submit_intent(Intent::SetHotbarSlot {
13589 entity_id: self.state.entity_id,
13590 slot,
13591 ability_id: ability_id.clone(),
13592 seq: self.seq,
13593 })
13594 .await?;
13595 self.state.intents_sent += 1;
13596 let idx = (slot - 1) as usize;
13597 if self.state.hotbar.len() < 9 {
13598 self.state.hotbar.resize(9, None);
13599 }
13600 if let Some(slot_mut) = self.state.hotbar.get_mut(idx) {
13601 *slot_mut = ability_id.clone();
13602 }
13603 match ability_id {
13604 Some(id) => {
13605 let label = if let Some(tid) = flatland_protocol::hotbar_consumable_template(&id) {
13606 format!("use {tid}")
13607 } else {
13608 id
13609 };
13610 self.state.push_log(format!("Hotbar {slot} → {label}"))
13611 }
13612 None => self.state.push_log(format!("Hotbar {slot} cleared")),
13613 }
13614 Ok(())
13615 }
13616
13617 pub fn npc_verb_options(&self) -> Vec<&'static str> {
13618 self.state.npc_verb_options()
13619 }
13620
13621 pub async fn confirm_npc_verb(&mut self) -> anyhow::Result<()> {
13622 let Some(npc_id) = self.state.npc_verb_target.clone() else {
13623 return Ok(());
13624 };
13625 let options = self.npc_verb_options();
13626 let choice = options
13627 .get(self.state.npc_verb_index)
13628 .copied()
13629 .unwrap_or("Talk");
13630 self.seq += 1;
13631 match choice {
13632 "Trade" | "Bank" | "Storage" | "Market" => {
13633 self.session
13634 .submit_intent(Intent::Interact {
13635 entity_id: self.state.entity_id,
13636 target_id: npc_id,
13637 seq: self.seq,
13638 })
13639 .await?;
13640 }
13641 _ => {
13642 self.session
13643 .submit_intent(Intent::NpcTalkOpen {
13644 entity_id: self.state.entity_id,
13645 npc_id,
13646 seq: self.seq,
13647 })
13648 .await?;
13649 }
13650 }
13651 self.state.intents_sent += 1;
13652 Ok(())
13653 }
13654
13655 pub async fn npc_talk_send(&mut self) -> anyhow::Result<()> {
13656 let Some(chat) = self.state.npc_chat.clone() else {
13657 return Ok(());
13658 };
13659 let message = chat.input.trim().to_string();
13660 if message.is_empty() || chat.pending {
13661 return Ok(());
13662 }
13663 if let Some(c) = self.state.npc_chat.as_mut() {
13664 c.lines.push(format!("You: {message}"));
13665 c.input.clear();
13666 c.pending = true;
13667 }
13668 self.seq += 1;
13669 self.session
13670 .submit_intent(Intent::NpcTalkSay {
13671 entity_id: self.state.entity_id,
13672 npc_id: chat.npc_id,
13673 message,
13674 seq: self.seq,
13675 })
13676 .await?;
13677 self.state.intents_sent += 1;
13678 Ok(())
13679 }
13680
13681 pub async fn npc_talk_close(&mut self) -> anyhow::Result<()> {
13682 let return_to_verbs = self.state.npc_verb_target.is_some();
13683 let Some(npc_id) = self.state.npc_chat.as_ref().map(|c| c.npc_id.clone()) else {
13684 self.state.show_npc_chat = false;
13685 if return_to_verbs {
13686 self.state.show_npc_verb_menu = true;
13687 }
13688 return Ok(());
13689 };
13690 self.seq += 1;
13691 self.session
13692 .submit_intent(Intent::NpcTalkClose {
13693 entity_id: self.state.entity_id,
13694 npc_id,
13695 seq: self.seq,
13696 })
13697 .await?;
13698 self.state.intents_sent += 1;
13699 self.state.show_npc_chat = false;
13700 self.state.npc_chat = None;
13701 if return_to_verbs {
13702 self.state.show_npc_verb_menu = true;
13703 }
13704 Ok(())
13705 }
13706
13707 pub async fn npc_interaction_back(&mut self) -> anyhow::Result<()> {
13709 if self.state.show_quest_offer
13710 && (self.state.show_npc_chat || self.state.npc_verb_target.is_some())
13711 {
13712 self.quest_offer_decline();
13713 return Ok(());
13714 }
13715 if self.state.show_npc_chat {
13716 return self.npc_talk_close().await;
13717 }
13718 if self.state.show_shop_menu {
13719 return self.back_from_shop_menu().await;
13720 }
13721 if self.state.bank_panel.is_some() {
13722 if !matches!(self.state.bank_ui_mode, BankUiMode::Menu) {
13723 self.bank_transfer_back();
13724 return Ok(());
13725 }
13726 return self.close_bank_panel().await;
13727 }
13728 if self.state.storage_panel.is_some() {
13729 if !matches!(self.state.storage_ui_mode, StorageUiMode::Menu) {
13730 self.storage_ui_back();
13731 return Ok(());
13732 }
13733 return self.close_storage_panel().await;
13734 }
13735 if self.state.market_panel.is_some() {
13736 if !matches!(self.state.market_ui_mode, MarketUiMode::Browse) {
13737 self.market_ui_back();
13738 return Ok(());
13739 }
13740 if self.state.market_buy_confirm.is_some() {
13741 self.state.market_buy_confirm = None;
13742 return Ok(());
13743 }
13744 return self.close_market_panel().await;
13745 }
13746 if self.state.show_npc_verb_menu {
13747 self.state.show_npc_verb_menu = false;
13748 self.state.npc_verb_target = None;
13749 }
13750 Ok(())
13751 }
13752
13753 pub async fn test_damage(&mut self, amount: f32) -> anyhow::Result<()> {
13754 self.seq += 1;
13755 self.session
13756 .submit_intent(Intent::TestDamage {
13757 entity_id: self.state.entity_id,
13758 amount,
13759 seq: self.seq,
13760 })
13761 .await?;
13762 self.state.intents_sent += 1;
13763 Ok(())
13764 }
13765
13766 pub async fn cycle_combat_target(&mut self, reverse: bool) -> anyhow::Result<()> {
13767 self.cycle_combat_target_slot(1, reverse).await
13768 }
13769
13770 pub async fn cycle_combat_target_slot(
13771 &mut self,
13772 slot_index: u8,
13773 reverse: bool,
13774 ) -> anyhow::Result<()> {
13775 if !self.state.is_alive() {
13776 anyhow::bail!("you are dead");
13777 }
13778 let candidates = self.state.candidates_for_slot(slot_index);
13779 if candidates.is_empty() {
13780 anyhow::bail!("no targets nearby");
13781 }
13782 let current = self.state.target_for_slot(slot_index);
13783 let idx = current.and_then(|id| candidates.iter().position(|(eid, _)| *eid == id));
13784 let next_idx = match idx {
13785 None => 0,
13786 Some(i) if reverse => {
13787 if i == 0 {
13788 candidates.len() - 1
13789 } else {
13790 i - 1
13791 }
13792 }
13793 Some(i) => (i + 1) % candidates.len(),
13794 };
13795 if idx == Some(next_idx) && candidates.len() == 1 {
13796 self.clear_combat_target_slot(slot_index).await?;
13797 return Ok(());
13798 }
13799 let (target_id, label) = candidates[next_idx].clone();
13800 self.set_combat_target_slot(slot_index, target_id, &label)
13801 .await
13802 }
13803
13804 pub async fn set_combat_target_slot(
13805 &mut self,
13806 slot_index: u8,
13807 target_id: EntityId,
13808 label: &str,
13809 ) -> anyhow::Result<()> {
13810 if !self.state.is_alive() {
13811 anyhow::bail!("you are dead");
13812 }
13813 self.seq += 1;
13814 self.session
13815 .submit_intent(Intent::SetTargetSlot {
13816 entity_id: self.state.entity_id,
13817 slot_index,
13818 target_id,
13819 seq: self.seq,
13820 })
13821 .await?;
13822 self.state.intents_sent += 1;
13823 if slot_index == 1 {
13824 self.state.combat_target = Some(target_id);
13825 self.state.combat_target_label = Some(label.to_string());
13826 }
13827 self.state
13828 .push_log(format!("Slot {slot_index} target: {label}"));
13829 Ok(())
13830 }
13831
13832 pub async fn set_combat_target(
13833 &mut self,
13834 target_id: EntityId,
13835 label: &str,
13836 ) -> anyhow::Result<()> {
13837 self.set_combat_target_slot(1, target_id, label).await
13838 }
13839
13840 pub async fn clear_combat_target_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
13841 if slot_index == 1 && self.state.combat_target.is_none() {
13842 return Ok(());
13843 }
13844 self.seq += 1;
13845 self.session
13846 .submit_intent(Intent::ClearTargetSlot {
13847 entity_id: self.state.entity_id,
13848 slot_index,
13849 seq: self.seq,
13850 })
13851 .await?;
13852 if slot_index == 1 {
13853 self.state.combat_target = None;
13854 self.state.combat_target_label = None;
13855 }
13856 self.state.intents_sent += 1;
13857 self.state
13858 .push_log(format!("Slot {slot_index} target cleared"));
13859 Ok(())
13860 }
13861
13862 pub async fn clear_combat_target(&mut self) -> anyhow::Result<()> {
13863 self.clear_combat_target_slot(1).await
13864 }
13865
13866 pub async fn advance_rotation(&mut self, slot_index: u8) -> anyhow::Result<()> {
13867 if !self.state.is_alive() {
13868 anyhow::bail!("you are dead");
13869 }
13870 self.seq += 1;
13871 self.session
13872 .submit_intent(Intent::AdvanceRotation {
13873 entity_id: self.state.entity_id,
13874 slot_index,
13875 seq: self.seq,
13876 })
13877 .await?;
13878 self.state.intents_sent += 1;
13879 Ok(())
13880 }
13881
13882 pub async fn assign_slot_preset(
13883 &mut self,
13884 slot_index: u8,
13885 preset_id: &str,
13886 ) -> anyhow::Result<()> {
13887 if !self.state.is_alive() {
13888 anyhow::bail!("you are dead");
13889 }
13890 self.seq += 1;
13891 self.session
13892 .submit_intent(Intent::AssignSlotPreset {
13893 entity_id: self.state.entity_id,
13894 slot_index,
13895 preset_id: preset_id.to_string(),
13896 seq: self.seq,
13897 })
13898 .await?;
13899 self.state.intents_sent += 1;
13900 if let Some(slot) = self
13901 .state
13902 .combat_slots
13903 .iter_mut()
13904 .find(|s| s.slot_index == slot_index)
13905 {
13906 slot.preset_id = Some(preset_id.to_string());
13907 if let Some(preset) = self
13908 .state
13909 .rotation_presets
13910 .iter()
13911 .find(|p| p.id == preset_id)
13912 {
13913 slot.preset_label = Some(preset.label.clone());
13914 slot.rotation = preset.abilities.clone();
13915 slot.rotation_index = 0;
13916 }
13917 }
13918 self.state
13919 .push_log(format!("T{slot_index} loadout → {preset_id}"));
13920 Ok(())
13921 }
13922
13923 pub async fn cast_ability(
13924 &mut self,
13925 ability_id: &str,
13926 target_id: Option<EntityId>,
13927 ) -> anyhow::Result<()> {
13928 if !self.state.is_alive() {
13929 anyhow::bail!("you are dead");
13930 }
13931 let allows_ground = self.state.ability_allows_ground(ability_id);
13932 let requires_ground = self.state.ability_requires_ground(ability_id);
13933 if requires_ground && self.state.ground_target.is_none() {
13934 anyhow::bail!("{ability_id} needs a ground target — Shift+click open ground first");
13935 }
13936 let (resolved_target_id, target_point) = if allows_ground {
13937 if let Some((x, y, z)) = self.state.ground_target {
13938 (
13939 target_id.unwrap_or(self.state.entity_id),
13940 Some(flatland_protocol::AimPoint { x, y, z }),
13941 )
13942 } else {
13943 (
13944 target_id
13945 .or_else(|| self.state.target_for_slot(2))
13946 .or_else(|| self.state.target_for_slot(1))
13947 .unwrap_or(self.state.entity_id),
13948 None,
13949 )
13950 }
13951 } else {
13952 (
13953 target_id
13954 .or_else(|| self.state.target_for_slot(2))
13955 .or_else(|| self.state.target_for_slot(1))
13956 .unwrap_or(self.state.entity_id),
13957 None,
13958 )
13959 };
13960 self.seq += 1;
13961 self.session
13962 .submit_intent(Intent::Cast {
13963 entity_id: self.state.entity_id,
13964 ability_id: ability_id.to_string(),
13965 target_id: resolved_target_id,
13966 target_point,
13967 seq: self.seq,
13968 })
13969 .await?;
13970 self.state.intents_sent += 1;
13971 match target_point {
13972 Some(point) => self.state.push_log(format!(
13973 "Cast {ability_id} → ({:.1}, {:.1})",
13974 point.x, point.y
13975 )),
13976 None => self
13977 .state
13978 .push_log(format!("Cast {ability_id} → {resolved_target_id}")),
13979 }
13980 Ok(())
13981 }
13982
13983 pub async fn upsert_rotation_preset(&mut self, preset: RotationPreset) -> anyhow::Result<()> {
13984 self.seq += 1;
13985 self.session
13986 .submit_intent(Intent::UpsertRotationPreset {
13987 entity_id: self.state.entity_id,
13988 preset: preset.clone(),
13989 seq: self.seq,
13990 })
13991 .await?;
13992 self.state.intents_sent += 1;
13993 if let Some(existing) = self
13994 .state
13995 .rotation_presets
13996 .iter_mut()
13997 .find(|p| p.id == preset.id)
13998 {
13999 *existing = preset.clone();
14000 } else {
14001 self.state.rotation_presets.push(preset.clone());
14002 }
14003 for slot in &mut self.state.combat_slots {
14004 if slot.preset_id.as_deref() == Some(preset.id.as_str()) {
14005 slot.preset_label = Some(preset.label.clone());
14006 slot.rotation = preset.abilities.clone();
14007 }
14008 }
14009 self.state
14010 .push_log(format!("Saved rotation: {}", preset.label));
14011 Ok(())
14012 }
14013
14014 pub async fn delete_rotation_preset(&mut self, preset_id: &str) -> anyhow::Result<()> {
14015 self.seq += 1;
14016 self.session
14017 .submit_intent(Intent::DeleteRotationPreset {
14018 entity_id: self.state.entity_id,
14019 preset_id: preset_id.to_string(),
14020 seq: self.seq,
14021 })
14022 .await?;
14023 self.state.intents_sent += 1;
14024 self.state.rotation_presets.retain(|p| p.id != preset_id);
14025 for slot in &mut self.state.combat_slots {
14026 if slot.preset_id.as_deref() == Some(preset_id) {
14027 slot.preset_id = None;
14028 slot.preset_label = None;
14029 slot.rotation.clear();
14030 slot.rotation_index = 0;
14031 }
14032 }
14033 self.state
14034 .push_log(format!("Deleted rotation: {preset_id}"));
14035 Ok(())
14036 }
14037
14038 pub async fn toggle_auto_attack_slot(&mut self, slot_index: u8) -> anyhow::Result<()> {
14039 if !self.state.is_alive() {
14040 anyhow::bail!("you are dead");
14041 }
14042 let enabled = !self
14043 .state
14044 .combat_slots
14045 .iter()
14046 .find(|s| s.slot_index == slot_index)
14047 .map(|s| s.auto_enabled)
14048 .unwrap_or(false);
14049 self.seq += 1;
14050 self.session
14051 .submit_intent(Intent::SetAutoAttack {
14052 entity_id: self.state.entity_id,
14053 slot_index,
14054 enabled,
14055 seq: self.seq,
14056 })
14057 .await?;
14058 if slot_index == 1 {
14059 self.state.auto_attack = enabled;
14060 }
14061 self.state.intents_sent += 1;
14062 self.state.push_log(format!(
14063 "T{slot_index} auto {}",
14064 if enabled { "ON" } else { "OFF" }
14065 ));
14066 Ok(())
14067 }
14068
14069 pub async fn pickup_nearest(&mut self) -> anyhow::Result<()> {
14070 if !self.state.connected {
14071 anyhow::bail!("not connected");
14072 }
14073 if !self.state.is_alive() {
14074 anyhow::bail!("you are dead");
14075 }
14076 let (px, py) = self.state.player_position();
14077 if self
14078 .state
14079 .ground_drops
14080 .iter()
14081 .all(|d| distance(px, py, d.x, d.y) > INTERACTION_RADIUS_M)
14082 {
14083 anyhow::bail!("no loot within {INTERACTION_RADIUS_M}m — walk onto the * and press f");
14084 }
14085 self.seq += 1;
14086 self.session
14087 .submit_intent(Intent::Pickup {
14088 entity_id: self.state.entity_id,
14089 drop_id: None,
14090 seq: self.seq,
14091 })
14092 .await?;
14093 self.state.intents_sent += 1;
14094 Ok(())
14095 }
14096
14097 pub async fn toggle_auto_attack(&mut self) -> anyhow::Result<()> {
14098 self.toggle_auto_attack_slot(1).await
14099 }
14100
14101 pub async fn dodge(&mut self) -> anyhow::Result<()> {
14102 if !self.state.is_alive() {
14103 anyhow::bail!("you are dead");
14104 }
14105 self.seq += 1;
14106 self.session
14107 .submit_intent(Intent::Dodge {
14108 entity_id: self.state.entity_id,
14109 seq: self.seq,
14110 })
14111 .await?;
14112 self.state.intents_sent += 1;
14113 self.state.push_log("Dodge!");
14114 Ok(())
14115 }
14116
14117 pub async fn lunge(&mut self) -> anyhow::Result<()> {
14118 if !self.state.is_alive() {
14119 anyhow::bail!("you are dead");
14120 }
14121 let (forward, strafe) = self.last_move_axes();
14122 self.seq += 1;
14123 self.session
14124 .submit_intent(Intent::Lunge {
14125 entity_id: self.state.entity_id,
14126 forward,
14127 strafe,
14128 seq: self.seq,
14129 })
14130 .await?;
14131 self.state.intents_sent += 1;
14132 self.state.push_log("Lunge!");
14133 Ok(())
14134 }
14135
14136 pub async fn directional_jump(&mut self, forward: f32, strafe: f32) -> anyhow::Result<()> {
14137 if !self.state.is_alive() {
14138 anyhow::bail!("you are dead");
14139 }
14140 self.seq += 1;
14141 self.session
14142 .submit_intent(Intent::DirectionalJump {
14143 entity_id: self.state.entity_id,
14144 forward,
14145 strafe,
14146 seq: self.seq,
14147 })
14148 .await?;
14149 self.state.intents_sent += 1;
14150 self.state.push_log("Jump!");
14151 Ok(())
14152 }
14153
14154 pub fn last_move_axes(&self) -> (f32, f32) {
14156 (self.last_move_forward, self.last_move_strafe)
14157 }
14158
14159 pub async fn set_block(&mut self, enabled: bool) -> anyhow::Result<()> {
14160 if !self.state.is_alive() {
14161 anyhow::bail!("you are dead");
14162 }
14163 self.seq += 1;
14164 self.session
14165 .submit_intent(Intent::Block {
14166 entity_id: self.state.entity_id,
14167 enabled,
14168 seq: self.seq,
14169 })
14170 .await?;
14171 self.state.intents_sent += 1;
14172 if enabled {
14173 self.state.push_log("Blocking");
14174 }
14175 Ok(())
14176 }
14177
14178 pub async fn equip_mainhand(&mut self, template_id: Option<String>) -> anyhow::Result<()> {
14179 if !self.state.is_alive() {
14180 anyhow::bail!("you are dead");
14181 }
14182 self.seq += 1;
14183 self.session
14184 .submit_intent(Intent::EquipMainhand {
14185 entity_id: self.state.entity_id,
14186 template_id,
14187 instance_id: None,
14188 seq: self.seq,
14189 })
14190 .await?;
14191 self.state.intents_sent += 1;
14192 Ok(())
14193 }
14194
14195 pub async fn activate_equip_selection(&mut self) -> anyhow::Result<()> {
14197 let idx = self.state.equip_menu_index;
14198 let slots = equip_paperdoll_rows(&self.state);
14199 let Some(row) = slots.get(idx) else {
14200 return Ok(());
14201 };
14202 match row {
14203 EquipPaperdollRow::Body { slot, filled } => {
14204 if *filled {
14205 self.equip_worn(*slot, None).await
14206 } else if let Some(inst) = first_inventory_for_slot(&self.state, *slot) {
14207 self.equip_worn(*slot, Some(inst)).await
14208 } else {
14209 self.state.push_log(format!("No item for {}", body_slot_label(*slot)));
14210 Ok(())
14211 }
14212 }
14213 EquipPaperdollRow::Mainhand { filled } => {
14214 if *filled {
14215 self.unequip_mainhand().await
14216 } else if let Some(tid) = first_inventory_weapon(&self.state) {
14217 self.equip_mainhand(Some(tid)).await
14218 } else {
14219 self.state.push_log("No weapon in inventory".to_string());
14220 Ok(())
14221 }
14222 }
14223 EquipPaperdollRow::Offhand { filled, locked } => {
14224 if *locked {
14225 self.state
14226 .push_log("Offhand locked — two-handed weapon equipped".to_string());
14227 Ok(())
14228 } else if *filled {
14229 self.unequip_offhand().await
14230 } else if let Some(tid) = first_inventory_offhand(&self.state) {
14231 self.equip_offhand(Some(tid)).await
14232 } else {
14233 self.state
14234 .push_log("No offhand item in inventory".to_string());
14235 Ok(())
14236 }
14237 }
14238 }
14239 }
14240
14241 pub async fn say(
14242 &mut self,
14243 channel: flatland_protocol::ChatChannel,
14244 text: &str,
14245 ) -> anyhow::Result<()> {
14246 self.say_to(channel, text, None).await
14247 }
14248
14249 pub async fn say_to(
14250 &mut self,
14251 channel: flatland_protocol::ChatChannel,
14252 text: &str,
14253 to_entity: Option<EntityId>,
14254 ) -> anyhow::Result<()> {
14255 self.seq += 1;
14256 self.session
14257 .submit_intent(Intent::Say {
14258 entity_id: self.state.entity_id,
14259 channel,
14260 text: text.to_string(),
14261 to_entity,
14262 seq: self.seq,
14263 })
14264 .await?;
14265 self.state.intents_sent += 1;
14266 Ok(())
14267 }
14268
14269 pub async fn confirm_player_verb(&mut self) -> anyhow::Result<()> {
14270 let Some(peer) = self.state.player_verbs.target_entity else {
14271 return Ok(());
14272 };
14273 let label = self.state.player_verbs.target_label.clone();
14274 let choice = crate::social::PlayerVerbState::options()
14275 .get(self.state.player_verbs.index)
14276 .copied()
14277 .unwrap_or("Whisper");
14278 self.state.player_verbs.close();
14279 match choice {
14280 "Trade" => {
14281 self.seq += 1;
14284 self.session
14285 .submit_intent(Intent::TradeRequest {
14286 entity_id: self.state.entity_id,
14287 peer_entity_id: peer,
14288 seq: self.seq,
14289 })
14290 .await?;
14291 self.state.intents_sent += 1;
14292 self.state
14293 .social_chat
14294 .push_system(format!("Trade request sent to {label} — waiting for accept"));
14295 }
14296 "Whisper" => self.state.social_chat.focus_whisper(peer, &label),
14297 _ => self.state.social_chat.focus_nearby(),
14298 }
14299 Ok(())
14300 }
14301
14302 pub async fn respond_pending_trade(&mut self, accept: bool) -> anyhow::Result<()> {
14303 let Some(pending) = self.state.social_chat.pending_trade.take() else {
14304 return Ok(());
14305 };
14306 self.seq += 1;
14307 self.session
14308 .submit_intent(Intent::TradeRespond {
14309 entity_id: self.state.entity_id,
14310 peer_entity_id: pending.from_entity,
14311 accept,
14312 seq: self.seq,
14313 })
14314 .await?;
14315 self.state.intents_sent += 1;
14316 if accept {
14317 self.state
14318 .social_chat
14319 .push_system(format!("Accepted trade with {}", pending.from_name));
14320 } else {
14321 self.state
14322 .social_chat
14323 .push_system(format!("Declined trade with {}", pending.from_name));
14324 }
14325 Ok(())
14326 }
14327
14328 pub async fn submit_social_chat_buffer(&mut self) -> anyhow::Result<()> {
14329 let text = self.state.social_chat.buffer.trim().to_string();
14330 if text.is_empty() {
14331 return Ok(());
14332 }
14333 self.state.social_chat.buffer.clear();
14334 if crate::social::is_chat_slash_line(&text) {
14335 match crate::social::parse_chat_slash(&text) {
14336 Some(cmd) => return self.apply_chat_slash(cmd).await,
14337 None => {
14338 self.state.social_chat.push_system(format!(
14339 "Unknown command — {}",
14340 crate::social::chat_slash_help_text()
14341 ));
14342 return Ok(());
14343 }
14344 }
14345 }
14346 let thread = self.state.social_chat.thread;
14347 let channel = thread.channel();
14348 let to = thread.to_entity();
14349 if let Some(peer) = to {
14350 let label = self.state.social_chat.peer_label.clone();
14351 self.state
14352 .social_chat
14353 .remember_whisper_peer(peer, &label, channel);
14354 }
14355 self.say_to(channel, &text, to).await
14356 }
14357
14358 async fn apply_chat_slash(
14359 &mut self,
14360 cmd: crate::social::ChatSlashCommand,
14361 ) -> anyhow::Result<()> {
14362 use crate::social::{chat_slash_help_text, ChatSlashCommand};
14363 match cmd {
14364 ChatSlashCommand::Help => {
14365 self.state
14366 .social_chat
14367 .push_system(chat_slash_help_text().to_string());
14368 Ok(())
14369 }
14370 ChatSlashCommand::Nearby { message } => {
14371 self.state.social_chat.focus_nearby();
14372 self.state
14373 .social_chat
14374 .push_system("Nearby speech — everyone close can hear");
14375 if let Some(msg) = message {
14376 self.say_to(flatland_protocol::ChatChannel::Nearby, &msg, None)
14377 .await
14378 } else {
14379 Ok(())
14380 }
14381 }
14382 ChatSlashCommand::Reply { message } => {
14383 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
14384 self.state.social_chat.push_system(
14385 "No one to reply to — wait for a whisper, or /whisper Name",
14386 );
14387 return Ok(());
14388 };
14389 let stone = peer.channel == flatland_protocol::ChatChannel::WhisperStone;
14390 self.state
14391 .social_chat
14392 .set_whisper_thread(peer.entity_id, &peer.label, stone);
14393 self.state.social_chat.push_system(format!(
14394 "Replying to {} — type and Enter · /nearby",
14395 peer.label
14396 ));
14397 if let Some(msg) = message {
14398 self.say_to(peer.channel, &msg, Some(peer.entity_id)).await
14399 } else {
14400 Ok(())
14401 }
14402 }
14403 ChatSlashCommand::Whisper { name, message } => {
14404 let (peer_id, label, stone) = if let Some(name) = name {
14405 match self.resolve_whisper_target(&name) {
14406 Ok(t) => t,
14407 Err(err) => {
14408 self.state.social_chat.push_system(err);
14409 return Ok(());
14410 }
14411 }
14412 } else {
14413 let Some(peer) = self.state.social_chat.last_whisper_peer.clone() else {
14414 self.state.social_chat.push_system(
14415 "Usage: /whisper Name [message] · or /reply after someone whispers you",
14416 );
14417 return Ok(());
14418 };
14419 (
14420 peer.entity_id,
14421 peer.label,
14422 peer.channel == flatland_protocol::ChatChannel::WhisperStone,
14423 )
14424 };
14425 self.state
14426 .social_chat
14427 .set_whisper_thread(peer_id, &label, stone);
14428 let channel = if stone {
14429 flatland_protocol::ChatChannel::WhisperStone
14430 } else {
14431 flatland_protocol::ChatChannel::Whisper
14432 };
14433 if let Some(msg) = message {
14434 self.state.social_chat.push_system(format!(
14435 "Whisper → {label}"
14436 ));
14437 self.say_to(channel, &msg, Some(peer_id)).await
14438 } else {
14439 self.state.social_chat.push_system(format!(
14440 "Whispering {label} — type and Enter · Esc / /nearby cancels"
14441 ));
14442 Ok(())
14443 }
14444 }
14445 }
14446 }
14447
14448 fn resolve_whisper_target(
14450 &self,
14451 name: &str,
14452 ) -> Result<(EntityId, String, bool), String> {
14453 let needle = name.trim().to_ascii_lowercase();
14454 if needle.is_empty() {
14455 return Err("Usage: /whisper Name [message]".into());
14456 }
14457 let mut candidates: Vec<(EntityId, String)> = self
14458 .state
14459 .entities
14460 .iter()
14461 .filter(|e| e.id != self.state.entity_id)
14462 .filter(|e| !e.label.trim().is_empty())
14463 .filter(|e| e.vitals.is_some())
14464 .filter(|e| {
14465 !self
14466 .state
14467 .npcs
14468 .iter()
14469 .any(|n| n.id == e.id.to_string())
14470 })
14471 .filter(|e| {
14472 !self
14473 .state
14474 .hired_workers
14475 .iter()
14476 .any(|w| w.entity_id == e.id)
14477 })
14478 .map(|e| (e.id, e.label.clone()))
14479 .collect();
14480
14481 if let Some(last) = &self.state.social_chat.last_whisper_peer {
14483 if !candidates.iter().any(|(id, _)| *id == last.entity_id) {
14484 candidates.push((last.entity_id, last.label.clone()));
14485 }
14486 }
14487
14488 let exact: Vec<_> = candidates
14489 .iter()
14490 .filter(|(_, label)| label.eq_ignore_ascii_case(name.trim()))
14491 .cloned()
14492 .collect();
14493 let pool = if exact.len() == 1 {
14494 exact
14495 } else if exact.len() > 1 {
14496 return Err(format!(
14497 "Several players named '{name}' nearby — move closer and try again"
14498 ));
14499 } else {
14500 let starts: Vec<_> = candidates
14501 .iter()
14502 .filter(|(_, label)| label.to_ascii_lowercase().starts_with(&needle))
14503 .cloned()
14504 .collect();
14505 if starts.len() == 1 {
14506 starts
14507 } else if starts.len() > 1 {
14508 let names: Vec<_> = starts.iter().map(|(_, l)| l.as_str()).collect();
14509 return Err(format!(
14510 "Ambiguous name '{name}' — matches: {}",
14511 names.join(", ")
14512 ));
14513 } else {
14514 let contains: Vec<_> = candidates
14515 .iter()
14516 .filter(|(_, label)| label.to_ascii_lowercase().contains(&needle))
14517 .cloned()
14518 .collect();
14519 if contains.len() == 1 {
14520 contains
14521 } else if contains.is_empty() {
14522 return Err(format!(
14523 "No player matching '{name}' in range — get closer or check the spelling"
14524 ));
14525 } else {
14526 let names: Vec<_> = contains.iter().map(|(_, l)| l.as_str()).collect();
14527 return Err(format!(
14528 "Ambiguous name '{name}' — matches: {}",
14529 names.join(", ")
14530 ));
14531 }
14532 }
14533 };
14534
14535 let (id, label) = pool.into_iter().next().unwrap();
14536 let stone = self
14537 .state
14538 .social_chat
14539 .last_whisper_peer
14540 .as_ref()
14541 .is_some_and(|p| p.entity_id == id && p.channel == flatland_protocol::ChatChannel::WhisperStone);
14542 Ok((id, label, stone))
14543 }
14544
14545 pub async fn trade_present_selected(
14546 &mut self,
14547 item_instance_id: uuid::Uuid,
14548 ) -> anyhow::Result<()> {
14549 self.trade_present_quantity(item_instance_id, None).await
14550 }
14551
14552 pub async fn trade_present_quantity(
14553 &mut self,
14554 item_instance_id: uuid::Uuid,
14555 quantity: Option<u32>,
14556 ) -> anyhow::Result<()> {
14557 self.seq += 1;
14558 self.session
14559 .submit_intent(Intent::TradePresent {
14560 entity_id: self.state.entity_id,
14561 item_instance_id,
14562 quantity,
14563 seq: self.seq,
14564 })
14565 .await?;
14566 self.state.intents_sent += 1;
14567 self.state.trade_ui.qty_entry = None;
14568 self.state.trade_ui.picking_inventory = false;
14569 Ok(())
14570 }
14571
14572 pub async fn trade_confirm_qty_or_present(&mut self) -> anyhow::Result<()> {
14574 if let Some(entry) = self.state.trade_ui.qty_entry.clone() {
14575 let qty = self.state.trade_ui.present_quantity();
14576 return self
14577 .trade_present_quantity(entry.item_instance_id, qty)
14578 .await;
14579 }
14580 if !self.state.trade_ui.picking_inventory {
14581 return Ok(());
14582 }
14583 let stacks = self.state.trade_presentable_stacks();
14584 let Some(stack) = stacks.get(self.state.trade_ui.inventory_index).copied() else {
14585 return Ok(());
14586 };
14587 let Some(id) = stack.item_instance_id else {
14588 return Ok(());
14589 };
14590 let label = stack
14591 .display_name
14592 .clone()
14593 .unwrap_or_else(|| stack.template_id.clone());
14594 if stack.quantity <= 1 {
14595 self.trade_present_quantity(id, Some(1)).await
14596 } else {
14597 self.state
14598 .trade_ui
14599 .begin_qty_entry(id, label, stack.quantity);
14600 Ok(())
14601 }
14602 }
14603
14604 pub async fn trade_set_ready(&mut self, ready: bool) -> anyhow::Result<()> {
14605 self.seq += 1;
14606 self.session
14607 .submit_intent(Intent::TradeSetReady {
14608 entity_id: self.state.entity_id,
14609 ready,
14610 seq: self.seq,
14611 })
14612 .await?;
14613 self.state.intents_sent += 1;
14614 Ok(())
14615 }
14616
14617 pub async fn trade_cancel(&mut self) -> anyhow::Result<()> {
14618 self.seq += 1;
14619 self.session
14620 .submit_intent(Intent::TradeCancel {
14621 entity_id: self.state.entity_id,
14622 seq: self.seq,
14623 })
14624 .await?;
14625 self.state.intents_sent += 1;
14626 self.state.trade_ui.close();
14627 Ok(())
14628 }
14629
14630 pub async fn destroy_whisper_stone(
14631 &mut self,
14632 item_instance_id: uuid::Uuid,
14633 ) -> anyhow::Result<()> {
14634 self.seq += 1;
14635 self.session
14636 .submit_intent(Intent::DestroyWhisperStone {
14637 entity_id: self.state.entity_id,
14638 item_instance_id,
14639 seq: self.seq,
14640 })
14641 .await?;
14642 self.state.intents_sent += 1;
14643 Ok(())
14644 }
14645
14646 pub async fn stop(&mut self) -> anyhow::Result<()> {
14647 self.seq += 1;
14648 self.session
14649 .submit_intent(Intent::Stop {
14650 entity_id: self.state.entity_id,
14651 seq: self.seq,
14652 })
14653 .await?;
14654 self.state.intents_sent += 1;
14655 Ok(())
14656 }
14657
14658 pub fn disconnect(&self) {
14659 self.session.disconnect();
14660 }
14661}
14662
14663fn distance(ax: f32, ay: f32, bx: f32, by: f32) -> f32 {
14664 let dx = ax - bx;
14665 let dy = ay - by;
14666 (dx * dx + dy * dy).sqrt()
14667}
14668
14669#[cfg(test)]
14670mod tests {
14671 use std::collections::BTreeMap;
14672
14673 use super::*;
14674 use flatland_protocol::{
14675 BuildingView, ResourceNodeState, ResourceNodeView, TickDelta, Transform, WorldCoord,
14676 };
14677
14678 fn sample_state() -> GameState {
14679 let mut state = GameState {
14680 session_id: 1,
14681 entity_id: 1,
14682 character_id: None,
14683 tick: 0,
14684 chunk_rev: 0,
14685 content_rev: 0,
14686 publish_rev: 0,
14687 entities: vec![EntityState {
14688 id: 1,
14689 label: "You".into(),
14690 transform: Transform {
14691 position: WorldCoord::surface(128.0, 128.0),
14692 yaw: 0.0,
14693 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
14694 },
14695 vitals: None,
14696 attributes: None,
14697 skills: None,
14698 inside_building: None,
14699 tile_id: None,
14700 paperdoll_ref: None,
14701 presentation_state: None,
14702 sprite_mode: None,
14703 progression_xp: None,
14704 combat_cues: vec![],
14705 statuses: vec![],
14706 }],
14707 player: None,
14708 resource_nodes: vec![ResourceNodeView {
14709 id: "oak-1".into(),
14710 label: "Oak".into(),
14711 x: 130.0,
14712 y: 128.0,
14713 z: 0.0,
14714 item_template: "oak_log".into(),
14715 state: ResourceNodeState::Available,
14716 blocking: true,
14717 blocking_radius_m: 0.8,
14718 harvest_off: false,
14719 tile_id: None,
14720 yaw: 0.0,
14721 pitch: 0.0,
14722 roll: 0.0,
14723 draw_scale: 1.0,
14724 sprite_mode: None,
14725 growth_progress: None,
14726 presentation_state: None,
14727 channel_start_tick: None,
14728 channel_end_tick: None,
14729 harvest_drop_templates: vec![],
14730 }],
14731 ground_drops: vec![],
14732 placed_containers: vec![],
14733 buildings: vec![BuildingView {
14734 id: "broker-hut".into(),
14735 label: "Broker".into(),
14736 x: 148.0,
14737 y: 118.0,
14738 width_m: 8.0,
14739 depth_m: 6.0,
14740 interior_blueprint: Some("broker_hut".into()),
14741 tags: vec![],
14742 market_boundary_zone_ids: vec![],
14743 market_max_volume: None,
14744 wall_set: None,
14745 roof_set: None,
14746 }],
14747 doors: vec![flatland_protocol::DoorView {
14748 id: "door-1".into(),
14749 building_id: "broker-hut".into(),
14750 x: 148.0,
14751 y: 118.0,
14752 open: false,
14753 portal: Some("front".into()),
14754 locked: false,
14755 accessible: true,
14756 lock_id: None,
14757 }],
14758 interior_map: None,
14759 npcs: vec![],
14760 blueprints: vec![],
14761 building_materials: vec![],
14762 world_x0: 0.0,
14763 world_y0: 0.0,
14764 world_width_m: 256.0,
14765 world_height_m: 256.0,
14766 terrain_zones: Vec::new(),
14767 z_platforms: Vec::new(),
14768 z_transitions: Vec::new(),
14769 z_bands_outdoor_backup: None,
14770 world_clock: flatland_protocol::WorldClock::default(),
14771 inventory: std::collections::HashMap::new(),
14772 inventory_hints: std::collections::HashMap::new(),
14773 logs: VecDeque::new(),
14774 intents_sent: 0,
14775 ticks_received: 0,
14776 connected: true,
14777 disconnect_reason: None,
14778 show_stats: false,
14779 hud_log_hidden: false,
14780 show_equip_menu: false,
14781 equip_menu_index: 0,
14782 show_craft_menu: false,
14783 show_plot_build_menu: false,
14784 plot_build_focus_wall: true,
14785 plot_build_wall_index: 0,
14786 plot_build_roof_index: 0,
14787 craft_menu_index: 0,
14788 craft_batch_quantity: 1,
14789 show_shop_menu: false,
14790 shop_catalog: None,
14791 bank_panel: None,
14792 bank_menu_index: 0,
14793 bank_ui_mode: BankUiMode::Menu,
14794 storage_panel: None,
14795 market_panel: None,
14796 market_menu_index: 0,
14797 market_filter: String::new(),
14798 market_filter_focused: false,
14799 market_category_filter: None,
14800 market_buy_confirm: None,
14801 market_ui_mode: MarketUiMode::Browse,
14802 storage_menu_index: 0,
14803 storage_ui_mode: StorageUiMode::Menu,
14804 shop_tab: ShopTab::default(),
14805 shop_menu_index: 0,
14806 shop_quantity: 1,
14807 shop_trade_log: VecDeque::new(),
14808 show_npc_verb_menu: false,
14809 npc_verb_target: None,
14810 npc_verb_index: 0,
14811 player_verbs: crate::social::PlayerVerbState::default(),
14812 social_chat: crate::social::SocialChatState::default(),
14813 trade_ui: crate::social::TradeUiState::default(),
14814 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
14815 show_npc_chat: false,
14816 npc_chat: None,
14817 show_inventory_menu: false,
14818 inventory_menu_index: 0,
14819 inventory_tab: InventoryTab::OnPerson,
14820 inventory_filter: String::new(),
14821 inventory_filter_focused: false,
14822 show_move_picker: false,
14823 show_rename_prompt: false,
14824 show_worker_rename: false,
14825 rename_buffer: String::new(),
14826 move_picker_index: 0,
14827 move_picker: None,
14828 show_grant_picker: false,
14829 grant_picker_index: 0,
14830 grant_picker: None,
14831 show_destroy_picker: false,
14832 destroy_confirm_pending: false,
14833 destroy_picker: None,
14834 combat_target: None,
14835 combat_target_label: None,
14836 ground_target: None,
14837 combat_fx: Vec::new(),
14838 property_zones: Vec::new(),
14839 tax_zones: Vec::new(),
14840 growth_zones: Vec::new(),
14841 biome_zones: Vec::new(),
14842 terrain_kind_nav: Vec::new(),
14843 property_plots: Vec::new(),
14844 property_plot_settings: None,
14845 claim_mode: None,
14846 relocate_mode: None,
14847 sell_plot_confirm: None,
14848 sell_plot_armed_at: None,
14849 show_plant_menu: false,
14850 plant_menu_index: 0,
14851 show_farm_access: false,
14852 farm_access_name_draft: String::new(),
14853 farm_access_discount_bps: 0,
14854 farm_access_index: 0,
14855 plant_quantity: 1,
14856 in_combat: false,
14857 auto_attack: true,
14858 combat_has_los: false,
14859 attack_cd_ticks: 0,
14860 gcd_ticks: 0,
14861 weapon_ability_id: "unarmed".into(),
14862 mainhand_template_id: None,
14863 mainhand_label: None,
14864 mainhand_instance_id: None,
14865 offhand_template_id: None,
14866 offhand_label: None,
14867 offhand_instance_id: None,
14868 mainhand_hand_slots: 1,
14869 defense: None,
14870 worn: BTreeMap::new(),
14871 carry_mass: 0.0,
14872 carry_mass_max: 0.0,
14873 encumbrance: flatland_protocol::EncumbranceState::Light,
14874 inventory_stacks: Vec::new(),
14875 keychain_stacks: Vec::new(),
14876 whisper_pouch_stacks: Vec::new(),
14877 combat_target_detail: None,
14878 statuses: Vec::new(),
14879 cast_progress: None,
14880 timed_channel: None,
14881 plot_build_offer: None,
14882 ability_cooldowns: Vec::new(),
14883 blocking_active: false,
14884 max_target_slots: 1,
14885 combat_slots: Vec::new(),
14886 rotation_presets: Vec::new(),
14887 known_abilities: Vec::new(),
14888 ability_meta: std::collections::HashMap::new(),
14889 ability_mastery: std::collections::HashMap::new(),
14890 hotbar: vec![None; 9],
14891 max_abilities_per_rotation: 0,
14892 show_loadout_menu: false,
14893 show_keychain_menu: false,
14894 keychain_menu_index: 0,
14895 show_rotation_editor: false,
14896 loadout_menu_index: 0,
14897 loadout_hotbar_slot: 1,
14898 loadout_ability_index: 0,
14899 loadout_focus_presets: false,
14900 rotation_editor: RotationEditorState::default(),
14901 harvest_in_progress: false,
14902 harvest_started_at: None,
14903 pending_craft_ack: None,
14904 pending_worker_job_ack: None,
14905 attending_worker_instance_id: None,
14906 quest_log: Vec::new(),
14907 interactables: Vec::new(),
14908 ledger: None,
14909 career: None,
14910 character_sheet_tab: CharacterSheetTab::Character,
14911 ledger_period: LedgerPeriod::Day,
14912 show_quest_offer: false,
14913 pending_quest_offer: None,
14914 show_quest_menu: false,
14915 quest_menu_index: 0,
14916 quest_withdraw_confirm: false,
14917 hired_workers: Vec::new(),
14918 show_workers_menu: false,
14919 workers_menu_index: 0,
14920 workers_menu_compact: false,
14921 worker_step_display: BTreeMap::new(),
14922 worker_error_display: BTreeMap::new(),
14923 show_worker_give_picker: false,
14924 worker_give_picker_index: 0,
14925 worker_give_picker: None,
14926 show_worker_give_target_picker: false,
14927 worker_give_target_picker_index: 0,
14928 worker_give_target_picker: None,
14929 show_worker_take_picker: false,
14930 worker_take_picker_index: 0,
14931 worker_take_picker: None,
14932 show_worker_teach_picker: false,
14933 worker_teach_picker_index: 0,
14934 worker_teach_picker: None,
14935 worker_route_editor: None,
14936 progression_curve: None,
14937 };
14938 state.player = state.entities.first().cloned();
14939 state
14940 }
14941
14942 #[test]
14943 fn whisper_cancels_when_peer_walks_out_of_range() {
14944 let mut state = sample_state();
14945 state.player = state.entities.first().cloned();
14946 let mut peer = state.entities[0].clone();
14947 peer.id = 2;
14948 peer.label = "Ada".into();
14949 peer.transform.position = WorldCoord::surface(129.0, 128.0); state.entities.push(peer.clone());
14951 state.social_chat.focus_whisper(2, "Ada");
14952 state.refresh_whisper_range();
14953 assert!(matches!(
14954 state.social_chat.thread,
14955 crate::social::ChatThreadKind::Whisper { peer: 2 }
14956 ));
14957
14958 peer.transform.position = WorldCoord::surface(132.0, 128.0); state.entities[1] = peer;
14960 state.refresh_whisper_range();
14961 assert_eq!(
14962 state.social_chat.thread,
14963 crate::social::ChatThreadKind::Nearby
14964 );
14965 assert!(!state.social_chat.input_focused);
14966 }
14967
14968 #[test]
14969 fn probe_use_world_hired_worker_manage() {
14970 let mut state = sample_state();
14971 state.hired_workers.push(flatland_protocol::HiredWorkerView {
14972 instance_id: "worker-1".into(),
14973 entity_id: 42,
14974 def_id: "worker_laborer".into(),
14975 label: "Sam".into(),
14976 x: 129.0,
14977 y: 128.0,
14978 z: 0.0,
14979 mode: flatland_protocol::WorkerModeView::JobLoop,
14980 state: flatland_protocol::WorkerStateView::Working,
14981 step_label: "cultivate".into(),
14982 vitals: flatland_protocol::WorkerVitalsSummary {
14983 health_pct: 100.0,
14984 stamina_pct: 100.0,
14985 },
14986 carry_pct: 0.0,
14987 last_error: None,
14988 wage_copper_per_interval: 1,
14989 effective_wage_copper: 1,
14990 wage_meters_walked: 0.0,
14991 lodging_container_id: None,
14992 route: None,
14993 route_stop_index: None,
14994 known_blueprint_ids: Vec::new(),
14995 level: 1,
14996 worker_xp: 0.0,
14997 inventory: Vec::new(),
14998 });
14999 let probe = state.probe_use_world();
15000 let primary = probe.primary.expect("primary");
15001 assert_eq!(primary.kind, crate::UseWorldKind::HiredWorker);
15002 assert_eq!(primary.id, "worker-1");
15003 assert!(primary.hint_line().contains("Manage"));
15004 assert!(primary.hint_line().contains("Sam"));
15005 assert_eq!(
15006 state.nearest_interact_target().as_deref(),
15007 Some("worker-1")
15008 );
15009 }
15010
15011 #[test]
15012 fn market_clerk_verb_options_include_market() {
15013 let mut state = sample_state();
15014 state.npcs.push(flatland_protocol::NpcView {
15015 id: "mira_market".into(),
15016 label: "Mira".into(),
15017 role: "market_clerk".into(),
15018 x: 129.0,
15019 y: 128.0,
15020 building_id: Some("town_market".into()),
15021 entity_id: None,
15022 life_state: None,
15023 hp_pct: None,
15024 can_trade: false,
15025 tile_id: None,
15026 behavior_state: None,
15027 presentation_state: None,
15028 sprite_mode: None,
15029 paperdoll_ref: None,
15030 });
15031 state.npc_verb_target = Some("mira_market".into());
15032 assert_eq!(state.npc_verb_options(), vec!["Market", "Talk"]);
15033 }
15034
15035 #[test]
15036 fn market_list_excludes_currency_stacks() {
15037 let mut state = sample_state();
15038 state.inventory_stacks = vec![
15039 flatland_protocol::ItemStack {
15040 template_id: "copper_coin".into(),
15041 quantity: 50,
15042 item_instance_id: Some(uuid::Uuid::from_u128(10)),
15043 display_name: Some("Copper Coin".into()),
15044 ..Default::default()
15045 },
15046 flatland_protocol::ItemStack {
15047 template_id: "oak_log".into(),
15048 quantity: 2,
15049 item_instance_id: Some(uuid::Uuid::from_u128(11)),
15050 display_name: Some("Oak Log".into()),
15051 ..Default::default()
15052 },
15053 flatland_protocol::ItemStack {
15054 template_id: "whisper_stone".into(),
15055 quantity: 1,
15056 item_instance_id: Some(uuid::Uuid::from_u128(12)),
15057 display_name: Some("Whisper Stone".into()),
15058 category: Some("quest".into()),
15059 listable: Some(false),
15060 ..Default::default()
15061 },
15062 ];
15063 let opts = state.market_list_item_options(&MarketListSourceKind::Person);
15064 assert_eq!(opts.len(), 1);
15065 assert!(opts[0].label.contains("Oak"));
15066 }
15067
15068 #[test]
15069 fn market_browse_filters_by_category_and_search() {
15070 let mut state = sample_state();
15071 state.market_panel = Some(flatland_protocol::MarketPanel {
15072 npc_id: "mira_market".into(),
15073 npc_label: "Mira".into(),
15074 building_id: "town_market".into(),
15075 building_label: "Town Market".into(),
15076 used_volume: 0.0,
15077 max_volume: 100.0,
15078 listings: vec![
15079 flatland_protocol::MarketListingView {
15080 listing_id: uuid::Uuid::from_u128(1),
15081 seller_character_id: uuid::Uuid::from_u128(2),
15082 seller_label: "Ada".into(),
15083 hall_building_id: "town_market".into(),
15084 hall_label: "Town Market".into(),
15085 template_id: "oak_log".into(),
15086 display_name: "Oak Log".into(),
15087 category: "resource".into(),
15088 quantity: 3,
15089 unit_price_copper: 10,
15090 line_total_copper: 30,
15091 npc_price: false,
15092 mine: false,
15093 },
15094 flatland_protocol::MarketListingView {
15095 listing_id: uuid::Uuid::from_u128(3),
15096 seller_character_id: uuid::Uuid::from_u128(2),
15097 seller_label: "Ada".into(),
15098 hall_building_id: "town_market".into(),
15099 hall_label: "Town Market".into(),
15100 template_id: "short_sword".into(),
15101 display_name: "Short Sword".into(),
15102 category: "weapon".into(),
15103 quantity: 1,
15104 unit_price_copper: 100,
15105 line_total_copper: 100,
15106 npc_price: false,
15107 mine: false,
15108 },
15109 ],
15110 tax_bps: 0,
15111 tax_flat_copper: 0,
15112 list_vaults: vec![],
15113 });
15114 assert_eq!(state.market_filtered_listing_indices().len(), 2);
15115 state.market_category_filter = Some("Weapons");
15116 let weapons = state.market_filtered_listing_indices();
15117 assert_eq!(weapons.len(), 1);
15118 assert_eq!(
15119 state.market_panel.as_ref().unwrap().listings[weapons[0]].display_name,
15120 "Short Sword"
15121 );
15122 state.market_category_filter = None;
15123 state.market_filter = "oak".into();
15124 let oak = state.market_filtered_listing_indices();
15125 assert_eq!(oak.len(), 1);
15126 assert_eq!(
15127 state.market_panel.as_ref().unwrap().listings[oak[0]].display_name,
15128 "Oak Log"
15129 );
15130 }
15131
15132 #[test]
15133 fn market_list_source_includes_person_and_vaults() {
15134 let mut state = sample_state();
15135 let item_id = uuid::Uuid::from_u128(1);
15136 state.inventory_stacks = vec![flatland_protocol::ItemStack {
15137 template_id: "oak_log".into(),
15138 quantity: 2,
15139 item_instance_id: Some(item_id),
15140 display_name: Some("Oak Log".into()),
15141 ..Default::default()
15142 }];
15143 state.market_panel = Some(flatland_protocol::MarketPanel {
15144 npc_id: "mira_market".into(),
15145 npc_label: "Mira".into(),
15146 building_id: "town_market".into(),
15147 building_label: "Town Market".into(),
15148 used_volume: 0.0,
15149 max_volume: 100.0,
15150 listings: vec![],
15151 tax_bps: 0,
15152 tax_flat_copper: 0,
15153 list_vaults: vec![flatland_protocol::MarketListVault {
15154 building_id: "town_storage".into(),
15155 building_label: "Town Storage".into(),
15156 contents: vec![flatland_protocol::ItemStack {
15157 template_id: "lumber".into(),
15158 quantity: 1,
15159 item_instance_id: Some(uuid::Uuid::from_u128(2)),
15160 display_name: Some("Lumber".into()),
15161 ..Default::default()
15162 }],
15163 }],
15164 });
15165 let sources = state.market_list_source_options();
15166 assert_eq!(sources.len(), 2);
15167 assert!(matches!(sources[0].0, MarketListSourceKind::Person));
15168 assert!(matches!(
15169 sources[1].0,
15170 MarketListSourceKind::TownStorage { .. }
15171 ));
15172 assert!(sources[1].1.contains("Town Storage"));
15173 }
15174
15175 #[test]
15176 fn probe_use_world_npc_beats_nearby_loot() {
15177 let mut state = sample_state();
15178 state.npcs.push(flatland_protocol::NpcView {
15179 id: "ada".into(),
15180 label: "Ada".into(),
15181 role: "broker".into(),
15182 x: 129.0,
15183 y: 128.0,
15184 building_id: None,
15185 entity_id: None,
15186 life_state: None,
15187 hp_pct: None,
15188 can_trade: true,
15189 tile_id: None,
15190 behavior_state: None,
15191 presentation_state: None,
15192 sprite_mode: None,
15193 paperdoll_ref: None,
15194 });
15195 state.ground_drops.push(flatland_protocol::GroundDropView {
15196 id: "d1".into(),
15197 template_id: "lumber".into(),
15198 quantity: 1,
15199 x: 128.5,
15200 y: 128.0,
15201 z: 0.0,
15202 tile_id: None,
15203 display_name: None,
15204 yaw: 0.0,
15205 pitch: 0.0,
15206 roll: 0.0,
15207 draw_scale: 1.0,
15208 });
15209 let probe = state.probe_use_world();
15210 let primary = probe.primary.expect("primary");
15211 assert_eq!(primary.kind, crate::UseWorldKind::Npc);
15212 assert_eq!(primary.id, "ada");
15213 }
15214
15215 #[test]
15216 fn probe_use_world_harvest_when_in_range() {
15217 let state = sample_state(); let probe = state.probe_use_world();
15219 assert!(
15220 probe.primary.is_none(),
15221 "oak is 2m away, out of harvest range"
15222 );
15223 assert!(probe
15224 .candidates
15225 .iter()
15226 .any(|c| c.kind == crate::UseWorldKind::Harvest));
15227
15228 let mut state = sample_state();
15229 state.resource_nodes[0].x = 129.0;
15230 let probe = state.probe_use_world();
15231 let primary = probe.primary.expect("primary");
15232 assert_eq!(primary.kind, crate::UseWorldKind::Harvest);
15233 }
15234
15235 #[test]
15236 fn probe_use_world_door_uses_building_label() {
15237 let mut state = sample_state();
15238 state.doors[0].x = 129.0;
15239 state.doors[0].y = 128.0;
15240 let probe = state.probe_use_world();
15241 let primary = probe.primary.expect("primary");
15242 assert_eq!(primary.kind, crate::UseWorldKind::EnterDoor);
15243 assert_eq!(primary.label, "Broker");
15244 assert_eq!(primary.hint_line(), "f → Enter Broker (1.0m)");
15245 }
15246
15247 #[test]
15248 fn empty_entity_tick_preserves_welcome_snapshot() {
15249 let mut state = sample_state();
15250 state.inventory.insert("carrot".into(), 3);
15251 let delta = TickDelta {
15252 tick: 1,
15253 entities: vec![],
15254 resource_nodes: vec![],
15255 ground_drops: vec![],
15256 placed_containers: vec![],
15257 buildings: vec![],
15258 doors: vec![],
15259 interior_map: None,
15260 npcs: vec![],
15261 inventory: vec![],
15262 blueprints: vec![],
15263 building_materials: vec![],
15264 world_clock: flatland_protocol::WorldClock::default(),
15265 combat: None,
15266 quest_log: vec![],
15267 hired_workers: Vec::new(),
15268 interactables: vec![],
15269 ledger: None,
15270 career: None,
15271 combat_fx: Vec::new(),
15272 property_plots: Vec::new(),
15273 terrain_overlays: Vec::new(),
15274 };
15275
15276 state.apply_tick_fields(&delta, 1);
15277
15278 assert_eq!(state.entities.len(), 1);
15279 assert!(state.player.is_some());
15280 assert_eq!(state.inventory.get("carrot"), Some(&3));
15281 assert_eq!(state.resource_nodes.len(), 1);
15282 }
15283
15284 #[test]
15285 fn tick_preserves_world_layers_when_delta_omits_them() {
15286 let mut state = sample_state();
15287 let delta = TickDelta {
15288 tick: 1,
15289 entities: state.entities.clone(),
15290 resource_nodes: vec![],
15291 ground_drops: vec![],
15292 placed_containers: vec![],
15293 buildings: vec![],
15294 doors: vec![],
15295 interior_map: None,
15296 npcs: vec![],
15297 inventory: vec![],
15298 blueprints: vec![],
15299 building_materials: vec![],
15300 world_clock: flatland_protocol::WorldClock::default(),
15301 combat: None,
15302 quest_log: vec![],
15303 hired_workers: Vec::new(),
15304 interactables: vec![],
15305 ledger: None,
15306 career: None,
15307 combat_fx: Vec::new(),
15308 property_plots: Vec::new(),
15309 terrain_overlays: Vec::new(),
15310 };
15311
15312 state.apply_tick_fields(&delta, 1);
15313
15314 assert_eq!(state.resource_nodes.len(), 1);
15315 assert_eq!(state.buildings.len(), 1);
15316 assert_eq!(state.doors.len(), 1);
15317 }
15318
15319 #[test]
15320 fn tick_updates_resource_nodes_when_server_sends_them() {
15321 let mut state = sample_state();
15322 let delta = TickDelta {
15323 tick: 1,
15324 entities: state.entities.clone(),
15325 resource_nodes: vec![ResourceNodeView {
15326 id: "oak-1".into(),
15327 label: "Oak".into(),
15328 x: 130.0,
15329 y: 128.0,
15330 z: 0.0,
15331 item_template: "oak_log".into(),
15332 state: ResourceNodeState::Cooldown,
15333 blocking: true,
15334 blocking_radius_m: 0.8,
15335 harvest_off: false,
15336 tile_id: None,
15337 yaw: 0.0,
15338 pitch: 0.0,
15339 roll: 0.0,
15340 draw_scale: 1.0,
15341 sprite_mode: None,
15342 growth_progress: None,
15343 presentation_state: None,
15344 channel_start_tick: None,
15345 channel_end_tick: None,
15346 harvest_drop_templates: vec![],
15347 }],
15348 buildings: vec![],
15349 doors: vec![],
15350 interior_map: None,
15351 npcs: vec![],
15352 inventory: vec![],
15353 blueprints: vec![],
15354 building_materials: vec![],
15355 world_clock: flatland_protocol::WorldClock::default(),
15356 ground_drops: vec![],
15357 placed_containers: vec![],
15358 combat: None,
15359 quest_log: vec![],
15360 hired_workers: Vec::new(),
15361 interactables: vec![],
15362 ledger: None,
15363 career: None,
15364 combat_fx: Vec::new(),
15365 property_plots: Vec::new(),
15366 terrain_overlays: Vec::new(),
15367 };
15368
15369 state.apply_tick_fields(&delta, 1);
15370
15371 assert!(matches!(
15372 state.resource_nodes[0].state,
15373 ResourceNodeState::Cooldown
15374 ));
15375 }
15376
15377 #[test]
15378 fn interact_prefers_npc_over_interior_exit_at_entry_spawn() {
15379 let mut state = GameState {
15380 session_id: 1,
15381 entity_id: 1,
15382 character_id: None,
15383 tick: 0,
15384 chunk_rev: 0,
15385 content_rev: 0,
15386 publish_rev: 0,
15387 entities: vec![EntityState {
15388 id: 1,
15389 label: "You".into(),
15390 transform: Transform {
15391 position: WorldCoord::surface(4.5, 2.0),
15392 yaw: 0.0,
15393 velocity: flatland_protocol::Velocity2D { vx: 0.0, vy: 0.0 },
15394 },
15395 vitals: None,
15396 attributes: None,
15397 skills: None,
15398 inside_building: Some("broker_hut".into()),
15399 tile_id: None,
15400 paperdoll_ref: None,
15401 presentation_state: None,
15402 sprite_mode: None,
15403 progression_xp: None,
15404 combat_cues: vec![],
15405 statuses: vec![],
15406 }],
15407 player: None,
15408 resource_nodes: vec![],
15409 ground_drops: vec![],
15410 placed_containers: vec![],
15411 buildings: vec![BuildingView {
15412 id: "broker_hut".into(),
15413 label: "Broker".into(),
15414 x: 158.0,
15415 y: 124.0,
15416 width_m: 8.0,
15417 depth_m: 6.0,
15418 interior_blueprint: Some("broker_hut".into()),
15419 tags: vec![],
15420 market_boundary_zone_ids: vec![],
15421 market_max_volume: None,
15422 wall_set: None,
15423 roof_set: None,
15424 }],
15425 doors: vec![flatland_protocol::DoorView {
15426 id: "broker_hut_exit".into(),
15427 building_id: "broker_hut".into(),
15428 x: 4.3,
15429 y: 0.9,
15430 open: true,
15431 portal: Some("front".into()),
15432 locked: false,
15433 accessible: true,
15434 lock_id: None,
15435 }],
15436 interior_map: None,
15437 npcs: vec![flatland_protocol::NpcView {
15438 id: "ada_broker".into(),
15439 label: "Ada".into(),
15440 x: 4.5,
15441 y: 2.0,
15442 building_id: Some("broker_hut".into()),
15443 role: "broker".into(),
15444 entity_id: None,
15445 life_state: None,
15446 hp_pct: None,
15447 can_trade: true,
15448 tile_id: None,
15449 behavior_state: None,
15450 presentation_state: None,
15451 sprite_mode: None,
15452 paperdoll_ref: None,
15453 }],
15454 blueprints: vec![],
15455 building_materials: vec![],
15456 world_x0: 0.0,
15457 world_y0: 0.0,
15458 world_width_m: 256.0,
15459 world_height_m: 256.0,
15460 terrain_zones: Vec::new(),
15461 z_platforms: Vec::new(),
15462 z_transitions: Vec::new(),
15463 z_bands_outdoor_backup: None,
15464 world_clock: flatland_protocol::WorldClock::default(),
15465 inventory: std::collections::HashMap::new(),
15466 inventory_hints: std::collections::HashMap::new(),
15467 logs: VecDeque::new(),
15468 intents_sent: 0,
15469 ticks_received: 0,
15470 connected: true,
15471 disconnect_reason: None,
15472 show_stats: false,
15473 hud_log_hidden: false,
15474 show_equip_menu: false,
15475 equip_menu_index: 0,
15476 show_craft_menu: false,
15477 show_plot_build_menu: false,
15478 plot_build_focus_wall: true,
15479 plot_build_wall_index: 0,
15480 plot_build_roof_index: 0,
15481 craft_menu_index: 0,
15482 craft_batch_quantity: 1,
15483 show_shop_menu: false,
15484 shop_catalog: None,
15485 bank_panel: None,
15486 bank_menu_index: 0,
15487 bank_ui_mode: BankUiMode::Menu,
15488 storage_panel: None,
15489 market_panel: None,
15490 market_menu_index: 0,
15491 market_filter: String::new(),
15492 market_filter_focused: false,
15493 market_category_filter: None,
15494 market_buy_confirm: None,
15495 market_ui_mode: MarketUiMode::Browse,
15496 storage_menu_index: 0,
15497 storage_ui_mode: StorageUiMode::Menu,
15498 shop_tab: ShopTab::default(),
15499 shop_menu_index: 0,
15500 shop_quantity: 1,
15501 shop_trade_log: VecDeque::new(),
15502 show_npc_verb_menu: false,
15503 npc_verb_target: None,
15504 npc_verb_index: 0,
15505 player_verbs: crate::social::PlayerVerbState::default(),
15506 social_chat: crate::social::SocialChatState::default(),
15507 trade_ui: crate::social::TradeUiState::default(),
15508 whisper_pouch_ui: crate::social::WhisperPouchUi::default(),
15509 show_npc_chat: false,
15510 npc_chat: None,
15511 show_inventory_menu: false,
15512 inventory_menu_index: 0,
15513 inventory_tab: InventoryTab::OnPerson,
15514 inventory_filter: String::new(),
15515 inventory_filter_focused: false,
15516 show_move_picker: false,
15517 show_rename_prompt: false,
15518 show_worker_rename: false,
15519 rename_buffer: String::new(),
15520 move_picker_index: 0,
15521 move_picker: None,
15522 show_grant_picker: false,
15523 grant_picker_index: 0,
15524 grant_picker: None,
15525 show_destroy_picker: false,
15526 destroy_confirm_pending: false,
15527 destroy_picker: None,
15528 combat_target: None,
15529 combat_target_label: None,
15530 ground_target: None,
15531 combat_fx: Vec::new(),
15532 property_zones: Vec::new(),
15533 tax_zones: Vec::new(),
15534 growth_zones: Vec::new(),
15535 biome_zones: Vec::new(),
15536 terrain_kind_nav: Vec::new(),
15537 property_plots: Vec::new(),
15538 property_plot_settings: None,
15539 claim_mode: None,
15540 relocate_mode: None,
15541 sell_plot_confirm: None,
15542 sell_plot_armed_at: None,
15543 show_plant_menu: false,
15544 plant_menu_index: 0,
15545 show_farm_access: false,
15546 farm_access_name_draft: String::new(),
15547 farm_access_discount_bps: 0,
15548 farm_access_index: 0,
15549 plant_quantity: 1,
15550 in_combat: false,
15551 auto_attack: true,
15552 combat_has_los: false,
15553 attack_cd_ticks: 0,
15554 gcd_ticks: 0,
15555 weapon_ability_id: "unarmed".into(),
15556 mainhand_template_id: None,
15557 mainhand_label: None,
15558 mainhand_instance_id: None,
15559 offhand_template_id: None,
15560 offhand_label: None,
15561 offhand_instance_id: None,
15562 mainhand_hand_slots: 1,
15563 defense: None,
15564 worn: BTreeMap::new(),
15565 carry_mass: 0.0,
15566 carry_mass_max: 0.0,
15567 encumbrance: flatland_protocol::EncumbranceState::Light,
15568 inventory_stacks: Vec::new(),
15569 keychain_stacks: Vec::new(),
15570 whisper_pouch_stacks: Vec::new(),
15571 combat_target_detail: None,
15572 statuses: Vec::new(),
15573 cast_progress: None,
15574 timed_channel: None,
15575 plot_build_offer: None,
15576 ability_cooldowns: Vec::new(),
15577 blocking_active: false,
15578 max_target_slots: 1,
15579 combat_slots: Vec::new(),
15580 rotation_presets: Vec::new(),
15581 known_abilities: Vec::new(),
15582 ability_meta: std::collections::HashMap::new(),
15583 ability_mastery: std::collections::HashMap::new(),
15584 hotbar: vec![None; 9],
15585 max_abilities_per_rotation: 0,
15586 show_loadout_menu: false,
15587 show_keychain_menu: false,
15588 keychain_menu_index: 0,
15589 show_rotation_editor: false,
15590 loadout_menu_index: 0,
15591 loadout_hotbar_slot: 1,
15592 loadout_ability_index: 0,
15593 loadout_focus_presets: false,
15594 rotation_editor: RotationEditorState::default(),
15595 harvest_in_progress: false,
15596 harvest_started_at: None,
15597 pending_craft_ack: None,
15598 pending_worker_job_ack: None,
15599 attending_worker_instance_id: None,
15600 quest_log: Vec::new(),
15601 interactables: Vec::new(),
15602 ledger: None,
15603 career: None,
15604 character_sheet_tab: CharacterSheetTab::Character,
15605 ledger_period: LedgerPeriod::Day,
15606 show_quest_offer: false,
15607 pending_quest_offer: None,
15608 show_quest_menu: false,
15609 quest_menu_index: 0,
15610 quest_withdraw_confirm: false,
15611 hired_workers: Vec::new(),
15612 show_workers_menu: false,
15613 workers_menu_index: 0,
15614 workers_menu_compact: false,
15615 worker_step_display: BTreeMap::new(),
15616 worker_error_display: BTreeMap::new(),
15617 show_worker_give_picker: false,
15618 worker_give_picker_index: 0,
15619 worker_give_picker: None,
15620 show_worker_give_target_picker: false,
15621 worker_give_target_picker_index: 0,
15622 worker_give_target_picker: None,
15623 show_worker_take_picker: false,
15624 worker_take_picker_index: 0,
15625 worker_take_picker: None,
15626 show_worker_teach_picker: false,
15627 worker_teach_picker_index: 0,
15628 worker_teach_picker: None,
15629 worker_route_editor: None,
15630 progression_curve: None,
15631 };
15632 state.player = state.entities.first().cloned();
15633 assert_eq!(
15634 state.nearest_interact_target().as_deref(),
15635 Some("ada_broker")
15636 );
15637 }
15638
15639 #[test]
15640 fn nearby_containers_hides_chest_out_of_range_and_locked_without_key() {
15641 let mut state = sample_state();
15642 state.placed_containers = vec![
15645 flatland_protocol::PlacedContainerView {
15646 id: "near".into(),
15647 template_id: "wooden_chest_small".into(),
15648 display_name: "Wooden Chest".into(),
15649 x: 130.0,
15650 y: 128.0,
15651 z: 0.0,
15652 locked: true,
15653 accessible: true,
15654 owner_character_id: None,
15655 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 2)],
15656 lock_id: None,
15657 capacity_volume: None,
15658 item_instance_id: Some(uuid::Uuid::from_u128(1)),
15659 tile_id: None,
15660 worker_lodging_capacity: None,
15661 blocking: false,
15662 blocking_radius_m: 0.0,
15663 building_id: None,
15664 },
15665 flatland_protocol::PlacedContainerView {
15666 id: "far".into(),
15667 template_id: "wooden_chest_small".into(),
15668 display_name: "Distant Chest".into(),
15669 x: 128.0 + CONTAINER_RANGE_M + 5.0,
15670 y: 128.0,
15671 z: 0.0,
15672 locked: false,
15673 accessible: true,
15674 owner_character_id: None,
15675 contents: vec![flatland_protocol::ItemStack::simple("lumber", 1)],
15676 lock_id: None,
15677 capacity_volume: None,
15678 item_instance_id: Some(uuid::Uuid::from_u128(2)),
15679 tile_id: None,
15680 worker_lodging_capacity: None,
15681 blocking: false,
15682 blocking_radius_m: 0.0,
15683 building_id: None,
15684 },
15685 ];
15686
15687 let nearby = state.nearby_containers();
15688 assert_eq!(
15689 nearby.len(),
15690 1,
15691 "far chest must not appear once out of range"
15692 );
15693 assert_eq!(nearby[0].view.id, "near");
15694 assert_eq!(nearby[0].rows.len(), 2, "shell row + contents");
15695 assert!(nearby[0].rows[0].is_chest_shell);
15696
15697 state.placed_containers[0].accessible = false;
15700 let nearby = state.nearby_containers();
15701 assert_eq!(nearby.len(), 1);
15702 assert_eq!(nearby[0].rows.len(), 1);
15703 assert!(nearby[0].rows[0].is_chest_shell);
15704 }
15705
15706 #[test]
15707 fn chest_pickup_destinations_offer_person_and_worn_bag() {
15708 let mut state = sample_state();
15709 let back_id = uuid::Uuid::from_u128(42);
15710 state.worn.insert(
15711 BodySlot::Back,
15712 flatland_protocol::ItemStack {
15713 template_id: "travel_backpack".into(),
15714 quantity: 1,
15715 item_instance_id: Some(back_id),
15716 props: Default::default(),
15717 status_bindings: Vec::new(),
15718 contents: Vec::new(),
15719 display_name: Some("Travel Backpack".into()),
15720 category: Some("container".into()),
15721 base_mass: Some(2.5),
15722 base_volume: Some(12.0),
15723 capacity_volume: Some(80.0),
15724 stackable: Some(false),
15725 world_placeable: Some(false),
15726 worker_lodging_capacity: None,
15727 equip_slot: None,
15728 armor_physical: None,
15729 resists: vec![],
15730 hand_slots: None,
15731 listable: None,
15732 },
15733 );
15734 let opts = state.chest_pickup_destinations("chest-1");
15735 assert!(matches!(
15736 opts.first().map(|o| &o.kind),
15737 Some(MoveOptionKind::RelocatePlaced { container_id }) if container_id == "chest-1"
15738 ));
15739 assert!(opts.iter().any(|o| matches!(
15740 &o.kind,
15741 MoveOptionKind::PickupPlaced {
15742 nest_parent_instance_id: None,
15743 ..
15744 }
15745 )));
15746 assert!(opts.iter().any(|o| matches!(
15747 &o.kind,
15748 MoveOptionKind::PickupPlaced {
15749 nest_parent_instance_id: Some(id),
15750 ..
15751 } if *id == back_id
15752 )));
15753 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
15754 }
15755
15756 #[test]
15757 fn placed_container_public_label_hides_owner_custom_name() {
15758 let owner = uuid::Uuid::from_u128(99);
15759 let mut state = sample_state();
15760 state.character_id = Some(uuid::Uuid::from_u128(1));
15761 state.inventory_hints.insert(
15762 "wooden_chest_medium".into(),
15763 InventoryHint {
15764 display_name: "Medium Wooden Chest".into(),
15765 category: "container".into(),
15766 base_mass: None,
15767 base_volume: None,
15768 capacity_volume: None,
15769 stackable: false,
15770 listable: true,
15771 },
15772 );
15773 let chest = flatland_protocol::PlacedContainerView {
15774 id: "c1".into(),
15775 template_id: "wooden_chest_medium".into(),
15776 display_name: "Barry's Loot #a3f2".into(),
15777 x: 128.0,
15778 y: 128.0,
15779 z: 0.0,
15780 locked: false,
15781 accessible: true,
15782 owner_character_id: Some(owner),
15783 contents: vec![],
15784 lock_id: None,
15785 capacity_volume: None,
15786 item_instance_id: None,
15787 tile_id: None,
15788 worker_lodging_capacity: None,
15789 blocking: false,
15790 blocking_radius_m: 0.0,
15791 building_id: None,
15792 };
15793 assert_eq!(
15794 state.placed_container_public_label(&chest),
15795 "Medium Wooden Chest"
15796 );
15797 state.character_id = Some(owner);
15798 assert_eq!(
15799 state.placed_container_public_label(&chest),
15800 "Barry's Loot #a3f2"
15801 );
15802 }
15803
15804 #[test]
15805 fn location_context_shows_crop_growth_percent_not_depleted() {
15806 let mut state = sample_state();
15807 state.player = state.entities.first().cloned();
15808 state.resource_nodes[0].label = "Carrot (growing)".into();
15809 state.resource_nodes[0].x = 128.2;
15810 state.resource_nodes[0].y = 128.0;
15811 state.resource_nodes[0].state = ResourceNodeState::Cooldown;
15812 state.resource_nodes[0].growth_progress = Some(0.47);
15813 let lines = state.location_context_lines();
15814 let line = lines
15815 .iter()
15816 .find(|l| l.text.contains("Carrot"))
15817 .map(|l| l.text.as_str())
15818 .unwrap_or("");
15819 assert!(
15820 line.contains("(growing, 47%)"),
15821 "expected growth percent, got: {line}"
15822 );
15823 assert!(
15824 !line.contains("depleted"),
15825 "growing crop should not show depleted: {line}"
15826 );
15827 }
15828
15829 #[test]
15830 fn resource_node_near_action_suffix_prefers_growth() {
15831 let node = ResourceNodeView {
15832 id: "crop".into(),
15833 label: "Wheat".into(),
15834 x: 0.0,
15835 y: 0.0,
15836 z: 0.0,
15837 item_template: "wheat".into(),
15838 state: ResourceNodeState::Cooldown,
15839 blocking: false,
15840 blocking_radius_m: 0.0,
15841 harvest_off: false,
15842 tile_id: None,
15843 yaw: 0.0,
15844 pitch: 0.0,
15845 roll: 0.0,
15846 draw_scale: 1.0,
15847 sprite_mode: None,
15848 growth_progress: Some(0.12),
15849 presentation_state: None,
15850 channel_start_tick: None,
15851 channel_end_tick: None,
15852 harvest_drop_templates: vec![],
15853 };
15854 assert_eq!(
15855 resource_node_near_action_suffix(&node),
15856 " (growing, 12%)"
15857 );
15858 }
15859
15860 #[test]
15861 fn location_context_lists_nearby_resource_node() {
15862 let mut state = sample_state();
15863 state.player = state.entities.first().cloned();
15864 state.resource_nodes[0].x = 128.2;
15865 state.resource_nodes[0].y = 128.0;
15866 let lines = state.location_context_lines();
15867 assert!(
15868 lines
15869 .iter()
15870 .any(|l| l.text.contains("Oak") && l.text.contains("harvest")),
15871 "expected resource node in context: {:?}",
15872 lines
15873 );
15874 }
15875
15876 #[test]
15877 fn quest_board_usable_within_board_radius() {
15878 let mut state = sample_state();
15879 state.player = state.entities.first().cloned();
15880 state.interactables = vec![flatland_protocol::InteractableView {
15881 id: "board-1".into(),
15882 kind: "quest_board".into(),
15883 label: "Town Quest Board".into(),
15884 x: 130.5,
15885 y: 128.0,
15886 z: 0.0,
15887 board_id: Some("starter_town_board".into()),
15888 }];
15889 assert_eq!(
15891 state.nearest_interact_target().as_deref(),
15892 Some("board-1"),
15893 "quest board should be selectable at ~2.5m"
15894 );
15895 let lines = state.location_context_lines();
15896 assert!(
15897 lines
15898 .iter()
15899 .any(|l| l.text.contains("Town Quest Board") && l.text.contains("f view quests")),
15900 "HUD should advertise f when board is in range: {:?}",
15901 lines
15902 );
15903 }
15904
15905 #[test]
15906 fn inventory_selectable_rows_orders_worn_before_person_on_person_tab() {
15907 let mut state = sample_state();
15908 state.worn.insert(
15909 BodySlot::Back,
15910 flatland_protocol::ItemStack {
15911 template_id: "travel_backpack".into(),
15912 quantity: 1,
15913 item_instance_id: Some(uuid::Uuid::from_u128(3)),
15914 props: Default::default(),
15915 status_bindings: Vec::new(),
15916 contents: vec![flatland_protocol::ItemStack::simple("iron_ore", 1)],
15917 display_name: None,
15918 category: None,
15919 base_mass: None,
15920 base_volume: None,
15921 capacity_volume: None,
15922 stackable: None,
15923 world_placeable: None,
15924 worker_lodging_capacity: None,
15925 equip_slot: None,
15926 armor_physical: None,
15927 resists: vec![],
15928 hand_slots: None,
15929 listable: None,
15930 },
15931 );
15932 state.inventory_stacks = vec![flatland_protocol::ItemStack::simple("lumber", 4)];
15933 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
15934 id: "chest-1".into(),
15935 template_id: "wooden_chest_small".into(),
15936 display_name: "Wooden Chest".into(),
15937 x: 129.0,
15938 y: 128.0,
15939 z: 0.0,
15940 locked: false,
15941 accessible: true,
15942 owner_character_id: None,
15943 contents: vec![flatland_protocol::ItemStack::simple("wood_axe", 1)],
15944 lock_id: None,
15945 capacity_volume: None,
15946 item_instance_id: Some(uuid::Uuid::from_u128(4)),
15947 tile_id: None,
15948 worker_lodging_capacity: None,
15949 blocking: false,
15950 blocking_radius_m: 0.0,
15951 building_id: None,
15952 }];
15953
15954 state.inventory_tab = InventoryTab::OnPerson;
15955 let rows = state.inventory_selectable_rows();
15956 let sections: Vec<InventorySection> = rows.iter().map(|r| r.section).collect();
15957 assert_eq!(
15958 sections,
15959 vec![
15960 InventorySection::Worn, InventorySection::Worn, InventorySection::Person, ]
15964 );
15965 assert_eq!(rows[0].stack.template_id, "travel_backpack");
15966 assert!(rows[0].is_equip_shell);
15967 assert_eq!(rows[1].stack.template_id, "iron_ore");
15968 assert_eq!(rows[1].depth, 1);
15969 assert_eq!(rows[2].stack.template_id, "lumber");
15970
15971 let lines = state.inventory_browser_lines();
15972 assert!(lines.iter().any(|l| matches!(
15973 l,
15974 InventoryBrowserLine::Section(s) if s.contains("Worn")
15975 )));
15976 assert!(lines.iter().any(|l| matches!(
15977 l,
15978 InventoryBrowserLine::Item { text, .. } if text.contains("travel_backpack")
15979 || text.contains("backpack")
15980 )));
15981 assert!(!lines.iter().any(|l| matches!(
15982 l,
15983 InventoryBrowserLine::Section(s) if s.contains("Nearby") || s.contains("Wooden")
15984 )));
15985
15986 state.inventory_tab = InventoryTab::Nearby;
15987 let nearby_rows = state.inventory_selectable_rows();
15988 assert_eq!(nearby_rows.len(), 2);
15989 assert!(nearby_rows[0].is_chest_shell);
15990 assert_eq!(nearby_rows[1].stack.template_id, "wood_axe");
15991 let nearby_lines = state.inventory_browser_lines();
15992 assert!(nearby_lines.iter().any(|l| matches!(
15993 l,
15994 InventoryBrowserLine::Section(s) if s.contains("Wooden Chest")
15995 )));
15996 }
15997
15998 #[test]
15999 fn move_destinations_for_excludes_current_location_and_always_offers_drop_and_cancel() {
16000 let mut state = sample_state();
16001 let back_id = uuid::Uuid::from_u128(5);
16002 state.worn.insert(
16003 BodySlot::Back,
16004 flatland_protocol::ItemStack {
16005 template_id: "travel_backpack".into(),
16006 quantity: 1,
16007 item_instance_id: Some(back_id),
16008 props: Default::default(),
16009 status_bindings: Vec::new(),
16010 contents: Vec::new(),
16011 display_name: None,
16012 category: Some("container".into()),
16013 base_mass: None,
16014 base_volume: None,
16015 capacity_volume: Some(80.0),
16016 stackable: None,
16017 world_placeable: None,
16018 worker_lodging_capacity: None,
16019 equip_slot: None,
16020 armor_physical: None,
16021 resists: vec![],
16022 hand_slots: None,
16023 listable: None,
16024 },
16025 );
16026 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16027 id: "chest-1".into(),
16028 template_id: "wooden_chest_small".into(),
16029 display_name: "Wooden Chest".into(),
16030 x: 129.0,
16031 y: 128.0,
16032 z: 0.0,
16033 locked: false,
16034 accessible: true,
16035 owner_character_id: None,
16036 contents: Vec::new(),
16037 lock_id: None,
16038 capacity_volume: None,
16039 item_instance_id: Some(uuid::Uuid::from_u128(6)),
16040 tile_id: None,
16041 worker_lodging_capacity: None,
16042 blocking: false,
16043 blocking_radius_m: 0.0,
16044 building_id: None,
16045 }];
16046
16047 let opts = state.move_destinations_for(
16050 &flatland_protocol::InventoryLocation::Root,
16051 None,
16052 None,
16053 "lumber",
16054 );
16055 assert!(!opts.iter().any(|o| matches!(
16056 &o.kind,
16057 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
16058 )));
16059 assert!(opts.iter().any(|o| matches!(
16060 &o.kind,
16061 MoveOptionKind::Move { location, parent_instance_id, .. }
16062 if *location == flatland_protocol::InventoryLocation::Worn {
16063 slot: BodySlot::Back,
16064 } && *parent_instance_id == Some(back_id)
16065 )));
16066 assert!(opts.iter().any(|o| matches!(
16067 &o.kind,
16068 MoveOptionKind::Move { location, .. }
16069 if *location == flatland_protocol::InventoryLocation::Placed { container_id: "chest-1".into() }
16070 )));
16071 assert!(matches!(opts.last().unwrap().kind, MoveOptionKind::Cancel));
16072 assert!(matches!(opts[opts.len() - 2].kind, MoveOptionKind::Drop));
16073
16074 let from_backpack = flatland_protocol::InventoryLocation::Worn {
16078 slot: BodySlot::Back,
16079 };
16080 let opts = state.move_destinations_for(&from_backpack, Some(back_id), None, "iron_ore");
16081 assert!(!opts.iter().any(|o| matches!(
16082 &o.kind,
16083 MoveOptionKind::Move { location, parent_instance_id, .. }
16084 if *location == from_backpack && *parent_instance_id == Some(back_id)
16085 )));
16086 assert!(opts.iter().any(|o| matches!(
16087 &o.kind,
16088 MoveOptionKind::Move { location, .. } if *location == flatland_protocol::InventoryLocation::Root
16089 )));
16090 }
16091
16092 #[test]
16093 fn worn_rows_orders_all_body_slots_and_nests_belt_loop_contents() {
16094 let mut state = sample_state();
16095 state.worn.insert(
16098 BodySlot::Waist,
16099 flatland_protocol::ItemStack {
16100 template_id: "simple_belt".into(),
16101 quantity: 1,
16102 item_instance_id: Some(uuid::Uuid::from_u128(10)),
16103 props: Default::default(),
16104 status_bindings: Vec::new(),
16105 contents: vec![flatland_protocol::ItemStack::simple("leather_pouch", 1)],
16106 display_name: None,
16107 category: Some("container".into()),
16108 base_mass: None,
16109 base_volume: None,
16110 capacity_volume: None,
16111 stackable: None,
16112 world_placeable: None,
16113 worker_lodging_capacity: None,
16114 equip_slot: None,
16115 armor_physical: None,
16116 resists: vec![],
16117 hand_slots: None,
16118 listable: None,
16119 },
16120 );
16121 state.worn.insert(
16122 BodySlot::Head,
16123 flatland_protocol::ItemStack {
16124 template_id: "cloth_cap".into(),
16125 quantity: 1,
16126 item_instance_id: Some(uuid::Uuid::from_u128(11)),
16127 props: Default::default(),
16128 status_bindings: Vec::new(),
16129 contents: Vec::new(),
16130 display_name: None,
16131 category: Some("armor".into()),
16132 base_mass: None,
16133 base_volume: None,
16134 capacity_volume: None,
16135 stackable: None,
16136 world_placeable: None,
16137 worker_lodging_capacity: None,
16138 equip_slot: None,
16139 armor_physical: None,
16140 resists: vec![],
16141 hand_slots: None,
16142 listable: None,
16143 },
16144 );
16145 state.worn.insert(
16146 BodySlot::Back,
16147 flatland_protocol::ItemStack {
16148 template_id: "travel_backpack".into(),
16149 quantity: 1,
16150 item_instance_id: Some(uuid::Uuid::from_u128(12)),
16151 props: Default::default(),
16152 status_bindings: Vec::new(),
16153 contents: Vec::new(),
16154 display_name: None,
16155 category: Some("container".into()),
16156 base_mass: None,
16157 base_volume: None,
16158 capacity_volume: None,
16159 stackable: None,
16160 world_placeable: None,
16161 worker_lodging_capacity: None,
16162 equip_slot: None,
16163 armor_physical: None,
16164 resists: vec![],
16165 hand_slots: None,
16166 listable: None,
16167 },
16168 );
16169
16170 let rows = state.worn_rows();
16171 assert_eq!(rows.len(), 4);
16173 assert_eq!(rows[0].stack.template_id, "cloth_cap");
16174 assert!(rows[0].is_equip_shell);
16175 assert_eq!(rows[1].stack.template_id, "travel_backpack");
16176 assert!(rows[1].is_equip_shell);
16177 assert_eq!(rows[2].stack.template_id, "simple_belt");
16178 assert!(rows[2].is_equip_shell);
16179 assert_eq!(rows[3].stack.template_id, "leather_pouch");
16180 assert_eq!(rows[3].depth, 1);
16181 assert!(!rows[3].is_equip_shell);
16182 }
16183
16184 #[test]
16185 fn move_destinations_for_offers_belt_loop_but_hides_armor_slots() {
16186 let mut state = sample_state();
16187 state.worn.insert(
16188 BodySlot::Waist,
16189 flatland_protocol::ItemStack {
16190 template_id: "simple_belt".into(),
16191 quantity: 1,
16192 item_instance_id: Some(uuid::Uuid::from_u128(20)),
16193 props: Default::default(),
16194 status_bindings: Vec::new(),
16195 contents: Vec::new(),
16196 display_name: Some("Simple Belt".into()),
16197 category: Some("container".into()),
16198 base_mass: None,
16199 base_volume: None,
16200 capacity_volume: None,
16201 stackable: None,
16202 world_placeable: None,
16203 worker_lodging_capacity: None,
16204 equip_slot: None,
16205 armor_physical: None,
16206 resists: vec![],
16207 hand_slots: None,
16208 listable: None,
16209 },
16210 );
16211 state.worn.insert(
16212 BodySlot::Head,
16213 flatland_protocol::ItemStack {
16214 template_id: "cloth_cap".into(),
16215 quantity: 1,
16216 item_instance_id: Some(uuid::Uuid::from_u128(21)),
16217 props: Default::default(),
16218 status_bindings: Vec::new(),
16219 contents: Vec::new(),
16220 display_name: Some("Cloth Cap".into()),
16221 category: Some("armor".into()),
16222 base_mass: None,
16223 base_volume: None,
16224 capacity_volume: None,
16225 stackable: None,
16226 world_placeable: None,
16227 worker_lodging_capacity: None,
16228 equip_slot: None,
16229 armor_physical: None,
16230 resists: vec![],
16231 hand_slots: None,
16232 listable: None,
16233 },
16234 );
16235
16236 let opts = state.move_destinations_for(
16237 &flatland_protocol::InventoryLocation::Root,
16238 None,
16239 None,
16240 "leather_pouch",
16241 );
16242 assert!(
16243 opts.iter().any(|o| matches!(
16244 &o.kind,
16245 MoveOptionKind::Move { location, .. }
16246 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
16247 )),
16248 "belt loop must be offered when moving a pouch"
16249 );
16250 assert!(
16251 !opts.iter().any(|o| matches!(
16252 &o.kind,
16253 MoveOptionKind::Move { location, .. }
16254 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Head }
16255 )),
16256 "armor slots can't hold other items and must not appear as move destinations"
16257 );
16258 let belt_opt = opts
16259 .iter()
16260 .find(|o| matches!(
16261 &o.kind,
16262 MoveOptionKind::Move { location, .. }
16263 if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
16264 ))
16265 .unwrap();
16266 assert!(belt_opt.label.contains("belt loop"));
16267
16268 let opts = state.move_destinations_for(
16269 &flatland_protocol::InventoryLocation::Root,
16270 None,
16271 None,
16272 "lumber",
16273 );
16274 assert!(
16275 !opts.iter().any(|o| o.label.contains("belt loop")),
16276 "loose materials must not target the belt shell — only nested pouches"
16277 );
16278 }
16279
16280 #[test]
16281 fn move_destinations_for_offers_dimensional_pouch_on_belt() {
16282 let mut state = sample_state();
16283 let belt_id = uuid::Uuid::from_u128(30);
16284 let pouch_id = uuid::Uuid::from_u128(31);
16285 state.worn.insert(
16286 BodySlot::Waist,
16287 flatland_protocol::ItemStack {
16288 template_id: "simple_belt".into(),
16289 quantity: 1,
16290 item_instance_id: Some(belt_id),
16291 props: Default::default(),
16292 status_bindings: Vec::new(),
16293 world_placeable: None,
16294 worker_lodging_capacity: None,
16295 equip_slot: None,
16296 armor_physical: None,
16297 resists: vec![],
16298 hand_slots: None,
16299 contents: vec![flatland_protocol::ItemStack {
16300 template_id: "dimensional_pouch".into(),
16301 quantity: 1,
16302 item_instance_id: Some(pouch_id),
16303 props: Default::default(),
16304 status_bindings: Vec::new(),
16305 contents: Vec::new(),
16306 display_name: Some("Dimensional Pouch".into()),
16307 category: Some("container".into()),
16308 base_mass: None,
16309 base_volume: None,
16310 capacity_volume: Some(200.0),
16311 stackable: None,
16312 world_placeable: None,
16313 worker_lodging_capacity: None,
16314 equip_slot: None,
16315 armor_physical: None,
16316 resists: vec![],
16317 hand_slots: None,
16318 listable: None,
16319 }],
16320 display_name: Some("Simple Belt".into()),
16321 category: Some("container".into()),
16322 base_mass: None,
16323 base_volume: None,
16324 capacity_volume: None,
16325 stackable: None,
16326 listable: None,
16327 },
16328 );
16329
16330 let opts = state.move_destinations_for(
16331 &flatland_protocol::InventoryLocation::Root,
16332 None,
16333 None,
16334 "iron_ore",
16335 );
16336 assert!(
16337 opts.iter().any(|o| matches!(
16338 &o.kind,
16339 MoveOptionKind::Move {
16340 location,
16341 parent_instance_id,
16342 ..
16343 } if *location == flatland_protocol::InventoryLocation::Worn { slot: BodySlot::Waist }
16344 && *parent_instance_id == Some(pouch_id)
16345 )),
16346 "dimensional pouch clipped on belt must accept loose items"
16347 );
16348 assert!(
16349 opts.iter().any(|o| o.label.contains("Dimensional Pouch")),
16350 "destination label should name the pouch"
16351 );
16352 }
16353
16354 #[test]
16355 fn container_volume_label_on_placed_chest_shell() {
16356 let mut state = sample_state();
16357 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16358 id: "chest-1".into(),
16359 template_id: "wooden_chest_small".into(),
16360 display_name: "Camp Chest".into(),
16361 x: 129.0,
16362 y: 128.0,
16363 z: 0.0,
16364 locked: false,
16365 accessible: true,
16366 owner_character_id: None,
16367 contents: vec![flatland_protocol::ItemStack {
16368 template_id: "iron_ore".into(),
16369 quantity: 2,
16370 item_instance_id: None,
16371 props: Default::default(),
16372 status_bindings: Vec::new(),
16373 contents: Vec::new(),
16374 display_name: None,
16375 category: None,
16376 base_mass: None,
16377 base_volume: Some(2.0),
16378 capacity_volume: None,
16379 stackable: None,
16380 world_placeable: None,
16381 worker_lodging_capacity: None,
16382 equip_slot: None,
16383 armor_physical: None,
16384 resists: vec![],
16385 hand_slots: None,
16386 listable: None,
16387 }],
16388 lock_id: None,
16389 capacity_volume: Some(60.0),
16390 item_instance_id: Some(uuid::Uuid::from_u128(4)),
16391 tile_id: None,
16392 worker_lodging_capacity: None,
16393 blocking: false,
16394 blocking_radius_m: 0.0,
16395 building_id: None,
16396 }];
16397 let nearby = state.nearby_containers();
16398 let label = state.container_volume_label(&nearby[0].rows[0]);
16399 assert!(
16400 label.contains("vol 4/60"),
16401 "expected used/cap in label, got {label}"
16402 );
16403 assert!(
16404 label.contains("56 free"),
16405 "expected free space, got {label}"
16406 );
16407 }
16408
16409 #[test]
16410 fn key_pair_chest_label_from_placed_lock_id() {
16411 let mut state = sample_state();
16412 let owner = uuid::Uuid::from_u128(77);
16413 state.character_id = Some(owner);
16414 let lock = uuid::Uuid::from_u128(99).to_string();
16415 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16416 id: "chest-1".into(),
16417 template_id: "wooden_chest_small".into(),
16418 display_name: "Barry's Loot #a3f2".into(),
16419 x: 129.0,
16420 y: 128.0,
16421 z: 0.0,
16422 locked: true,
16423 accessible: true,
16424 owner_character_id: Some(owner),
16425 contents: Vec::new(),
16426 lock_id: Some(lock.clone()),
16427 capacity_volume: None,
16428 item_instance_id: Some(uuid::Uuid::from_u128(4)),
16429 tile_id: None,
16430 worker_lodging_capacity: None,
16431 blocking: false,
16432 blocking_radius_m: 0.0,
16433 building_id: None,
16434 }];
16435 let key_id = uuid::Uuid::from_u128(5);
16436 let key = flatland_protocol::ItemStack {
16437 template_id: KEY_TEMPLATE.into(),
16438 quantity: 1,
16439 item_instance_id: Some(key_id),
16440 props: BTreeMap::from([
16441 (PROP_OPENS_LOCK_ID.into(), lock),
16442 (
16443 PROP_OPENS_CONTAINER_NAME.into(),
16444 "Barry's Loot #a3f2".into(),
16445 ),
16446 ]),
16447 status_bindings: Vec::new(),
16448 contents: Vec::new(),
16449 display_name: Some("Container Key".into()),
16450 category: Some("key".into()),
16451 base_mass: None,
16452 base_volume: None,
16453 capacity_volume: None,
16454 stackable: None,
16455 world_placeable: None,
16456 worker_lodging_capacity: None,
16457 equip_slot: None,
16458 armor_physical: None,
16459 resists: vec![],
16460 hand_slots: None,
16461 listable: None,
16462 };
16463 state.inventory_stacks = vec![key.clone()];
16464 assert_eq!(
16465 state.key_pair_chest_label(&key).as_deref(),
16466 Some("Barry's Loot #a3f2")
16467 );
16468 assert!(state.key_drop_blocked(&key));
16469 }
16470
16471 #[test]
16472 fn key_pair_chest_label_prefers_cached_name_when_chest_out_of_range() {
16473 let mut state = sample_state();
16474 let lock = uuid::Uuid::from_u128(101).to_string();
16475 let key = flatland_protocol::ItemStack {
16476 template_id: KEY_TEMPLATE.into(),
16477 quantity: 1,
16478 item_instance_id: Some(uuid::Uuid::from_u128(7)),
16479 props: BTreeMap::from([
16480 (PROP_OPENS_LOCK_ID.into(), lock),
16481 (PROP_OPENS_CONTAINER_NAME.into(), "Camp Stash".into()),
16482 ]),
16483 status_bindings: Vec::new(),
16484 contents: Vec::new(),
16485 display_name: None,
16486 category: Some("key".into()),
16487 base_mass: None,
16488 base_volume: None,
16489 capacity_volume: None,
16490 stackable: None,
16491 world_placeable: None,
16492 worker_lodging_capacity: None,
16493 equip_slot: None,
16494 armor_physical: None,
16495 resists: vec![],
16496 hand_slots: None,
16497 listable: None,
16498 };
16499 state.placed_containers.clear();
16500 assert_eq!(
16501 state.key_pair_chest_label(&key).as_deref(),
16502 Some("Camp Stash")
16503 );
16504 }
16505
16506 #[test]
16507 fn key_drop_allowed_when_paired_chest_unlocked() {
16508 let mut state = sample_state();
16509 let lock = uuid::Uuid::from_u128(100).to_string();
16510 let key_id = uuid::Uuid::from_u128(6);
16511 state.placed_containers = vec![flatland_protocol::PlacedContainerView {
16512 id: "chest-1".into(),
16513 template_id: "wooden_chest_small".into(),
16514 display_name: "Camp Chest".into(),
16515 x: 129.0,
16516 y: 128.0,
16517 z: 0.0,
16518 locked: false,
16519 accessible: true,
16520 owner_character_id: None,
16521 contents: Vec::new(),
16522 lock_id: Some(lock.clone()),
16523 capacity_volume: None,
16524 item_instance_id: None,
16525 tile_id: None,
16526 worker_lodging_capacity: None,
16527 blocking: false,
16528 blocking_radius_m: 0.0,
16529 building_id: None,
16530 }];
16531 let key = flatland_protocol::ItemStack {
16532 template_id: KEY_TEMPLATE.into(),
16533 quantity: 1,
16534 item_instance_id: Some(key_id),
16535 props: BTreeMap::from([(PROP_OPENS_LOCK_ID.into(), lock)]),
16536 status_bindings: Vec::new(),
16537 contents: Vec::new(),
16538 display_name: None,
16539 category: Some("key".into()),
16540 base_mass: None,
16541 base_volume: None,
16542 capacity_volume: None,
16543 stackable: None,
16544 world_placeable: None,
16545 worker_lodging_capacity: None,
16546 equip_slot: None,
16547 armor_physical: None,
16548 resists: vec![],
16549 hand_slots: None,
16550 listable: None,
16551 };
16552 state.inventory_stacks = vec![key.clone()];
16553 assert!(!state.key_drop_blocked(&key));
16554 let opts = state.move_destinations_for(
16555 &flatland_protocol::InventoryLocation::Root,
16556 None,
16557 Some(key_id),
16558 KEY_TEMPLATE,
16559 );
16560 assert!(opts.iter().any(|o| o.kind == MoveOptionKind::Drop));
16561 }
16562
16563 #[test]
16564 fn combat_hud_refreshes_progression_xp_when_entity_stale() {
16565 use flatland_protocol::{CombatHud, ProgressionXp, ProgressionCurve};
16566
16567 let mut state = sample_state();
16568 let curve = ProgressionCurve::default();
16569 let bootstrap = ProgressionXp::bootstrap_new(
16570 curve.baseline_display,
16571 curve.xp_base,
16572 curve.xp_growth,
16573 );
16574 let mut fresh = bootstrap.clone();
16575 fresh.strength += 0.08;
16576 if let Some(player) = state.player.as_mut() {
16577 player.progression_xp = Some(bootstrap);
16578 }
16579
16580 let combat = CombatHud {
16581 progression_xp: Some(fresh.clone()),
16582 progression_baseline: curve.baseline_display,
16583 progression_xp_base: curve.xp_base,
16584 progression_xp_growth: curve.xp_growth,
16585 attributes: state.player.as_ref().and_then(|p| p.attributes),
16586 skills: state.player.as_ref().and_then(|p| p.skills.clone()),
16587 ..CombatHud::default()
16588 };
16589 state.apply_combat_hud(&combat);
16590
16591 let xp = state
16592 .player
16593 .as_ref()
16594 .and_then(|p| p.progression_xp.as_ref())
16595 .expect("xp");
16596 assert!((xp.strength - fresh.strength).abs() < 0.001);
16597 assert!(state.progression_curve.is_some());
16598 }
16599
16600 #[test]
16601 fn combat_hud_syncs_known_abilities_and_hotbar() {
16602 use flatland_protocol::CombatHud;
16603
16604 let mut state = sample_state();
16605 let combat = CombatHud {
16606 known_abilities: vec!["unarmed".into(), "fireball".into()],
16607 hotbar: vec![Some("fireball".into()), None, Some("unarmed".into())],
16608 max_abilities_per_rotation: 4,
16609 ability_id: "short_sword_slash".into(),
16610 ..CombatHud::default()
16611 };
16612 state.apply_combat_hud(&combat);
16613
16614 assert_eq!(state.known_abilities, vec!["unarmed", "fireball"]);
16615 assert_eq!(state.hotbar_ability(1), Some("fireball"));
16616 assert_eq!(state.hotbar_ability(2), None);
16617 assert_eq!(state.hotbar_ability(3), Some("unarmed"));
16618 assert_eq!(state.max_abilities_per_rotation, 4);
16619 let choices = state.loadout_ability_choices();
16620 assert!(choices.iter().any(|a| a == "short_sword_slash"));
16621 assert!(choices.iter().any(|a| a == "fireball"));
16622 }
16623
16624 #[test]
16625 fn loadout_hotbar_choices_include_inventory_consumables() {
16626 let mut state = sample_state();
16627 state.known_abilities = vec!["unarmed".into()];
16628 state.weapon_ability_id = "unarmed".into();
16629 state.inventory_stacks = vec![flatland_protocol::ItemStack {
16630 template_id: "bottle_of_water".into(),
16631 quantity: 3,
16632 item_instance_id: Some(uuid::Uuid::from_u128(9)),
16633 display_name: Some("Bottle of Water".into()),
16634 category: Some("consumable".into()),
16635 ..Default::default()
16636 }];
16637 state.inventory.insert("bottle_of_water".into(), 3);
16638 state.inventory_hints.insert(
16639 "bottle_of_water".into(),
16640 InventoryHint {
16641 display_name: "Bottle of Water".into(),
16642 category: "consumable".into(),
16643 ..Default::default()
16644 },
16645 );
16646
16647 let choices = state.loadout_hotbar_choices();
16648 assert!(choices.iter().any(|c| c.binding == "unarmed"));
16649 let water = choices
16650 .iter()
16651 .find(|c| c.binding == "item:bottle_of_water")
16652 .expect("water binding");
16653 assert_eq!(water.meta.as_deref(), Some("use"));
16654 assert!(water.label.contains("Water"));
16655 assert_eq!(
16656 state.hotbar_slot_label(1),
16657 None,
16658 "unbound until set"
16659 );
16660 state.hotbar = vec![None, None, None, None, Some("item:bottle_of_water".into())];
16661 assert_eq!(
16662 state.hotbar_slot_label(5).as_deref(),
16663 Some("Bottle of Water×3")
16664 );
16665 }
16666
16667 #[test]
16668 fn storage_store_options_excludes_hand_equipped() {
16669 let mut state = sample_state();
16670 let sword_id = uuid::Uuid::from_u128(11);
16671 let ore_id = uuid::Uuid::from_u128(22);
16672 state.inventory_stacks = vec![
16673 flatland_protocol::ItemStack {
16674 template_id: "short_sword".into(),
16675 quantity: 1,
16676 item_instance_id: Some(sword_id),
16677 display_name: Some("Short Sword".into()),
16678 category: Some("weapon".into()),
16679 ..Default::default()
16680 },
16681 flatland_protocol::ItemStack {
16682 template_id: "iron_ore".into(),
16683 quantity: 5,
16684 item_instance_id: Some(ore_id),
16685 display_name: Some("Iron Ore".into()),
16686 category: Some("resource".into()),
16687 ..Default::default()
16688 },
16689 ];
16690 state.mainhand_template_id = Some("short_sword".into());
16691 state.mainhand_instance_id = Some(sword_id);
16692
16693 let opts = state.storage_store_options();
16694 assert_eq!(opts.len(), 1);
16695 assert_eq!(opts[0].item_instance_id, ore_id);
16696 assert!(state.hand_equipped_instance_ids().contains(&sword_id));
16697 }
16698
16699 #[test]
16700 fn loose_consumable_move_picker_offers_use_and_storage() {
16701 let mut state = sample_state();
16702 let inst = uuid::Uuid::from_u128(77);
16703 state.inventory_stacks = vec![flatland_protocol::ItemStack {
16704 template_id: "carrot".into(),
16705 quantity: 2,
16706 item_instance_id: Some(inst),
16707 props: Default::default(),
16708 status_bindings: Vec::new(),
16709 contents: Vec::new(),
16710 display_name: Some("Wild Carrot".into()),
16711 category: Some("consumable".into()),
16712 base_mass: None,
16713 base_volume: None,
16714 capacity_volume: None,
16715 stackable: Some(true),
16716 world_placeable: None,
16717 worker_lodging_capacity: None,
16718 equip_slot: None,
16719 armor_physical: None,
16720 resists: vec![],
16721 hand_slots: None,
16722 listable: None,
16723 }];
16724 state.inventory_hints.insert(
16725 "carrot".into(),
16726 InventoryHint {
16727 display_name: "Wild Carrot".into(),
16728 category: "consumable".into(),
16729 base_mass: Some(0.15),
16730 base_volume: Some(0.3),
16731 capacity_volume: None,
16732 stackable: true,
16733 listable: true,
16734 },
16735 );
16736 state.show_inventory_menu = true;
16737 state.inventory_menu_index = 0;
16738
16739 let row = state.inventory_selected_row().expect("carrot row");
16740 let mut options = state.move_destinations_for(
16741 &row.from,
16742 row.from_parent_instance_id,
16743 row.stack.item_instance_id,
16744 &row.stack.template_id,
16745 );
16746 if row.from == flatland_protocol::InventoryLocation::Root
16747 && state.inventory_item_category(&row.stack.template_id) == Some("consumable")
16748 {
16749 options.insert(
16750 0,
16751 MoveOption {
16752 label: "Use (eat / drink)".into(),
16753 kind: MoveOptionKind::Use,
16754 },
16755 );
16756 }
16757
16758 assert_eq!(options.first().map(|o| &o.label), Some(&"Use (eat / drink)".into()));
16759 assert_eq!(options.first().map(|o| &o.kind), Some(&MoveOptionKind::Use));
16760 assert!(options.iter().any(|o| matches!(o.kind, MoveOptionKind::Drop)));
16761 }
16762
16763 #[test]
16764 fn inventory_category_group_order_is_stable() {
16765 assert_eq!(inventory_category_group("weapon").0, "Weapons");
16766 assert_eq!(inventory_category_group("armor").0, "Armor");
16767 assert_eq!(inventory_category_group("consumable").0, "Consumables");
16768 assert_eq!(inventory_category_group("resource").0, "Resources");
16769 assert_eq!(inventory_category_group("container").0, "Containers");
16770 assert!(inventory_category_group("weapon").1 < inventory_category_group("armor").1);
16771 assert!(inventory_category_group("armor").1 < inventory_category_group("other").1);
16772 }
16773
16774 #[test]
16775 fn page_list_index_clamps_without_wrap() {
16776 assert_eq!(page_list_index(0, -1, 25), 0);
16777 assert_eq!(page_list_index(0, 1, 25), 10);
16778 assert_eq!(page_list_index(12, 1, 25), 22);
16779 assert_eq!(page_list_index(22, 1, 25), 24);
16780 assert_eq!(page_list_index(5, 1, 0), 0);
16781 assert_eq!(page_list_index(3, -1, 8), 0);
16782 }
16783
16784 #[test]
16785 fn inventory_filter_hides_non_matching_person_items() {
16786 let mut state = sample_state();
16787 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
16788 sword.display_name = Some("Iron Sword".into());
16789 sword.category = Some("weapon".into());
16790 let mut herb = flatland_protocol::ItemStack::simple("wild_herb", 3);
16791 herb.display_name = Some("Wild Herb".into());
16792 herb.category = Some("consumable".into());
16793 state.inventory_stacks = vec![sword, herb];
16794 state.inventory_tab = InventoryTab::OnPerson;
16795 state.inventory_filter = "sword".into();
16796
16797 let rows = state.inventory_selectable_rows();
16798 assert_eq!(rows.len(), 1);
16799 assert_eq!(rows[0].stack.template_id, "iron_sword");
16800
16801 let lines = state.inventory_browser_lines();
16802 assert!(lines.iter().any(|l| matches!(
16803 l,
16804 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("sword")
16805 )));
16806 assert!(!lines.iter().any(|l| matches!(
16807 l,
16808 InventoryBrowserLine::Item { text, .. } if text.to_ascii_lowercase().contains("herb")
16809 )));
16810 }
16811
16812 #[test]
16813 fn duplicate_identical_instanced_items_use_hover_tooltip_not_inline_hash() {
16814 let mut state = sample_state();
16815 let id_a = uuid::Uuid::from_u128(0xa1);
16816 let id_b = uuid::Uuid::from_u128(0xb2);
16817 let mut sword_a = flatland_protocol::ItemStack::simple("iron_sword", 1);
16818 sword_a.display_name = Some("Iron Sword".into());
16819 sword_a.category = Some("weapon".into());
16820 sword_a.item_instance_id = Some(id_a);
16821 let mut sword_b = flatland_protocol::ItemStack::simple("iron_sword", 1);
16822 sword_b.display_name = Some("Iron Sword".into());
16823 sword_b.category = Some("weapon".into());
16824 sword_b.item_instance_id = Some(id_b);
16825 state.inventory_stacks = vec![sword_a, sword_b];
16826 state.inventory_tab = InventoryTab::OnPerson;
16827
16828 let lines = state.inventory_browser_lines();
16829 let items: Vec<_> = lines
16830 .iter()
16831 .filter_map(|l| match l {
16832 InventoryBrowserLine::Item {
16833 title,
16834 instance_tooltip,
16835 ..
16836 } => Some((title.clone(), instance_tooltip.clone())),
16837 _ => None,
16838 })
16839 .collect();
16840 assert_eq!(items.len(), 2);
16841 for (title, tip) in &items {
16842 assert!(
16843 !title.contains('#'),
16844 "title should not show instance suffix: {title}"
16845 );
16846 assert!(
16847 tip.is_some(),
16848 "two identical rows should expose instance on hover"
16849 );
16850 }
16851
16852 state.inventory_stacks.pop();
16853 let lines = state.inventory_browser_lines();
16854 let one = lines.iter().find_map(|l| match l {
16855 InventoryBrowserLine::Item {
16856 title,
16857 instance_tooltip,
16858 ..
16859 } => Some((title.clone(), instance_tooltip.clone())),
16860 _ => None,
16861 });
16862 let (title, tip) = one.expect("one sword row");
16863 assert!(!title.contains('#'));
16864 assert!(tip.is_none(), "single row should not need instance tooltip");
16865 }
16866
16867 #[test]
16868 fn inventory_person_rows_group_by_category() {
16869 let mut state = sample_state();
16870 let mut sword = flatland_protocol::ItemStack::simple("iron_sword", 1);
16871 sword.category = Some("weapon".into());
16872 sword.display_name = Some("Iron Sword".into());
16873 let mut ore = flatland_protocol::ItemStack::simple("iron_ore", 2);
16874 ore.category = Some("resource".into());
16875 ore.display_name = Some("Iron Ore".into());
16876 let mut potion = flatland_protocol::ItemStack::simple("health_potion", 1);
16877 potion.category = Some("consumable".into());
16878 potion.display_name = Some("Health Potion".into());
16879 state.inventory_stacks = vec![ore, potion, sword];
16880 state.inventory_tab = InventoryTab::OnPerson;
16881
16882 let lines = state.inventory_browser_lines();
16883 let labels: Vec<&str> = lines
16884 .iter()
16885 .filter_map(|l| match l {
16886 InventoryBrowserLine::SlotLabel(s) => Some(s.as_str()),
16887 _ => None,
16888 })
16889 .collect();
16890 assert!(
16891 labels.iter().any(|s| s.contains("Weapons")),
16892 "expected Weapons group: {labels:?}"
16893 );
16894 assert!(labels.iter().any(|s| s.contains("Consumables")));
16895 assert!(labels.iter().any(|s| s.contains("Resources")));
16896
16897 let weapon_pos = labels.iter().position(|s| s.contains("Weapons")).unwrap();
16898 let consumable_pos = labels.iter().position(|s| s.contains("Consumables")).unwrap();
16899 let resource_pos = labels.iter().position(|s| s.contains("Resources")).unwrap();
16900 assert!(weapon_pos < consumable_pos);
16901 assert!(consumable_pos < resource_pos);
16902 }
16903
16904 #[test]
16905 fn inventory_tab_cycle_resets_selection() {
16906 let mut state = sample_state();
16907 state.inventory_tab = InventoryTab::OnPerson;
16908 state.inventory_menu_index = 3;
16909 state.inventory_tab = state.inventory_tab.cycle(true);
16910 assert_eq!(state.inventory_tab, InventoryTab::Nearby);
16911 assert_eq!(InventoryTab::Nearby.label(), "Nearby storage");
16913 assert_eq!(InventoryTab::OnPerson.cycle(true), InventoryTab::Nearby);
16914 assert_eq!(InventoryTab::Nearby.cycle(true), InventoryTab::OnPerson);
16915 assert_eq!(InventoryTab::OnPerson.cycle(false), InventoryTab::Nearby);
16916 }
16917
16918 #[test]
16919 fn parse_bank_copper_amount_blank_and_zero_mean_all() {
16920 assert_eq!(parse_bank_copper_amount(""), Some(0));
16921 assert_eq!(parse_bank_copper_amount(" "), Some(0));
16922 assert_eq!(parse_bank_copper_amount("0"), Some(0));
16923 assert_eq!(parse_bank_copper_amount("250"), Some(250));
16924 assert_eq!(parse_bank_copper_amount("nope"), None);
16925 }
16926
16927 #[test]
16928 fn parse_storage_quantity_blank_and_zero_mean_all() {
16929 assert_eq!(parse_storage_quantity(""), Some(None));
16930 assert_eq!(parse_storage_quantity(" "), Some(None));
16931 assert_eq!(parse_storage_quantity("0"), Some(None));
16932 assert_eq!(parse_storage_quantity("3"), Some(Some(3)));
16933 assert_eq!(parse_storage_quantity("nope"), None);
16934 }
16935
16936 #[test]
16937 fn path_stuck_repathing_is_hud_noise_but_no_lodging_is_not() {
16938 assert!(worker_error_is_hud_noise("path stuck — repathing"));
16939 assert!(worker_error_is_hud_noise("path stuck — nudged clear, repathing"));
16940 assert!(worker_error_is_hud_noise("returned to lodging after path failures"));
16941 assert!(!worker_error_is_hud_noise(
16943 "path stuck — no lodging to reset to"
16944 ));
16945 }
16946
16947 #[test]
16948 fn leaving_building_restores_outdoor_z_bands() {
16949 use flatland_protocol::{InteriorMapView, ZPlatformView};
16950
16951 let mut state = sample_state();
16952 state.z_platforms.clear();
16953 state.z_transitions.clear();
16954 state.player.as_mut().unwrap().inside_building = Some("broker_hut".into());
16955 state.interior_map = Some(InteriorMapView {
16956 building_id: "broker_hut".into(),
16957 blueprint_id: "broker_hut".into(),
16958 background_color: "#000".into(),
16959 default_floor_color: None,
16960 floor_height_m: 3.0,
16961 z_platforms: vec![ZPlatformView {
16962 id: "floor_0".into(),
16963 z: 0.0,
16964 x0: 0.0,
16965 y0: 0.0,
16966 x1: 8.0,
16967 y1: 8.0,
16968 }],
16969 z_transitions: vec![],
16970 rooms: vec![],
16971 room_doors: vec![],
16972 });
16973 state.sync_interior_map_context();
16974 assert_eq!(state.z_platforms.len(), 1, "indoors installs interior platforms");
16975 assert!(state.z_bands_outdoor_backup.is_some());
16976
16977 state.player.as_mut().unwrap().inside_building = None;
16978 state.sync_interior_map_context();
16979 assert!(
16980 state.z_platforms.is_empty(),
16981 "leaving must restore outdoor bands (empty), not leave interior platforms"
16982 );
16983 assert!(state.z_bands_outdoor_backup.is_none());
16984 assert!(state.interior_map.is_none());
16985 }
16986}